From ffdb4804032c98c25cee962cfc17d49532e8967a Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 02:48:27 +0200 Subject: [PATCH] feat(select): invalid state + ItemDescription + defaultValue + visual fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/uix/eidos/components/select/index.ts | 4 ++ .../select/select-item-description.svelte | 10 +++++ src/uix/eidos/components/select/select.css | 41 ++++++++++++++++++- src/uix/eidos/components/select/types.ts | 2 + src/uix/morfo/components/select.ts | 18 +++++++- .../components/select-item-description.svelte | 35 ++++++++++++++++ .../select/components/select.svelte | 33 +++++++++++++-- src/uix/soma/components/select/exports.ts | 2 + .../select/select-provider.svelte.test.ts | 1 + .../select/select-provider.svelte.ts | 30 ++++++++++++++ src/uix/soma/components/select/types.ts | 12 +++++- web/routes/uix/components/select/+page.svelte | 27 +++++++++--- 12 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/uix/eidos/components/select/select-item-description.svelte create mode 100644 src/uix/soma/components/select/components/select-item-description.svelte diff --git a/src/uix/eidos/components/select/index.ts b/src/uix/eidos/components/select/index.ts index a2d2b73ae..b450942d9 100644 --- a/src/uix/eidos/components/select/index.ts +++ b/src/uix/eidos/components/select/index.ts @@ -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, diff --git a/src/uix/eidos/components/select/select-item-description.svelte b/src/uix/eidos/components/select/select-item-description.svelte new file mode 100644 index 000000000..f0d1488d8 --- /dev/null +++ b/src/uix/eidos/components/select/select-item-description.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/select/select.css b/src/uix/eidos/components/select/select.css index 1f8ae8363..d8279ade6 100644 --- a/src/uix/eidos/components/select/select.css +++ b/src/uix/eidos/components/select/select.css @@ -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; diff --git a/src/uix/eidos/components/select/types.ts b/src/uix/eidos/components/select/types.ts index fcbab57b1..9d716f290 100644 --- a/src/uix/eidos/components/select/types.ts +++ b/src/uix/eidos/components/select/types.ts @@ -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 diff --git a/src/uix/morfo/components/select.ts b/src/uix/morfo/components/select.ts index a3c7343a0..deb36a3b6 100644 --- a/src/uix/morfo/components/select.ts +++ b/src/uix/morfo/components/select.ts @@ -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', diff --git a/src/uix/soma/components/select/components/select-item-description.svelte b/src/uix/soma/components/select/components/select-item-description.svelte new file mode 100644 index 000000000..6298021fa --- /dev/null +++ b/src/uix/soma/components/select/components/select-item-description.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/soma/components/select/components/select.svelte b/src/uix/soma/components/select/components/select.svelte index 7ce3da134..01ddd15a8 100644 --- a/src/uix/soma/components/select/components/select.svelte +++ b/src/uix/soma/components/select/components/select.svelte @@ -1,4 +1,5 @@