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 Viewport from './select-viewport.svelte'
import Item from './select-item.svelte'
import ItemDescription from './select-item-description.svelte'
import ItemIndicator from './select-item-indicator.svelte'
import Group from './select-group.svelte'
import GroupHeading from './select-group-heading.svelte'
@ -20,6 +21,7 @@ type SelectNamespace = typeof SelectComponent & {
Content: typeof Content
Viewport: typeof Viewport
Item: typeof Item
ItemDescription: typeof ItemDescription
ItemIndicator: typeof ItemIndicator
Group: typeof Group
GroupHeading: typeof GroupHeading
@ -35,6 +37,7 @@ Select.Portal = Portal
Select.Content = Content
Select.Viewport = Viewport
Select.Item = Item
Select.ItemDescription = ItemDescription
Select.ItemIndicator = ItemIndicator
Select.Group = Group
Select.GroupHeading = GroupHeading
@ -53,6 +56,7 @@ export type {
SelectContentProps as ContentProps,
SelectViewportProps as ViewportProps,
SelectItemProps as ItemProps,
SelectItemDescriptionProps as ItemDescriptionProps,
SelectItemIndicatorProps as ItemIndicatorProps,
SelectGroupProps as GroupProps,
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;
}
/* 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] {
display: block;
flex: 1 1 auto;
min-inline-size: 0;
max-inline-size: 100%;
overflow: hidden;
text-align: start;
text-overflow: ellipsis;
@ -324,7 +339,10 @@
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);
color: var(--select-item-color-highlighted);
}
@ -334,11 +352,32 @@
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] {
color: var(--select-disabled-color);
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] {
display: inline-flex;
flex: 0 0 auto;

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

@ -8,7 +8,7 @@ export const selectMorfo = {
texts: {
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: [
{
@ -52,9 +52,12 @@ export const selectMorfo = {
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ 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') }
],
aria: []
aria: [
{ attr: 'aria-invalid', value: v.propRef('invalid'), severity: 'optional' }
]
},
{
name: 'Trigger',
@ -68,6 +71,7 @@ export const selectMorfo = {
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' },
{ attr: 'data-placeholder', severity: 'optional' }
],
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',
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">
import { untrack } from 'svelte';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
@ -14,10 +15,21 @@
ref = $bindable(null),
id = createId(uid, 'select'),
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),
disabled = false,
required = false,
invalid = false,
name = '',
loop = false,
allowDeselect = false,
@ -31,6 +43,17 @@
...restProps
}: 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({
id: readableActive(() => id),
ref: writableActive(
@ -45,7 +68,7 @@
}
),
value: writableActive(
() => value,
() => value ?? [],
(v) => {
value = v;
onValueChange(v);
@ -54,6 +77,7 @@
type: readableActive(() => type),
disabled: readableActive(() => disabled),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
loop: readableActive(() => loop),
allowDeselect: readableActive(() => allowDeselect),
@ -67,6 +91,7 @@
{#snippet hiddenSelect()}
{#if name}
{@const safeValue = value ?? []}
<select
{...{ name, required, disabled, multiple: type === 'multiple' }}
aria-hidden="true"
@ -74,9 +99,9 @@
style="position:absolute;pointer-events:none;opacity:0;width:0;height:0"
>
{#if type === 'single'}
<option value={value[0] ?? ''} selected>{value[0] ?? ''}</option>
<option value={safeValue[0] ?? ''} selected>{safeValue[0] ?? ''}</option>
{:else}
{#each value as v}
{#each safeValue as v}
<option value={v} selected>{v}</option>
{/each}
{/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 Viewport } from './components/select-viewport.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 GroupHeading } from './components/select-group-heading.svelte';
export { default as Separator } from './components/select-separator.svelte';
@ -18,6 +19,7 @@ export type {
SelectContentProps as ContentProps,
SelectViewportProps as ViewportProps,
SelectItemProps as ItemProps,
SelectItemDescriptionProps as ItemDescriptionProps,
SelectGroupProps as GroupProps,
SelectGroupHeadingProps as GroupHeadingProps,
SelectSeparatorProps as SeparatorProps,

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

@ -50,6 +50,7 @@ interface SelectOpts
type: SelectType;
disabled: boolean;
required: boolean;
invalid: boolean;
name: string;
loop: boolean;
allowDeselect: boolean;
@ -116,6 +117,7 @@ export class SelectProvider {
props: {
disabled: () => this.opts.disabled.current,
required: () => this.opts.required.current,
invalid: () => this.opts.invalid.current,
type: () => this.opts.type.current,
multiple: () => this.opts.type.current === 'multiple'
},
@ -384,8 +386,10 @@ export class SelectTriggerProvider {
'aria-controls': this.provider.contentId.current || undefined,
'aria-activedescendant': this.provider.highlightedId || 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-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,
disabled: this.provider.opts.disabled.current || undefined,
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 ────────────────────────────────────────────────────────────────────
interface SelectGroupOpts extends WithRefOpts {}

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

@ -13,17 +13,23 @@
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
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 };
const uix = getActiveUix();
const params = page.url.searchParams;
const options: readonly Option[] = [
{ value: 'apple', label: 'Apple', group: 'Fruit' },
{ value: 'pear', label: 'Pear', group: 'Fruit' },
{ value: 'apple', label: 'Apple', group: 'Fruit', description: 'Crisp, sweet, ubiquitous' },
{ value: 'pear', label: 'Pear', group: 'Fruit', description: 'Soft, mellow autumn 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: 'pepper', label: 'Pepper', group: 'Vegetables' }
];
@ -47,6 +53,7 @@
let mode = $state<SelectMode>('single');
let disabled = $state(params.get('perm.disabled') === 'true');
let required = $state(false);
let invalid = $state(false);
let allowDeselect = $state(true);
let size = $state<SelectSize>('md');
let variant = $state<SelectVariant>('surface');
@ -198,6 +205,7 @@
type={mode}
{disabled}
{required}
{invalid}
{allowDeselect}
{size}
{variant}
@ -225,7 +233,12 @@
data-perm-step={!disabled && option.value === 'pear' ? '1' : 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.Item>
{/each}
@ -311,6 +324,10 @@
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>required</span>
</label>
<label data-uix-switch>
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>invalid</span>
</label>
<label data-uix-switch>
<input type="checkbox" bind:checked={allowDeselect} />
<span data-uix-switch-label>allowDeselect</span>

Loading…
Cancel
Save

Powered by TurnKey Linux.