From 75136e6de50c3179af65f1705a2167b2af5cb3f4 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 03:18:06 +0200 Subject: [PATCH] fix(select) + feat(field): match-anchor width, label persistence, OptionalIndicator, auto-label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select bugs - The popover content didn't match the trigger width even though matchAnchorWidth was already implemented in the eidos layer. Root cause was in soma/layers/floating/floating.svelte.ts: `transformedStyle` read `this.opts.style` directly without `.current`, so consumer styles never merged into the floating wrapper. Fixed by reading `this.opts.style.current` and also returning the value when it's an object (previously fell through to undefined). All popover-based components (select, combobox, date/time pickers, color picker) now get their `style` prop applied. - The trigger value text reverted from the rendered label (e.g. "Apple") to the raw value string ("apple") whenever the popover closed. Items unmount when Content's Presence collapses, and they were unregistering their label on cleanup — leaving the label registry empty between opens. Drop the unregister on cleanup; keep registrations alive across popover open/close cycles. Re-mounts overwrite the entry. field improvements (F1 + F2 + F3) - F1: add an explicit `[data-field][data-variant='surface']` CSS block so the surface variant (declared in the type union) has its own rule instead of silently falling back to the default style. - F2: new `OptionalIndicator` part (morfo + soma wrapper + eidos wrapper). Renders only when `required === false`. Default text comes from `texts['optional-indicator']` ("(optional)"). Styled muted + 0.85em. - F3: `` without children now auto-renders the localised label from `texts.label`. Reduces boilerplate when consumers rely on the field name for its label. - Demo at /uix/components/field gained composition examples for the new parts. Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4576 files. In-browser: select trigger and popover widths both 336 px; closed and open values both show labels (e.g. "Apple, Pear, Orange"). Field demo shows 2 required `*`, 1 `(optional)`, and an empty `` falling back to the literal "Field". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../field/field-optional-indicator.svelte | 12 +++++ src/uix/eidos/components/field/field.css | 13 +++++ src/uix/eidos/components/field/index.ts | 4 ++ src/uix/eidos/components/field/types.ts | 2 + src/uix/morfo/components/field.ts | 14 ++++- .../field/components/field-label.svelte | 14 ++++- .../field-optional-indicator.svelte | 52 +++++++++++++++++++ src/uix/soma/components/field/exports.ts | 2 + .../components/field/field-provider.svelte.ts | 32 ++++++++++++ src/uix/soma/components/field/langs.ts | 9 ++-- src/uix/soma/components/field/types.ts | 7 +++ .../select/select-provider.svelte.ts | 10 +++- .../soma/layers/floating/floating.svelte.ts | 10 +++- web/routes/uix/components/field/+page.svelte | 30 +++++++++++ 14 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 src/uix/eidos/components/field/field-optional-indicator.svelte create mode 100644 src/uix/soma/components/field/components/field-optional-indicator.svelte 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.