diff --git a/src/uix/eidos/components/field/field-optional-indicator.svelte b/src/uix/eidos/components/field/field-optional-indicator.svelte new file mode 100644 index 000000000..bd1da0ef6 --- /dev/null +++ b/src/uix/eidos/components/field/field-optional-indicator.svelte @@ -0,0 +1,12 @@ + + + + {#if children} + {@render children()} + {/if} + diff --git a/src/uix/eidos/components/field/field.css b/src/uix/eidos/components/field/field.css index d840261ae..151001470 100644 --- a/src/uix/eidos/components/field/field.css +++ b/src/uix/eidos/components/field/field.css @@ -90,6 +90,11 @@ --_field-control-font-size: var(--field-control-font-size-xl); } +/* surface: filled, default treatment. */ +[data-field][data-variant='surface'] { + --_field-control-bg: var(--field-control-bg); +} + /* outline: transparent fill, border stays visible. */ [data-field][data-variant='outline'] { --_field-control-bg: var(--field-control-ghost-bg); @@ -168,6 +173,14 @@ line-height: 1; } +[data-field-optional-indicator] { + color: var(--color-content-muted); + font-size: 0.85em; + font-weight: var(--font-weight-normal, 400); + line-height: 1; + margin-inline-start: var(--space-1); +} + [data-field-control] { display: inline-flex; align-items: center; diff --git a/src/uix/eidos/components/field/index.ts b/src/uix/eidos/components/field/index.ts index adc21b7a0..5d5d8c4a8 100644 --- a/src/uix/eidos/components/field/index.ts +++ b/src/uix/eidos/components/field/index.ts @@ -13,6 +13,7 @@ import FieldComponent from './field.svelte'; import Label from './field-label.svelte'; import RequiredIndicator from './field-required-indicator.svelte'; +import OptionalIndicator from './field-optional-indicator.svelte'; import Control from './field-control.svelte'; import Input from './field-input.svelte'; import HelperText from './field-helper-text.svelte'; @@ -23,6 +24,7 @@ import Suffix from './field-suffix.svelte'; type FieldNamespace = typeof FieldComponent & { Label: typeof Label; RequiredIndicator: typeof RequiredIndicator; + OptionalIndicator: typeof OptionalIndicator; Control: typeof Control; Input: typeof Input; HelperText: typeof HelperText; @@ -34,6 +36,7 @@ type FieldNamespace = typeof FieldComponent & { const Field = FieldComponent as FieldNamespace; Field.Label = Label; Field.RequiredIndicator = RequiredIndicator; +Field.OptionalIndicator = OptionalIndicator; Field.Control = Control; Field.Input = Input; Field.HelperText = HelperText; @@ -49,6 +52,7 @@ export type { FieldProps, FieldLabelProps as LabelProps, FieldRequiredIndicatorProps as RequiredIndicatorProps, + FieldOptionalIndicatorProps as OptionalIndicatorProps, FieldControlProps as ControlProps, FieldInputProps as InputProps, FieldHelperTextProps as HelperTextProps, diff --git a/src/uix/eidos/components/field/types.ts b/src/uix/eidos/components/field/types.ts index b677cf882..5e42d38a6 100644 --- a/src/uix/eidos/components/field/types.ts +++ b/src/uix/eidos/components/field/types.ts @@ -8,6 +8,7 @@ import type { ProviderProps, FieldSnippetProps as FieldProviderSnippetProps, RequiredIndicatorProps, + OptionalIndicatorProps, SuffixProps } from '$soma/components/field'; import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'; @@ -36,6 +37,7 @@ export type FieldProps = ProviderProps & { export type FieldLabelProps = LabelProps; export type FieldRequiredIndicatorProps = RequiredIndicatorProps; +export type FieldOptionalIndicatorProps = OptionalIndicatorProps; export type FieldControlProps = ControlProps; export type FieldInputProps = InputProps; export type FieldHelperTextProps = HelperTextProps; diff --git a/src/uix/morfo/components/field.ts b/src/uix/morfo/components/field.ts index 54e370f96..f9032777f 100644 --- a/src/uix/morfo/components/field.ts +++ b/src/uix/morfo/components/field.ts @@ -6,7 +6,9 @@ export const fieldMorfo = { kebab: 'field', scope: ['soma'], texts: { - label: '#?components.field.label|Field' + label: '#?components.field.label|Field', + 'required-indicator': '#?components.field.required-indicator|*', + 'optional-indicator': '#?components.field.optional-indicator|(optional)' }, parts: [ { @@ -64,6 +66,16 @@ export const fieldMorfo = { data: [{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' }], aria: [{ attr: 'aria-hidden', value: v.literal('true') }] }, + { + name: 'OptionalIndicator', + kebab: 'optional-indicator', + archetype: 'indicator', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' }], + aria: [{ attr: 'aria-hidden', value: v.literal('true') }] + }, { name: 'Control', kebab: 'control', diff --git a/src/uix/soma/components/field/components/field-label.svelte b/src/uix/soma/components/field/components/field-label.svelte index 7c6294835..6e39c83b2 100644 --- a/src/uix/soma/components/field/components/field-label.svelte +++ b/src/uix/soma/components/field/components/field-label.svelte @@ -5,7 +5,8 @@ } from '$libs/reactive'; import { mergeProps } from '../../../props'; import { createId } from '../../../id'; - import { FieldLabelProvider } from '../field-provider.svelte'; + import { FieldLabelProvider, FieldProvider } from '../field-provider.svelte'; + import { FIELD_LANGS } from '../langs'; import type { FieldLabelProps } from '../types'; const uid = $props.id(); @@ -18,6 +19,8 @@ ...restProps }: FieldLabelProps = $props(); + const provider = FieldProvider.require(); + const state = FieldLabelProvider.create({ id: readableActive(() => id), ref: writableActive( @@ -27,12 +30,19 @@ }); const mergedProps = $derived(mergeProps(restProps, state.props)); + + // Auto-render localized label text when no children/child are passed. + const defaultText = $derived(provider.soma.langs.ts(FIELD_LANGS.LABEL) ?? 'Field'); {#if child} {@render child({ props: mergedProps })} {:else} {/if} diff --git a/src/uix/soma/components/field/components/field-optional-indicator.svelte b/src/uix/soma/components/field/components/field-optional-indicator.svelte new file mode 100644 index 000000000..ac5a51968 --- /dev/null +++ b/src/uix/soma/components/field/components/field-optional-indicator.svelte @@ -0,0 +1,52 @@ + + +{#if shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {#if children} + {@render children()} + {:else} + {defaultText} + {/if} + + {/if} +{/if} diff --git a/src/uix/soma/components/field/exports.ts b/src/uix/soma/components/field/exports.ts index 0db2eff27..b6daf4568 100644 --- a/src/uix/soma/components/field/exports.ts +++ b/src/uix/soma/components/field/exports.ts @@ -1,6 +1,7 @@ export { default as Provider } from './components/field.svelte'; export { default as Label } from './components/field-label.svelte'; export { default as RequiredIndicator } from './components/field-required-indicator.svelte'; +export { default as OptionalIndicator } from './components/field-optional-indicator.svelte'; export { default as Control } from './components/field-control.svelte'; export { default as Input } from './components/field-input.svelte'; export { default as HelperText } from './components/field-helper-text.svelte'; @@ -12,6 +13,7 @@ export type { FieldProps as ProviderProps, FieldLabelProps as LabelProps, FieldRequiredIndicatorProps as RequiredIndicatorProps, + FieldOptionalIndicatorProps as OptionalIndicatorProps, FieldControlProps as ControlProps, FieldInputProps as InputProps, FieldHelperTextProps as HelperTextProps, diff --git a/src/uix/soma/components/field/field-provider.svelte.ts b/src/uix/soma/components/field/field-provider.svelte.ts index 646af675c..930ef8ec1 100644 --- a/src/uix/soma/components/field/field-provider.svelte.ts +++ b/src/uix/soma/components/field/field-provider.svelte.ts @@ -241,6 +241,38 @@ export class FieldRequiredIndicatorProvider { ); } +// ── OptionalIndicator ──────────────────────────────────────────────────────── + +interface FieldOptionalIndicatorOpts extends WithRefOpts {} + +export class FieldOptionalIndicatorProvider { + readonly opts: FieldOptionalIndicatorOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: FieldOptionalIndicatorOpts) { + return new FieldOptionalIndicatorProvider(opts); + } + + readonly provider: FieldProvider; + + private constructor(opts: FieldOptionalIndicatorOpts) { + this.opts = opts; + this.provider = FieldProvider.require(); + this.runtimePart = this.provider.runtime.part('optional-indicator', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + 'aria-hidden': 'true' as const + } as const) + ); +} + // ── Control ────────────────────────────────────────────────────────────────── interface FieldControlOpts extends WithRefOpts {} diff --git a/src/uix/soma/components/field/langs.ts b/src/uix/soma/components/field/langs.ts index 0a83a48bd..5998d8b54 100644 --- a/src/uix/soma/components/field/langs.ts +++ b/src/uix/soma/components/field/langs.ts @@ -1,3 +1,6 @@ -// Field has no built-in translatable strings. Labels, helper and error text -// are provided by the consumer. -export const FIELD_LANGS = {} as const; +/** Idlangref constants for the Field component. */ +export const FIELD_LANGS = { + LABEL: '#?components.field.label|Field', + REQUIRED_INDICATOR: '#?components.field.required-indicator|*', + OPTIONAL_INDICATOR: '#?components.field.optional-indicator|(optional)' +} as const; diff --git a/src/uix/soma/components/field/types.ts b/src/uix/soma/components/field/types.ts index 4f5948f19..8e5ae2285 100644 --- a/src/uix/soma/components/field/types.ts +++ b/src/uix/soma/components/field/types.ts @@ -90,6 +90,13 @@ export type FieldRequiredIndicatorProps = WithChild<{ }> & Without; +export type FieldOptionalIndicatorProps = WithChild<{ + id?: string; + /** Only render while the field is NOT required. @default true */ + onlyWhenOptional?: boolean; +}> & + Without; + export type FieldControlProps = WithChild<{ id?: string }> & Without; export type FieldInputProps = WithChild<{ diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index 80a6d96a3..c64778a1d 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -683,10 +683,16 @@ export class SelectItemProvider { owner: this }); - // Register label for display text (avoids DOM queries) + // Register label for display text (avoids DOM queries). + // Note: items unmount whenever the popover closes (Content uses Presence + // so its DOM subtree disappears). Unregistering on cleanup would empty + // the registry between opens — which leaves `getDisplayText()` falling + // back to the raw value string for the closed-trigger display. Keep + // the entry alive; re-mounts simply overwrite it (idempotent). this.provider.registerLabel(opts.value.current, opts.label.current || opts.value.current); $effect(() => { - return () => this.provider.unregisterLabel(opts.value.current); + // Keep label registration in sync with reactive prop changes. + this.provider.registerLabel(opts.value.current, opts.label.current || opts.value.current); }); } diff --git a/src/uix/soma/layers/floating/floating.svelte.ts b/src/uix/soma/layers/floating/floating.svelte.ts index e878bc271..2f1d88ef8 100644 --- a/src/uix/soma/layers/floating/floating.svelte.ts +++ b/src/uix/soma/layers/floating/floating.svelte.ts @@ -106,8 +106,14 @@ export class FloatingContent { arrowId = state(useId()); transformedStyle = $derived.by(() => { - if (typeof this.opts.style === 'string') return cssToStyleObj(this.opts.style); - if (!this.opts.style) return {}; + // opts.style is a readable signal — must read `.current` to access the + // consumer value. Previously this checked `typeof this.opts.style` + // directly, which always read the signal object itself, so consumer + // styles never merged into the floating wrapper. + const style = this.opts.style.current; + if (typeof style === 'string') return cssToStyleObj(style); + if (!style) return {}; + return style; }); updatePositionStrategy = undefined as unknown as FloatingContentOpts['updatePositionStrategy']; diff --git a/web/routes/uix/components/field/+page.svelte b/web/routes/uix/components/field/+page.svelte index 2b2d97e21..234d3f0a3 100644 --- a/web/routes/uix/components/field/+page.svelte +++ b/web/routes/uix/components/field/+page.svelte @@ -212,6 +212,36 @@ {#if tab === 'live'}
+

Composition examples

+

+ Auto-rendered label text, optional indicator and required indicator side by side. +

+
+ + First name + + + + Required — required indicator shows. + + + + Middle name + + + + Optional — optional indicator shows its default text. + + + + + + + + Empty <Field.Label /> falls back to localized texts.label. + +
+

Controls

soma owns state and ARIA wiring.