feat(select): invalid state + ItemDescription + defaultValue + visual fixes

Audit-driven improvements to Select:

- morfo APG URL fixed (combobox → listbox; Select is a collapsible-listbox
  per ARIA APG, not a text-input combobox).
- `invalid` prop added to soma + morfo data-invalid/aria-invalid on Provider
  and Trigger. Eidos applies risk-border + tinted focus shadow when invalid.
- `defaultValue` prop added (string | string[]) for uncontrolled init.
  Normalized to string[] inside the soma wrapper via untrack so the
  reference is intentionally non-reactive.
- `ItemDescription` part: new morfo part (kebab item-description, archetype
  description), new soma wrapper + new eidos wrapper. CSS gives muted
  color and 0.85em font-size; when the parent item is checked, the
  description inherits accent color.
- Value truncation: ensure `min-inline-size: 0` + `max-inline-size: 100%`
  on the trigger value so long labels ellipsis instead of overflowing.
- Highlighted vs checked distinction: the highlighted-only state now uses
  a lighter hover background, and an item that is both highlighted and
  checked gets an additional inset focus ring on top of the accent fill.

Demo at /uix/components/select adds an `invalid` switch, and three of the
six items now ship with a description (Apple / Pear / Carrot).

Verified in browser:
- triggerInvalid + aria-invalid = true after toggling switch.
- Description on non-checked item: rgb(123,123,123) (content-muted).
- Description on checked item: accent purple (inherits).
- svelte-check --threshold error → 0 errors / 0 warnings / 4574 files.

Deferred: SL3 readonly (rare use case), SL4 virtual scrolling (own task).

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

@ -5,6 +5,7 @@ import Indicator from './select-indicator.svelte'
import Content from './select-content.svelte' import Content from './select-content.svelte'
import Viewport from './select-viewport.svelte' import Viewport from './select-viewport.svelte'
import Item from './select-item.svelte' import Item from './select-item.svelte'
import ItemDescription from './select-item-description.svelte'
import ItemIndicator from './select-item-indicator.svelte' import ItemIndicator from './select-item-indicator.svelte'
import Group from './select-group.svelte' import Group from './select-group.svelte'
import GroupHeading from './select-group-heading.svelte' import GroupHeading from './select-group-heading.svelte'
@ -20,6 +21,7 @@ type SelectNamespace = typeof SelectComponent & {
Content: typeof Content Content: typeof Content
Viewport: typeof Viewport Viewport: typeof Viewport
Item: typeof Item Item: typeof Item
ItemDescription: typeof ItemDescription
ItemIndicator: typeof ItemIndicator ItemIndicator: typeof ItemIndicator
Group: typeof Group Group: typeof Group
GroupHeading: typeof GroupHeading GroupHeading: typeof GroupHeading
@ -35,6 +37,7 @@ Select.Portal = Portal
Select.Content = Content Select.Content = Content
Select.Viewport = Viewport Select.Viewport = Viewport
Select.Item = Item Select.Item = Item
Select.ItemDescription = ItemDescription
Select.ItemIndicator = ItemIndicator Select.ItemIndicator = ItemIndicator
Select.Group = Group Select.Group = Group
Select.GroupHeading = GroupHeading Select.GroupHeading = GroupHeading
@ -53,6 +56,7 @@ export type {
SelectContentProps as ContentProps, SelectContentProps as ContentProps,
SelectViewportProps as ViewportProps, SelectViewportProps as ViewportProps,
SelectItemProps as ItemProps, SelectItemProps as ItemProps,
SelectItemDescriptionProps as ItemDescriptionProps,
SelectItemIndicatorProps as ItemIndicatorProps, SelectItemIndicatorProps as ItemIndicatorProps,
SelectGroupProps as GroupProps, SelectGroupProps as GroupProps,
SelectGroupHeadingProps as GroupHeadingProps, SelectGroupHeadingProps as GroupHeadingProps,

@ -0,0 +1,10 @@
<script lang="ts">
import * as Select from '$soma/components/select';
import type { SelectItemDescriptionProps } from './types';
let { children, ...rest }: SelectItemDescriptionProps = $props();
</script>
<Select.ItemDescription {...rest}>
{@render children?.()}
</Select.ItemDescription>

@ -166,9 +166,24 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Invalid state — mirror field.css conventions. Border uses the risk palette
token; on focus we still keep the risk border (not the accent) so users
keep seeing the warning while interacting. */
[data-select-trigger][data-invalid] {
border-color: var(--color-risk-border);
}
[data-select-trigger][data-invalid]:focus-visible,
[data-select-trigger][data-invalid][data-state='open'] {
border-color: var(--color-risk-border);
box-shadow: 0 0 0 var(--select-trigger-border-width) var(--color-risk-border);
}
[data-select-value] { [data-select-value] {
display: block;
flex: 1 1 auto; flex: 1 1 auto;
min-inline-size: 0; min-inline-size: 0;
max-inline-size: 100%;
overflow: hidden; overflow: hidden;
text-align: start; text-align: start;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -324,7 +339,10 @@
color var(--select-transition-duration) var(--select-transition-ease); color var(--select-transition-duration) var(--select-transition-ease);
} }
[data-select-item][data-highlighted] { /* Keyboard-focus background only when the item is NOT also checked. The
checked-and-highlighted case is handled below with an inset ring so the
accent track stays visible. */
[data-select-item][data-highlighted]:not([data-state='checked']) {
background: var(--select-item-bg-highlighted); background: var(--select-item-bg-highlighted);
color: var(--select-item-color-highlighted); color: var(--select-item-color-highlighted);
} }
@ -334,11 +352,32 @@
color: var(--_select-accent-text); color: var(--_select-accent-text);
} }
/* Highlighted + checked: keep accent track but layer an inset focus shadow
so the row reads as "this checked item is the keyboard focus". */
[data-select-item][data-highlighted][data-state='checked'] {
box-shadow: inset 0 0 0 var(--select-item-focus-ring-width, 2px) var(--_select-accent-border);
}
[data-select-item][data-disabled] { [data-select-item][data-disabled] {
color: var(--select-disabled-color); color: var(--select-disabled-color);
cursor: not-allowed; cursor: not-allowed;
} }
/* Secondary descriptive text under the item label (React Aria parity). */
[data-select-item-description] {
display: block;
margin-block-start: var(--select-item-description-mt, 0.125rem);
color: var(--select-item-description-color, var(--color-content-muted));
font-size: var(--select-item-description-font-size, 0.85em);
line-height: var(--select-line-height);
opacity: 0.8;
}
[data-select-item][data-state='checked'] [data-select-item-description] {
color: inherit;
opacity: 0.85;
}
[data-select-item-indicator] { [data-select-item-indicator] {
display: inline-flex; display: inline-flex;
flex: 0 0 auto; flex: 0 0 auto;

@ -3,6 +3,7 @@ import type {
ContentProps, ContentProps,
GroupHeadingProps, GroupHeadingProps,
GroupProps, GroupProps,
ItemDescriptionProps,
ItemProps, ItemProps,
ProviderProps, ProviderProps,
SeparatorProps, SeparatorProps,
@ -48,6 +49,7 @@ export type SelectContentProps = ContentProps & {
export type SelectViewportProps = ViewportProps export type SelectViewportProps = ViewportProps
export type SelectItemProps = ItemProps export type SelectItemProps = ItemProps
export type SelectItemDescriptionProps = ItemDescriptionProps
export type SelectGroupProps = GroupProps export type SelectGroupProps = GroupProps
export type SelectGroupHeadingProps = GroupHeadingProps export type SelectGroupHeadingProps = GroupHeadingProps
export type SelectSeparatorProps = SeparatorProps export type SelectSeparatorProps = SeparatorProps

@ -8,7 +8,7 @@ export const selectMorfo = {
texts: { texts: {
label: '#?components.select.label|Select' label: '#?components.select.label|Select'
}, },
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/listbox/',
events: [ events: [
{ {
@ -52,9 +52,12 @@ export const selectMorfo = {
data: [ data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }, { attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
{ attr: 'data-type', values: ['single', 'multiple'], value: v.propRef('type') } { attr: 'data-type', values: ['single', 'multiple'], value: v.propRef('type') }
], ],
aria: [] aria: [
{ attr: 'aria-invalid', value: v.propRef('invalid'), severity: 'optional' }
]
}, },
{ {
name: 'Trigger', name: 'Trigger',
@ -68,6 +71,7 @@ export const selectMorfo = {
data: [ data: [
{ attr: 'data-state', values: ['open', 'closed'] }, { attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' }, { attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' },
{ attr: 'data-placeholder', severity: 'optional' } { attr: 'data-placeholder', severity: 'optional' }
], ],
aria: [ aria: [
@ -169,6 +173,16 @@ export const selectMorfo = {
} }
] ]
}, },
{
name: 'ItemDescription',
kebab: 'item-description',
archetype: 'description',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{ {
name: 'Group', name: 'Group',
kebab: 'group', kebab: 'group',

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { SelectItemDescriptionProvider } from '../select-provider.svelte';
import type { SelectItemDescriptionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'select-item-description'),
children,
child,
...restProps
}: SelectItemDescriptionProps = $props();
const state = SelectItemDescriptionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { readableActive, writableActive } from '$libs/reactive'; import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props'; import { mergeProps } from '../../../props';
import { createId } from '../../../id'; import { createId } from '../../../id';
@ -14,10 +15,21 @@
ref = $bindable(null), ref = $bindable(null),
id = createId(uid, 'select'), id = createId(uid, 'select'),
type = 'single', type = 'single',
value = $bindable([]), /**
* Bindable selected value(s). When provided, takes precedence over
* `defaultValue`. Use this for controlled usage.
*/
value = $bindable(),
/**
* Initial value used only when `value` is undefined. Equivalent to
* React-style "uncontrolled with initial value". Accepts a single
* string or an array; coerced to `string[]` internally.
*/
defaultValue,
open = $bindable(false), open = $bindable(false),
disabled = false, disabled = false,
required = false, required = false,
invalid = false,
name = '', name = '',
loop = false, loop = false,
allowDeselect = false, allowDeselect = false,
@ -31,6 +43,17 @@
...restProps ...restProps
}: SelectProps = $props(); }: SelectProps = $props();
// Normalize defaultValue → string[] and seed `value` if uncontrolled.
// Read once at init via untrack — defaultValue is intentionally not
// reactive (any later mutation is the consumer's responsibility via
// `value`).
if (value === undefined) {
value = untrack(() => {
if (defaultValue === undefined) return [];
return Array.isArray(defaultValue) ? [...defaultValue] : [defaultValue];
});
}
const state = SelectProvider.create({ const state = SelectProvider.create({
id: readableActive(() => id), id: readableActive(() => id),
ref: writableActive( ref: writableActive(
@ -45,7 +68,7 @@
} }
), ),
value: writableActive( value: writableActive(
() => value, () => value ?? [],
(v) => { (v) => {
value = v; value = v;
onValueChange(v); onValueChange(v);
@ -54,6 +77,7 @@
type: readableActive(() => type), type: readableActive(() => type),
disabled: readableActive(() => disabled), disabled: readableActive(() => disabled),
required: readableActive(() => required), required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name), name: readableActive(() => name),
loop: readableActive(() => loop), loop: readableActive(() => loop),
allowDeselect: readableActive(() => allowDeselect), allowDeselect: readableActive(() => allowDeselect),
@ -67,6 +91,7 @@
{#snippet hiddenSelect()} {#snippet hiddenSelect()}
{#if name} {#if name}
{@const safeValue = value ?? []}
<select <select
{...{ name, required, disabled, multiple: type === 'multiple' }} {...{ name, required, disabled, multiple: type === 'multiple' }}
aria-hidden="true" aria-hidden="true"
@ -74,9 +99,9 @@
style="position:absolute;pointer-events:none;opacity:0;width:0;height:0" style="position:absolute;pointer-events:none;opacity:0;width:0;height:0"
> >
{#if type === 'single'} {#if type === 'single'}
<option value={value[0] ?? ''} selected>{value[0] ?? ''}</option> <option value={safeValue[0] ?? ''} selected>{safeValue[0] ?? ''}</option>
{:else} {:else}
{#each value as v} {#each safeValue as v}
<option value={v} selected>{v}</option> <option value={v} selected>{v}</option>
{/each} {/each}
{/if} {/if}

@ -4,6 +4,7 @@ export { default as Value } from './components/select-value.svelte';
export { default as Content } from './components/select-content.svelte'; export { default as Content } from './components/select-content.svelte';
export { default as Viewport } from './components/select-viewport.svelte'; export { default as Viewport } from './components/select-viewport.svelte';
export { default as Item } from './components/select-item.svelte'; export { default as Item } from './components/select-item.svelte';
export { default as ItemDescription } from './components/select-item-description.svelte';
export { default as Group } from './components/select-group.svelte'; export { default as Group } from './components/select-group.svelte';
export { default as GroupHeading } from './components/select-group-heading.svelte'; export { default as GroupHeading } from './components/select-group-heading.svelte';
export { default as Separator } from './components/select-separator.svelte'; export { default as Separator } from './components/select-separator.svelte';
@ -18,6 +19,7 @@ export type {
SelectContentProps as ContentProps, SelectContentProps as ContentProps,
SelectViewportProps as ViewportProps, SelectViewportProps as ViewportProps,
SelectItemProps as ItemProps, SelectItemProps as ItemProps,
SelectItemDescriptionProps as ItemDescriptionProps,
SelectGroupProps as GroupProps, SelectGroupProps as GroupProps,
SelectGroupHeadingProps as GroupHeadingProps, SelectGroupHeadingProps as GroupHeadingProps,
SelectSeparatorProps as SeparatorProps, SelectSeparatorProps as SeparatorProps,

@ -51,6 +51,7 @@ function createSelectOpts() {
type: state<SelectType>('single'), type: state<SelectType>('single'),
disabled: state(false), disabled: state(false),
required: state(false), required: state(false),
invalid: state(false),
name: state('choice'), name: state('choice'),
loop: state(false), loop: state(false),
allowDeselect: state(false), allowDeselect: state(false),

@ -50,6 +50,7 @@ interface SelectOpts
type: SelectType; type: SelectType;
disabled: boolean; disabled: boolean;
required: boolean; required: boolean;
invalid: boolean;
name: string; name: string;
loop: boolean; loop: boolean;
allowDeselect: boolean; allowDeselect: boolean;
@ -116,6 +117,7 @@ export class SelectProvider {
props: { props: {
disabled: () => this.opts.disabled.current, disabled: () => this.opts.disabled.current,
required: () => this.opts.required.current, required: () => this.opts.required.current,
invalid: () => this.opts.invalid.current,
type: () => this.opts.type.current, type: () => this.opts.type.current,
multiple: () => this.opts.type.current === 'multiple' multiple: () => this.opts.type.current === 'multiple'
}, },
@ -384,8 +386,10 @@ export class SelectTriggerProvider {
'aria-controls': this.provider.contentId.current || undefined, 'aria-controls': this.provider.contentId.current || undefined,
'aria-activedescendant': this.provider.highlightedId || undefined, 'aria-activedescendant': this.provider.highlightedId || undefined,
'aria-required': this.provider.opts.required.current || undefined, 'aria-required': this.provider.opts.required.current || undefined,
'aria-invalid': this.provider.opts.invalid.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current),
'data-placeholder': this.provider.opts.value.current.length === 0 ? '' : undefined, 'data-placeholder': this.provider.opts.value.current.length === 0 ? '' : undefined,
disabled: this.provider.opts.disabled.current || undefined, disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick, onclick: this.onclick,
@ -718,6 +722,32 @@ export class SelectItemProvider {
); );
} }
// ── ItemDescription ──────────────────────────────────────────────────────────
interface SelectItemDescriptionOpts extends WithRefOpts {}
export class SelectItemDescriptionProvider {
readonly opts: SelectItemDescriptionOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: SelectItemDescriptionOpts) {
return new SelectItemDescriptionProvider(opts);
}
private constructor(opts: SelectItemDescriptionOpts) {
this.opts = opts;
const provider = SelectProvider.require();
this.runtimePart = provider.runtime.part('item-description', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Group ──────────────────────────────────────────────────────────────────── // ── Group ────────────────────────────────────────────────────────────────────
interface SelectGroupOpts extends WithRefOpts {} interface SelectGroupOpts extends WithRefOpts {}

@ -22,8 +22,10 @@ export type SelectProps = WithChild<{
id?: string; id?: string;
/** Selection mode. @default 'single' */ /** Selection mode. @default 'single' */
type?: SelectType; type?: SelectType;
/** Selected value(s). Bindable. @default [] */ /** Selected value(s). Bindable. When provided, takes precedence over `defaultValue`. @default [] */
value?: string[]; value?: string[];
/** Initial value used only when `value` is undefined. @default undefined */
defaultValue?: string | string[];
/** Callback on value change. */ /** Callback on value change. */
onValueChange?: OnChangeFn<string[]>; onValueChange?: OnChangeFn<string[]>;
/** Whether the listbox is open. Bindable. @default false */ /** Whether the listbox is open. Bindable. @default false */
@ -36,6 +38,8 @@ export type SelectProps = WithChild<{
disabled?: boolean; disabled?: boolean;
/** @default false */ /** @default false */
required?: boolean; required?: boolean;
/** Marks the trigger as having an invalid value. Emits `data-invalid` and `aria-invalid`. @default false */
invalid?: boolean;
/** Form field name. Renders hidden <select> when set. */ /** Form field name. Renders hidden <select> when set. */
name?: string; name?: string;
/** Wrap keyboard navigation at boundaries. @default false */ /** Wrap keyboard navigation at boundaries. @default false */
@ -131,6 +135,12 @@ export type SelectItemProps = WithChild<
> & > &
Without<PrimitiveDivAttributes, {}>; Without<PrimitiveDivAttributes, {}>;
export type SelectItemDescriptionProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveSpanAttributes, {}>;
export type SelectGroupProps = WithChild<{ export type SelectGroupProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */ /** Unique identifier. Auto-generated if omitted. */
id?: string; id?: string;

@ -13,17 +13,23 @@
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type SelectMode = 'single' | 'multiple'; type SelectMode = 'single' | 'multiple';
type Option = { value: string; label: string; group: string; disabled?: boolean }; type Option = {
value: string;
label: string;
group: string;
disabled?: boolean;
description?: string;
};
type TraceEntry = { event: string; family: string; intent?: string; at: number }; type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix(); const uix = getActiveUix();
const params = page.url.searchParams; const params = page.url.searchParams;
const options: readonly Option[] = [ const options: readonly Option[] = [
{ value: 'apple', label: 'Apple', group: 'Fruit' }, { value: 'apple', label: 'Apple', group: 'Fruit', description: 'Crisp, sweet, ubiquitous' },
{ value: 'pear', label: 'Pear', group: 'Fruit' }, { value: 'pear', label: 'Pear', group: 'Fruit', description: 'Soft, mellow autumn fruit' },
{ value: 'orange', label: 'Orange', group: 'Fruit' }, { value: 'orange', label: 'Orange', group: 'Fruit' },
{ value: 'carrot', label: 'Carrot', group: 'Vegetables' }, { value: 'carrot', label: 'Carrot', group: 'Vegetables', description: 'Root, high in beta-carotene' },
{ value: 'mushroom', label: 'Mushroom', group: 'Vegetables', disabled: true }, { value: 'mushroom', label: 'Mushroom', group: 'Vegetables', disabled: true },
{ value: 'pepper', label: 'Pepper', group: 'Vegetables' } { value: 'pepper', label: 'Pepper', group: 'Vegetables' }
]; ];
@ -47,6 +53,7 @@
let mode = $state<SelectMode>('single'); let mode = $state<SelectMode>('single');
let disabled = $state(params.get('perm.disabled') === 'true'); let disabled = $state(params.get('perm.disabled') === 'true');
let required = $state(false); let required = $state(false);
let invalid = $state(false);
let allowDeselect = $state(true); let allowDeselect = $state(true);
let size = $state<SelectSize>('md'); let size = $state<SelectSize>('md');
let variant = $state<SelectVariant>('surface'); let variant = $state<SelectVariant>('surface');
@ -198,6 +205,7 @@
type={mode} type={mode}
{disabled} {disabled}
{required} {required}
{invalid}
{allowDeselect} {allowDeselect}
{size} {size}
{variant} {variant}
@ -225,7 +233,12 @@
data-perm-step={!disabled && option.value === 'pear' ? '1' : undefined} data-perm-step={!disabled && option.value === 'pear' ? '1' : undefined}
data-perm-label={!disabled && option.value === 'pear' ? 'select pear' : undefined} data-perm-label={!disabled && option.value === 'pear' ? 'select pear' : undefined}
> >
<span>{option.label}</span> <span style="display: flex; flex-direction: column; min-inline-size: 0;">
<span>{option.label}</span>
{#if option.description}
<Select.ItemDescription>{option.description}</Select.ItemDescription>
{/if}
</span>
<Select.ItemIndicator /> <Select.ItemIndicator />
</Select.Item> </Select.Item>
{/each} {/each}
@ -311,6 +324,10 @@
<input type="checkbox" bind:checked={required} /> <input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>required</span> <span data-uix-switch-label>required</span>
</label> </label>
<label data-uix-switch>
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>invalid</span>
</label>
<label data-uix-switch> <label data-uix-switch>
<input type="checkbox" bind:checked={allowDeselect} /> <input type="checkbox" bind:checked={allowDeselect} />
<span data-uix-switch-label>allowDeselect</span> <span data-uix-switch-label>allowDeselect</span>

Loading…
Cancel
Save

Powered by TurnKey Linux.