fix(select) + feat(field): match-anchor width, label persistence, OptionalIndicator, auto-label

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: `<Field.Label />` 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
`<Field.Label />` falling back to the literal "Field".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent ffdb480403
commit 75136e6de5

@ -0,0 +1,12 @@
<script lang="ts">
import * as Field from '$soma/components/field';
import type { FieldOptionalIndicatorProps } from './types';
let { children, ...rest }: FieldOptionalIndicatorProps = $props();
</script>
<Field.OptionalIndicator {...rest}>
{#if children}
{@render children()}
{/if}
</Field.OptionalIndicator>

@ -90,6 +90,11 @@
--_field-control-font-size: var(--field-control-font-size-xl); --_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. */ /* outline: transparent fill, border stays visible. */
[data-field][data-variant='outline'] { [data-field][data-variant='outline'] {
--_field-control-bg: var(--field-control-ghost-bg); --_field-control-bg: var(--field-control-ghost-bg);
@ -168,6 +173,14 @@
line-height: 1; 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] { [data-field-control] {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

@ -13,6 +13,7 @@
import FieldComponent from './field.svelte'; import FieldComponent from './field.svelte';
import Label from './field-label.svelte'; import Label from './field-label.svelte';
import RequiredIndicator from './field-required-indicator.svelte'; import RequiredIndicator from './field-required-indicator.svelte';
import OptionalIndicator from './field-optional-indicator.svelte';
import Control from './field-control.svelte'; import Control from './field-control.svelte';
import Input from './field-input.svelte'; import Input from './field-input.svelte';
import HelperText from './field-helper-text.svelte'; import HelperText from './field-helper-text.svelte';
@ -23,6 +24,7 @@ import Suffix from './field-suffix.svelte';
type FieldNamespace = typeof FieldComponent & { type FieldNamespace = typeof FieldComponent & {
Label: typeof Label; Label: typeof Label;
RequiredIndicator: typeof RequiredIndicator; RequiredIndicator: typeof RequiredIndicator;
OptionalIndicator: typeof OptionalIndicator;
Control: typeof Control; Control: typeof Control;
Input: typeof Input; Input: typeof Input;
HelperText: typeof HelperText; HelperText: typeof HelperText;
@ -34,6 +36,7 @@ type FieldNamespace = typeof FieldComponent & {
const Field = FieldComponent as FieldNamespace; const Field = FieldComponent as FieldNamespace;
Field.Label = Label; Field.Label = Label;
Field.RequiredIndicator = RequiredIndicator; Field.RequiredIndicator = RequiredIndicator;
Field.OptionalIndicator = OptionalIndicator;
Field.Control = Control; Field.Control = Control;
Field.Input = Input; Field.Input = Input;
Field.HelperText = HelperText; Field.HelperText = HelperText;
@ -49,6 +52,7 @@ export type {
FieldProps, FieldProps,
FieldLabelProps as LabelProps, FieldLabelProps as LabelProps,
FieldRequiredIndicatorProps as RequiredIndicatorProps, FieldRequiredIndicatorProps as RequiredIndicatorProps,
FieldOptionalIndicatorProps as OptionalIndicatorProps,
FieldControlProps as ControlProps, FieldControlProps as ControlProps,
FieldInputProps as InputProps, FieldInputProps as InputProps,
FieldHelperTextProps as HelperTextProps, FieldHelperTextProps as HelperTextProps,

@ -8,6 +8,7 @@ import type {
ProviderProps, ProviderProps,
FieldSnippetProps as FieldProviderSnippetProps, FieldSnippetProps as FieldProviderSnippetProps,
RequiredIndicatorProps, RequiredIndicatorProps,
OptionalIndicatorProps,
SuffixProps SuffixProps
} from '$soma/components/field'; } from '$soma/components/field';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'; 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 FieldLabelProps = LabelProps;
export type FieldRequiredIndicatorProps = RequiredIndicatorProps; export type FieldRequiredIndicatorProps = RequiredIndicatorProps;
export type FieldOptionalIndicatorProps = OptionalIndicatorProps;
export type FieldControlProps = ControlProps; export type FieldControlProps = ControlProps;
export type FieldInputProps = InputProps; export type FieldInputProps = InputProps;
export type FieldHelperTextProps = HelperTextProps; export type FieldHelperTextProps = HelperTextProps;

@ -6,7 +6,9 @@ export const fieldMorfo = {
kebab: 'field', kebab: 'field',
scope: ['soma'], scope: ['soma'],
texts: { 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: [ parts: [
{ {
@ -64,6 +66,16 @@ export const fieldMorfo = {
data: [{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' }], data: [{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' }],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }] 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', name: 'Control',
kebab: 'control', kebab: 'control',

@ -5,7 +5,8 @@
} from '$libs/reactive'; } from '$libs/reactive';
import { mergeProps } from '../../../props'; import { mergeProps } from '../../../props';
import { createId } from '../../../id'; 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'; import type { FieldLabelProps } from '../types';
const uid = $props.id(); const uid = $props.id();
@ -18,6 +19,8 @@
...restProps ...restProps
}: FieldLabelProps = $props(); }: FieldLabelProps = $props();
const provider = FieldProvider.require();
const state = FieldLabelProvider.create({ const state = FieldLabelProvider.create({
id: readableActive(() => id), id: readableActive(() => id),
ref: writableActive( ref: writableActive(
@ -27,12 +30,19 @@
}); });
const mergedProps = $derived(mergeProps(restProps, state.props)); 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');
</script> </script>
{#if child} {#if child}
{@render child({ props: mergedProps })} {@render child({ props: mergedProps })}
{:else} {:else}
<label {...mergedProps}> <label {...mergedProps}>
{@render children?.()} {#if children}
{@render children()}
{:else}
{defaultText}
{/if}
</label> </label>
{/if} {/if}

@ -0,0 +1,52 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { FieldProvider, FieldOptionalIndicatorProvider } from '../field-provider.svelte';
import { FIELD_LANGS } from '../langs';
import type { FieldOptionalIndicatorProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'field-optional-indicator'),
onlyWhenOptional = true,
children,
child,
...restProps
}: FieldOptionalIndicatorProps = $props();
const provider = FieldProvider.require();
// Render only when the field is NOT required (i.e. optional).
const shouldRender = $derived(!onlyWhenOptional || !provider.isRequired);
const state = FieldOptionalIndicatorProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const defaultText = $derived(
provider.soma.langs.ts(FIELD_LANGS.OPTIONAL_INDICATOR) ?? '(optional)'
);
</script>
{#if shouldRender}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{#if children}
{@render children()}
{:else}
{defaultText}
{/if}
</span>
{/if}
{/if}

@ -1,6 +1,7 @@
export { default as Provider } from './components/field.svelte'; export { default as Provider } from './components/field.svelte';
export { default as Label } from './components/field-label.svelte'; export { default as Label } from './components/field-label.svelte';
export { default as RequiredIndicator } from './components/field-required-indicator.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 Control } from './components/field-control.svelte';
export { default as Input } from './components/field-input.svelte'; export { default as Input } from './components/field-input.svelte';
export { default as HelperText } from './components/field-helper-text.svelte'; export { default as HelperText } from './components/field-helper-text.svelte';
@ -12,6 +13,7 @@ export type {
FieldProps as ProviderProps, FieldProps as ProviderProps,
FieldLabelProps as LabelProps, FieldLabelProps as LabelProps,
FieldRequiredIndicatorProps as RequiredIndicatorProps, FieldRequiredIndicatorProps as RequiredIndicatorProps,
FieldOptionalIndicatorProps as OptionalIndicatorProps,
FieldControlProps as ControlProps, FieldControlProps as ControlProps,
FieldInputProps as InputProps, FieldInputProps as InputProps,
FieldHelperTextProps as HelperTextProps, FieldHelperTextProps as HelperTextProps,

@ -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 ────────────────────────────────────────────────────────────────── // ── Control ──────────────────────────────────────────────────────────────────
interface FieldControlOpts extends WithRefOpts {} interface FieldControlOpts extends WithRefOpts {}

@ -1,3 +1,6 @@
// Field has no built-in translatable strings. Labels, helper and error text /** Idlangref constants for the Field component. */
// are provided by the consumer. export const FIELD_LANGS = {
export const FIELD_LANGS = {} as const; LABEL: '#?components.field.label|Field',
REQUIRED_INDICATOR: '#?components.field.required-indicator|*',
OPTIONAL_INDICATOR: '#?components.field.optional-indicator|(optional)'
} as const;

@ -90,6 +90,13 @@ export type FieldRequiredIndicatorProps = WithChild<{
}> & }> &
Without<PrimitiveSpanAttributes, {}>; Without<PrimitiveSpanAttributes, {}>;
export type FieldOptionalIndicatorProps = WithChild<{
id?: string;
/** Only render while the field is NOT required. @default true */
onlyWhenOptional?: boolean;
}> &
Without<PrimitiveSpanAttributes, {}>;
export type FieldControlProps = WithChild<{ id?: string }> & Without<PrimitiveDivAttributes, {}>; export type FieldControlProps = WithChild<{ id?: string }> & Without<PrimitiveDivAttributes, {}>;
export type FieldInputProps = WithChild<{ export type FieldInputProps = WithChild<{

@ -683,10 +683,16 @@ export class SelectItemProvider {
owner: this 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); this.provider.registerLabel(opts.value.current, opts.label.current || opts.value.current);
$effect(() => { $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);
}); });
} }

@ -106,8 +106,14 @@ export class FloatingContent {
arrowId = state(useId()); arrowId = state(useId());
transformedStyle = $derived.by(() => { transformedStyle = $derived.by(() => {
if (typeof this.opts.style === 'string') return cssToStyleObj(this.opts.style); // opts.style is a readable signal — must read `.current` to access the
if (!this.opts.style) return {}; // 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']; updatePositionStrategy = undefined as unknown as FloatingContentOpts['updatePositionStrategy'];

@ -212,6 +212,36 @@
{#if tab === 'live'} {#if tab === 'live'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title>Composition examples</h2>
<p data-uix-section-desc>
Auto-rendered label text, optional indicator and required indicator side by side.
</p>
<div data-uix-example-grid style="display: grid; gap: var(--uix-space-4); margin-block: var(--uix-space-3);">
<Field name="given-name" required>
<Field.Label>First name <Field.RequiredIndicator /></Field.Label>
<Field.Control>
<Field.Input type="text" />
</Field.Control>
<Field.HelperText>Required — required indicator shows.</Field.HelperText>
</Field>
<Field name="middle-name">
<Field.Label>Middle name <Field.OptionalIndicator /></Field.Label>
<Field.Control>
<Field.Input type="text" />
</Field.Control>
<Field.HelperText>Optional — optional indicator shows its default text.</Field.HelperText>
</Field>
<Field name="nickname">
<Field.Label />
<Field.Control>
<Field.Input type="text" />
</Field.Control>
<Field.HelperText>Empty &lt;Field.Label /&gt; falls back to localized texts.label.</Field.HelperText>
</Field>
</div>
<h2 data-uix-section-title>Controls</h2> <h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc> <p data-uix-section-desc>
<span data-uix-layer-badge="soma">soma</span> owns state and ARIA wiring. <span data-uix-layer-badge="soma">soma</span> owns state and ARIA wiring.

Loading…
Cancel
Save

Powered by TurnKey Linux.