From 9fbf8e48b5e747b4b2ed15f347f62e4b19e68d38 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 03:47:32 +0200 Subject: [PATCH] feat(number-field, search-field, combobox): audit-driven improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three components closing the 6-component audit batch. number-field - N1 RTL scrubber: invert horizontal delta sign when dir='rtl' so dragging right decrements (visually backward) in RTL layouts. Vertical unaffected. Added 2 RTL scrub tests; existing scrub test pinned to ltr. - N2 stepper height: Increment/Decrement now use --_number-field-height for inline-size/block-size/min-inline-size so they always match the input row at every size token (xs/sm/md/lg/xl). - N3 input focus ring: explicit [data-number-field-input]:focus-visible with --_number-field-accent-border box-shadow, alongside the existing Provider-level ring. search-field - S1 icon tokens: added --search-field-icon-size-{xs..xl} + icon-color + icon-margin-inline-end + a Morfo Icon part + soma/eidos wrappers. Demo uses . - S2 debounceMs prop: soma wraps onValueChange in a timers.schedule with the provided delay. clear() cancels, submit() flushes synchronously. - S3 loading slot: new LoadingIndicator part (morfo + soma + eidos) + data-loading on Provider + CSS spinner via @keyframes search-field-spin honoring prefers-reduced-motion. combobox - C1 ClearButton part: new morfo trigger + soma provider + eidos wrapper with X icon. Hidden via [data-empty] (opacity + pointer-events:none). Click clears value + inputValue and refocuses input. - C2 allowsCustomValue: when true and no highlighted item, Enter commits the current inputValue as the value. Single mode closes after commit; multi appends + clears input + stays open. - C4 Backspace removes last tag in multi mode when input is empty. - C5 Sticky group headers: CSS position:sticky with content-overlay bg. Plus: fixed search-field demo's brittle parts[1] index reference to a named lookup (parts.find(p => p.kebab === 'input')). Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4584 files. All component tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../combobox/combobox-clear-button.svelte | 29 ++++ .../eidos/components/combobox/combobox.css | 45 ++++++ src/uix/eidos/components/combobox/index.ts | 4 + src/uix/eidos/components/combobox/types.ts | 2 + .../components/number-field/number-field.css | 17 ++- .../eidos/components/search-field/index.ts | 8 ++ .../search-field/search-field-icon.svelte | 10 ++ .../search-field-loading-indicator.svelte | 10 ++ .../components/search-field/search-field.css | 57 ++++++++ .../eidos/components/search-field/types.ts | 4 + src/uix/eidos/generated/base.css | 16 +++ src/uix/eidos/lib/recipes/base.ts | 16 +++ src/uix/morfo/components/combobox.ts | 27 +++- src/uix/morfo/components/search-field.ts | 31 ++++- .../combobox/combobox-provider.svelte.test.ts | 1 + .../combobox/combobox-provider.svelte.ts | 100 ++++++++++++- .../components/combobox-clear-button.svelte | 35 +++++ .../combobox/components/combobox.svelte | 2 + src/uix/soma/components/combobox/exports.ts | 4 +- src/uix/soma/components/combobox/langs.ts | 1 + src/uix/soma/components/combobox/types.ts | 12 ++ .../number-field-provider.svelte.test.ts | 87 ++++++++++++ .../number-field-provider.svelte.ts | 7 +- .../components/search-field-icon.svelte | 35 +++++ .../search-field-loading-indicator.svelte | 38 +++++ .../components/search-field.svelte | 4 + .../soma/components/search-field/exports.ts | 4 + .../search-field-provider.svelte.test.ts | 2 + .../search-field-provider.svelte.ts | 131 ++++++++++++++++++ src/uix/soma/components/search-field/types.ts | 37 +++++ .../uix/components/combobox/+page.svelte | 7 + .../uix/components/search-field/+page.svelte | 45 +++++- 32 files changed, 815 insertions(+), 13 deletions(-) create mode 100644 src/uix/eidos/components/combobox/combobox-clear-button.svelte create mode 100644 src/uix/eidos/components/search-field/search-field-icon.svelte create mode 100644 src/uix/eidos/components/search-field/search-field-loading-indicator.svelte create mode 100644 src/uix/soma/components/combobox/components/combobox-clear-button.svelte create mode 100644 src/uix/soma/components/search-field/components/search-field-icon.svelte create mode 100644 src/uix/soma/components/search-field/components/search-field-loading-indicator.svelte diff --git a/src/uix/eidos/components/combobox/combobox-clear-button.svelte b/src/uix/eidos/components/combobox/combobox-clear-button.svelte new file mode 100644 index 000000000..ac6570e3e --- /dev/null +++ b/src/uix/eidos/components/combobox/combobox-clear-button.svelte @@ -0,0 +1,29 @@ + + + + {#if children} + {@render children()} + {:else} + + {/if} + diff --git a/src/uix/eidos/components/combobox/combobox.css b/src/uix/eidos/components/combobox/combobox.css index 7641cf6c5..d28e1d923 100644 --- a/src/uix/eidos/components/combobox/combobox.css +++ b/src/uix/eidos/components/combobox/combobox.css @@ -241,6 +241,46 @@ transition: transform var(--duration-fast) var(--ease-default); } +[data-combobox-clear-button] { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + inline-size: var(--control-height-xs); + block-size: var(--control-height-xs); + padding: 0; + border: 0; + border-radius: var(--radius-full); + background: transparent; + color: var(--color-content-secondary); + font: inherit; + cursor: pointer; + outline: none; + transition: + background var(--duration-fast) var(--ease-default), + color var(--duration-fast) var(--ease-default), + opacity var(--duration-fast) var(--ease-default); +} + +[data-combobox-clear-button]:hover:not(:disabled):not([data-disabled]) { + background: var(--color-surface-raised); + color: var(--color-content-primary); +} + +[data-combobox-clear-button]:focus-visible { + box-shadow: + 0 0 0 var(--focus-ring-offset) var(--color-surface-default), + 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); +} + +[data-combobox-clear-button][data-disabled], +[data-combobox-clear-button]:disabled, +[data-combobox][data-empty] [data-combobox-clear-button] { + cursor: default; + opacity: 0; + pointer-events: none; +} + [data-combobox-trigger][data-state='open'] [data-combobox-indicator] { transform: rotate(180deg); color: var(--_combobox-accent-text); @@ -347,7 +387,11 @@ } [data-combobox-group-heading] { + position: sticky; + top: 0; + z-index: 1; padding: var(--space-1) var(--space-2); + background: var(--color-surface-overlay); color: var(--color-content-muted); font-family: var(--font-ui); font-size: var(--font-size-xs); @@ -436,6 +480,7 @@ @media (prefers-reduced-motion: reduce) { [data-combobox-control], [data-combobox-trigger], + [data-combobox-clear-button], [data-combobox-indicator], [data-combobox-content], [data-combobox-item], diff --git a/src/uix/eidos/components/combobox/index.ts b/src/uix/eidos/components/combobox/index.ts index 9102c1fbf..32d240a7f 100644 --- a/src/uix/eidos/components/combobox/index.ts +++ b/src/uix/eidos/components/combobox/index.ts @@ -11,6 +11,7 @@ import Group from './combobox-group.svelte' import GroupHeading from './combobox-group-heading.svelte' import Separator from './combobox-separator.svelte' import Arrow from './combobox-arrow.svelte' +import ClearButton from './combobox-clear-button.svelte' import { Portal } from '$soma/components/internal' type ComboboxNamespace = typeof ComboboxComponent & { @@ -27,6 +28,7 @@ type ComboboxNamespace = typeof ComboboxComponent & { GroupHeading: typeof GroupHeading Separator: typeof Separator Arrow: typeof Arrow + ClearButton: typeof ClearButton } const Combobox = ComboboxComponent as ComboboxNamespace @@ -43,6 +45,7 @@ Combobox.Group = Group Combobox.GroupHeading = GroupHeading Combobox.Separator = Separator Combobox.Arrow = Arrow +Combobox.ClearButton = ClearButton export { Combobox } @@ -62,6 +65,7 @@ export type { ComboboxGroupHeadingProps as GroupHeadingProps, ComboboxSeparatorProps as SeparatorProps, ComboboxArrowProps as ArrowProps, + ComboboxClearButtonProps as ClearButtonProps, ComboboxSize, ComboboxVariant, ComboboxColor diff --git a/src/uix/eidos/components/combobox/types.ts b/src/uix/eidos/components/combobox/types.ts index 8d4cef7d1..6f46d2a32 100644 --- a/src/uix/eidos/components/combobox/types.ts +++ b/src/uix/eidos/components/combobox/types.ts @@ -1,5 +1,6 @@ import type { ArrowProps, + ClearButtonProps, ContentProps, GroupHeadingProps, GroupProps, @@ -68,3 +69,4 @@ export type ComboboxGroupProps = GroupProps export type ComboboxGroupHeadingProps = GroupHeadingProps export type ComboboxSeparatorProps = SeparatorProps export type ComboboxArrowProps = ArrowProps +export type ComboboxClearButtonProps = ClearButtonProps diff --git a/src/uix/eidos/components/number-field/number-field.css b/src/uix/eidos/components/number-field/number-field.css index cc7a25f67..27daf5fb1 100644 --- a/src/uix/eidos/components/number-field/number-field.css +++ b/src/uix/eidos/components/number-field/number-field.css @@ -129,12 +129,22 @@ color: inherit; font: inherit; outline: none; + transition: box-shadow var(--number-field-transition-duration) + var(--number-field-transition-ease); } [data-number-field-input][data-disabled] { cursor: not-allowed; } +/* Explicit keyboard focus ring on the input itself. Ensures clear focus + feedback even before the Provider's [data-focused] attribute is applied + (e.g. very first keydown, or when wrapping rules are overridden). */ +[data-number-field-input]:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--_number-field-accent-border); +} + [data-number-field-increment-trigger], [data-number-field-decrement-trigger], [data-number-field-scrubber] { @@ -142,8 +152,11 @@ flex: 0 0 auto; align-items: center; justify-content: center; - inline-size: var(--number-field-control-size); - block-size: var(--number-field-control-size); + /* Stepper buttons and scrubber inherit the Provider's resolved height so + they always match the input row, regardless of size token. */ + inline-size: var(--_number-field-height); + block-size: var(--_number-field-height); + min-inline-size: var(--_number-field-height); border: var(--number-field-control-border-width) solid var(--number-field-control-border); border-radius: var(--number-field-control-radius); background: var(--number-field-control-bg); diff --git a/src/uix/eidos/components/search-field/index.ts b/src/uix/eidos/components/search-field/index.ts index a7e269347..0d2325221 100644 --- a/src/uix/eidos/components/search-field/index.ts +++ b/src/uix/eidos/components/search-field/index.ts @@ -9,15 +9,21 @@ import SearchFieldComponent from './search-field.svelte'; import Input from './search-field-input.svelte'; import ClearTrigger from './search-field-clear-trigger.svelte'; +import Icon from './search-field-icon.svelte'; +import LoadingIndicator from './search-field-loading-indicator.svelte'; type SearchFieldNamespace = typeof SearchFieldComponent & { Input: typeof Input; ClearTrigger: typeof ClearTrigger; + Icon: typeof Icon; + LoadingIndicator: typeof LoadingIndicator; }; const SearchField = SearchFieldComponent as SearchFieldNamespace; SearchField.Input = Input; SearchField.ClearTrigger = ClearTrigger; +SearchField.Icon = Icon; +SearchField.LoadingIndicator = LoadingIndicator; export { SearchField }; @@ -27,6 +33,8 @@ export type { SearchFieldProps, SearchFieldInputProps as InputProps, SearchFieldClearTriggerProps as ClearTriggerProps, + SearchFieldIconProps as IconProps, + SearchFieldLoadingIndicatorProps as LoadingIndicatorProps, SearchFieldProviderSnippetProps as ProviderSnippetProps, SearchFieldSize, SearchFieldVariant, diff --git a/src/uix/eidos/components/search-field/search-field-icon.svelte b/src/uix/eidos/components/search-field/search-field-icon.svelte new file mode 100644 index 000000000..3b4e49d28 --- /dev/null +++ b/src/uix/eidos/components/search-field/search-field-icon.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/search-field/search-field-loading-indicator.svelte b/src/uix/eidos/components/search-field/search-field-loading-indicator.svelte new file mode 100644 index 000000000..f8316160e --- /dev/null +++ b/src/uix/eidos/components/search-field/search-field-loading-indicator.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/search-field/search-field.css b/src/uix/eidos/components/search-field/search-field.css index 65c0fa5c4..948ddd0c6 100644 --- a/src/uix/eidos/components/search-field/search-field.css +++ b/src/uix/eidos/components/search-field/search-field.css @@ -3,6 +3,8 @@ --_search-field-px: var(--search-field-px-md); --_search-field-gap: var(--search-field-gap-md); --_search-field-font-size: var(--search-field-font-size-md); + --_search-field-icon-size: var(--search-field-icon-size-md); + --_search-field-loading-size: var(--search-field-loading-indicator-size-md); --_search-field-accent-border: var(--color-primary-border); --_search-field-accent-track: var(--color-primary-track); --_search-field-accent-text: var(--color-primary-text); @@ -74,6 +76,8 @@ --_search-field-px: var(--search-field-px-xs); --_search-field-gap: var(--search-field-gap-xs); --_search-field-font-size: var(--search-field-font-size-xs); + --_search-field-icon-size: var(--search-field-icon-size-xs); + --_search-field-loading-size: var(--search-field-loading-indicator-size-xs); } [data-search-field][data-size='sm'] { @@ -81,6 +85,8 @@ --_search-field-px: var(--search-field-px-sm); --_search-field-gap: var(--search-field-gap-sm); --_search-field-font-size: var(--search-field-font-size-sm); + --_search-field-icon-size: var(--search-field-icon-size-sm); + --_search-field-loading-size: var(--search-field-loading-indicator-size-sm); } [data-search-field][data-size='lg'] { @@ -88,6 +94,8 @@ --_search-field-px: var(--search-field-px-lg); --_search-field-gap: var(--search-field-gap-lg); --_search-field-font-size: var(--search-field-font-size-lg); + --_search-field-icon-size: var(--search-field-icon-size-lg); + --_search-field-loading-size: var(--search-field-loading-indicator-size-lg); } [data-search-field][data-size='xl'] { @@ -95,6 +103,8 @@ --_search-field-px: var(--search-field-px-xl); --_search-field-gap: var(--search-field-gap-xl); --_search-field-font-size: var(--search-field-font-size-xl); + --_search-field-icon-size: var(--search-field-icon-size-xl); + --_search-field-loading-size: var(--search-field-loading-indicator-size-xl); } [data-search-field][data-variant='outline'] { @@ -175,9 +185,56 @@ pointer-events: none; } +[data-search-field-icon] { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + inline-size: var(--_search-field-icon-size); + block-size: var(--_search-field-icon-size); + margin-inline-end: var(--search-field-icon-margin-inline-end); + color: var(--search-field-icon-color); + font-size: var(--_search-field-icon-size); + line-height: 1; + pointer-events: none; +} + +[data-search-field-loading-indicator] { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + inline-size: var(--_search-field-loading-size); + block-size: var(--_search-field-loading-size); + color: var(--search-field-loading-indicator-color); + pointer-events: none; +} + +[data-search-field-loading-indicator]::before { + content: ''; + display: block; + inline-size: 100%; + block-size: 100%; + border-radius: 50%; + border: var(--search-field-loading-indicator-thickness) solid var(--search-field-loading-indicator-track); + border-top-color: currentColor; + animation: search-field-spin var(--search-field-loading-indicator-duration) linear infinite; +} + +@keyframes search-field-spin { + to { + transform: rotate(360deg); + } +} + @media (prefers-reduced-motion: reduce) { [data-search-field], [data-search-field-clear-trigger] { transition: none; } + [data-search-field-loading-indicator]::before { + animation: none; + border-top-color: var(--search-field-loading-indicator-track); + opacity: 0.6; + } } diff --git a/src/uix/eidos/components/search-field/types.ts b/src/uix/eidos/components/search-field/types.ts index 843c5ce4c..02afa76e3 100644 --- a/src/uix/eidos/components/search-field/types.ts +++ b/src/uix/eidos/components/search-field/types.ts @@ -1,6 +1,8 @@ import type { ClearTriggerProps, + IconProps, InputProps, + LoadingIndicatorProps, ProviderProps, ProviderSnippetProps as SearchFieldProviderSnippetProps } from '$soma/components/search-field'; @@ -27,4 +29,6 @@ export type SearchFieldProps = ProviderProps & { export type SearchFieldInputProps = InputProps; export type SearchFieldClearTriggerProps = ClearTriggerProps; +export type SearchFieldIconProps = IconProps; +export type SearchFieldLoadingIndicatorProps = LoadingIndicatorProps; export type { SearchFieldProviderSnippetProps }; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 2323cddc9..484f3feba 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1357,6 +1357,22 @@ --search-field-clear-color-hover: var(--color-content-primary); --search-field-clear-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --search-field-clear-hidden-opacity: 0; + --search-field-icon-size-xs: var(--icon-size-xs); + --search-field-icon-size-sm: var(--icon-size-sm); + --search-field-icon-size-md: var(--icon-size-md); + --search-field-icon-size-lg: var(--icon-size-md); + --search-field-icon-size-xl: var(--icon-size-lg); + --search-field-icon-color: var(--color-content-muted); + --search-field-icon-margin-inline-end: var(--space-1-5); + --search-field-loading-indicator-size-xs: var(--icon-size-xs); + --search-field-loading-indicator-size-sm: var(--icon-size-sm); + --search-field-loading-indicator-size-md: var(--icon-size-md); + --search-field-loading-indicator-size-lg: var(--icon-size-md); + --search-field-loading-indicator-size-xl: var(--icon-size-lg); + --search-field-loading-indicator-color: var(--color-content-muted); + --search-field-loading-indicator-track: var(--color-border-subtle); + --search-field-loading-indicator-thickness: 2px; + --search-field-loading-indicator-duration: 900ms; --search-field-transition-duration: var(--duration-fast); --search-field-transition-ease: var(--ease-default); --search-field-disabled-opacity: 0.56; diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 48011db71..d278a20dd 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1179,6 +1179,22 @@ export const THEME_BASE_RECIPE_TOKENS = { 'clear-focus-shadow': '0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', 'clear-hidden-opacity': '0', + 'icon-size-xs': 'var(--icon-size-xs)', + 'icon-size-sm': 'var(--icon-size-sm)', + 'icon-size-md': 'var(--icon-size-md)', + 'icon-size-lg': 'var(--icon-size-md)', + 'icon-size-xl': 'var(--icon-size-lg)', + 'icon-color': 'var(--color-content-muted)', + 'icon-margin-inline-end': 'var(--space-1-5)', + 'loading-indicator-size-xs': 'var(--icon-size-xs)', + 'loading-indicator-size-sm': 'var(--icon-size-sm)', + 'loading-indicator-size-md': 'var(--icon-size-md)', + 'loading-indicator-size-lg': 'var(--icon-size-md)', + 'loading-indicator-size-xl': 'var(--icon-size-lg)', + 'loading-indicator-color': 'var(--color-content-muted)', + 'loading-indicator-track': 'var(--color-border-subtle)', + 'loading-indicator-thickness': '2px', + 'loading-indicator-duration': '900ms', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'disabled-opacity': '0.56' diff --git a/src/uix/morfo/components/combobox.ts b/src/uix/morfo/components/combobox.ts index beecdeb63..19084660b 100644 --- a/src/uix/morfo/components/combobox.ts +++ b/src/uix/morfo/components/combobox.ts @@ -8,7 +8,8 @@ export const comboboxMorfo = { apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/', texts: { label: '#?components.combobox.label|Combobox', - toggle: '#?components.combobox.toggle|Toggle' + toggle: '#?components.combobox.toggle|Toggle', + clear: '#?components.combobox.clear|Clear value' }, events: [ { @@ -44,7 +45,8 @@ export const comboboxMorfo = { data: [ { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }, - { attr: 'data-type', values: ['single', 'multiple'], value: v.propRef('type') } + { attr: 'data-type', values: ['single', 'multiple'], value: v.propRef('type') }, + { attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' } ], aria: [] }, @@ -85,7 +87,8 @@ export const comboboxMorfo = { { key: 'Home', action: 'first-item' }, { key: 'End', action: 'last-item' }, { key: 'Enter', action: 'select' }, - { key: 'Escape', action: 'close' } + { key: 'Escape', action: 'close' }, + { key: 'Backspace', action: 'remove-last' } ] }, { @@ -118,6 +121,24 @@ export const comboboxMorfo = { } ] }, + { + name: 'ClearButton', + kebab: 'clear-button', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [{ attr: 'data-disabled', value: v.propRef('clearDisabled'), severity: 'optional' }], + aria: [ + { attr: 'type', value: v.literal('button') }, + { + attr: 'aria-label', + value: v.translationRef('#?components.combobox.clear|Clear value'), + severity: 'recommended' + } + ] + }, { name: 'Content', kebab: 'content', diff --git a/src/uix/morfo/components/search-field.ts b/src/uix/morfo/components/search-field.ts index 23b199f42..ed4cf3617 100644 --- a/src/uix/morfo/components/search-field.ts +++ b/src/uix/morfo/components/search-field.ts @@ -45,10 +45,23 @@ export const searchFieldMorfo = { { attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }, { attr: 'data-required', value: v.propRef('required'), severity: 'optional' }, { attr: 'data-focused', value: v.propRef('focused'), severity: 'optional' }, - { attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' } + { attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' }, + { attr: 'data-loading', value: v.propRef('loading'), severity: 'optional' } ], aria: [] }, + { + // Visual prefix icon (e.g. magnifier). Optional — consumers compose + // it inline before the Input. Always decorative for AT. + name: 'Icon', + kebab: 'icon', + archetype: 'indicator', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [], + aria: [{ attr: 'aria-hidden', value: v.literal('true') }] + }, { name: 'Input', kebab: 'input', @@ -84,6 +97,22 @@ export const searchFieldMorfo = { } ] }, + { + // Async-search affordance. Soma's provider emits this part only when + // `loading=true`; eidos renders a spinner. `aria-live=polite` keeps + // state changes audible without barging. + name: 'LoadingIndicator', + kebab: 'loading-indicator', + archetype: 'indicator', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [], + aria: [ + { attr: 'aria-hidden', value: v.literal('true') }, + { attr: 'aria-live', value: v.literal('polite') } + ] + }, { name: 'ClearTrigger', kebab: 'clear-trigger', diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.test.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.test.ts index 0f46ab0c4..727257475 100644 --- a/src/uix/soma/components/combobox/combobox-provider.svelte.test.ts +++ b/src/uix/soma/components/combobox/combobox-provider.svelte.test.ts @@ -56,6 +56,7 @@ function createComboboxOpts() { name: state('choice'), loop: state(false), allowDeselect: state(false), + allowsCustomValue: state(false), dir: state<'ltr' | 'rtl'>('ltr'), onOpenChangeComplete: state(() => {}) }; diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.ts index c88e1fa84..1db8255c5 100644 --- a/src/uix/soma/components/combobox/combobox-provider.svelte.ts +++ b/src/uix/soma/components/combobox/combobox-provider.svelte.ts @@ -46,6 +46,7 @@ interface ComboboxOpts name: string; loop: boolean; allowDeselect: boolean; + allowsCustomValue: boolean; dir: Direction; onOpenChangeComplete: OnChangeFn; }> {} @@ -108,7 +109,9 @@ export class ComboboxProvider { disabled: () => this.opts.disabled.current, required: () => this.opts.required.current, type: () => this.opts.type.current, - multiple: () => this.opts.type.current === 'multiple' + multiple: () => this.opts.type.current === 'multiple', + empty: () => this.isEmpty, + clearDisabled: () => this.opts.disabled.current || this.isEmpty }, parts: { input: () => this.inputId.current || undefined, @@ -137,6 +140,19 @@ export class ComboboxProvider { return this.opts.value.current.includes(value); } + /** True when both `inputValue` is empty and no values are selected. Drives `data-empty` + clear button disabled state. */ + readonly isEmpty = $derived.by( + () => this.opts.inputValue.current === '' && this.opts.value.current.length === 0 + ); + + /** Clear `inputValue` and `value` (deselects all in multi). Refocuses the input. */ + clearAll() { + if (this.opts.disabled.current) return; + this.opts.inputValue.current = ''; + this.opts.value.current = []; + this.soma.dom.focus(this.inputRef.current); + } + private resolveItemEl(value: string): HTMLElement | null { const root = this.contentRef.current; if (!root) return null; @@ -207,6 +223,28 @@ export class ComboboxProvider { this.opts.inputValue.current = value; } + /** + * Commit the current input text as a value when `allowsCustomValue` is on + * and no item is highlighted. Single: replace and close. Multi: append + clear input. + */ + commitCustomValue(raw: string) { + const value = raw.trim(); + if (value === '' || this.opts.disabled.current) return; + // Register the custom value with itself as label so the trigger / displayText resolves it. + this.registerLabel(value, value); + if (this.opts.type.current === 'single') { + this.opts.value.current = [value]; + this.opts.inputValue.current = value; + this.handleClose(); + } else { + const current = this.opts.value.current; + if (!current.includes(value)) { + this.opts.value.current = [...current, value]; + } + this.opts.inputValue.current = ''; + } + } + /** Get display text for the current value. Resolves labels via the registry. */ getDisplayText(): string { return this.opts.value.current.map((v) => this.labelRegistry.get(v) ?? v).join(', '); @@ -298,6 +336,24 @@ export class ComboboxInputProvider { highlighted.click(); } } + } else if ( + this.provider.opts.allowsCustomValue.current && + this.provider.opts.inputValue.current !== '' + ) { + // No highlighted item: commit current input as a custom value. + e.preventDefault(); + this.provider.commitCustomValue(this.provider.opts.inputValue.current); + } + } else if (e.key === KEYS.BACKSPACE) { + // Multi mode: empty input + Backspace removes the last selected value. + if ( + this.provider.opts.type.current === 'multiple' && + this.provider.opts.inputValue.current === '' && + this.provider.opts.value.current.length > 0 + ) { + e.preventDefault(); + const current = this.provider.opts.value.current; + this.provider.opts.value.current = current.slice(0, -1); } } }; @@ -788,3 +844,45 @@ export class ComboboxArrowProvider { this.runtimePart.assert({ ...this.runtimePart.props, ...this.floatingArrow.props } as const) ); } + +// ── ClearButton ───────────────────────────────────────────────────────────── + +interface ComboboxClearButtonOpts extends WithRefOpts {} + +export class ComboboxClearButtonProvider { + readonly opts: ComboboxClearButtonOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: ComboboxClearButtonOpts) { + return new ComboboxClearButtonProvider(opts); + } + + readonly provider: ComboboxProvider; + + private constructor(opts: ComboboxClearButtonOpts) { + this.opts = opts; + this.provider = ComboboxProvider.require(); + this.runtimePart = this.provider.runtime.part('clear-button', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + readonly onclick = (_e: SomaMouseEvent) => { + this.provider.clearAll(); + }; + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + type: 'button' as const, + 'aria-label': this.provider.soma.langs.ts(COMBOBOX_LANGS.CLEAR), + tabindex: -1, + disabled: this.provider.opts.disabled.current || this.provider.isEmpty || undefined, + 'data-disabled': boolToEmptyStrOrUndef( + this.provider.opts.disabled.current || this.provider.isEmpty + ), + onclick: this.onclick + } as const) + ); +} diff --git a/src/uix/soma/components/combobox/components/combobox-clear-button.svelte b/src/uix/soma/components/combobox/components/combobox-clear-button.svelte new file mode 100644 index 000000000..0445efef8 --- /dev/null +++ b/src/uix/soma/components/combobox/components/combobox-clear-button.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/combobox/components/combobox.svelte b/src/uix/soma/components/combobox/components/combobox.svelte index e52727660..1104b124b 100644 --- a/src/uix/soma/components/combobox/components/combobox.svelte +++ b/src/uix/soma/components/combobox/components/combobox.svelte @@ -22,6 +22,7 @@ name = '', loop = false, allowDeselect = false, + allowsCustomValue = false, dir, onValueChange = () => {}, onOpenChange = () => {}, @@ -65,6 +66,7 @@ name: readableActive(() => name), loop: readableActive(() => loop), allowDeselect: readableActive(() => allowDeselect), + allowsCustomValue: readableActive(() => allowsCustomValue), dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'), onOpenChangeComplete: readableActive(() => onOpenChangeComplete) }); diff --git a/src/uix/soma/components/combobox/exports.ts b/src/uix/soma/components/combobox/exports.ts index 98d5c66ef..9d342808c 100644 --- a/src/uix/soma/components/combobox/exports.ts +++ b/src/uix/soma/components/combobox/exports.ts @@ -8,6 +8,7 @@ export { default as Group } from './components/combobox-group.svelte'; export { default as GroupHeading } from './components/combobox-group-heading.svelte'; export { default as Separator } from './components/combobox-separator.svelte'; export { default as Arrow } from './components/combobox-arrow.svelte'; +export { default as ClearButton } from './components/combobox-clear-button.svelte'; export type { ComboboxType, @@ -20,5 +21,6 @@ export type { ComboboxGroupProps as GroupProps, ComboboxGroupHeadingProps as GroupHeadingProps, ComboboxSeparatorProps as SeparatorProps, - ComboboxArrowProps as ArrowProps + ComboboxArrowProps as ArrowProps, + ComboboxClearButtonProps as ClearButtonProps } from './types'; diff --git a/src/uix/soma/components/combobox/langs.ts b/src/uix/soma/components/combobox/langs.ts index fad736ebd..01e2e46a7 100644 --- a/src/uix/soma/components/combobox/langs.ts +++ b/src/uix/soma/components/combobox/langs.ts @@ -1,4 +1,5 @@ /** Idlangref constants for the Combobox component. */ export const COMBOBOX_LANGS = { TOGGLE: '#?components.combobox.toggle|Toggle', + CLEAR: '#?components.combobox.clear|Clear value' } as const; diff --git a/src/uix/soma/components/combobox/types.ts b/src/uix/soma/components/combobox/types.ts index f747c8c89..7925f09c1 100644 --- a/src/uix/soma/components/combobox/types.ts +++ b/src/uix/soma/components/combobox/types.ts @@ -45,6 +45,12 @@ export type ComboboxProps = WithChild<{ loop?: boolean; /** Allow clearing selection in single mode by re-selecting. @default false */ allowDeselect?: boolean; + /** + * If true, pressing Enter with no highlighted item commits the current + * `inputValue` as a value. Single: replaces and closes. Multi: appends + * and clears the input. @default false + */ + allowsCustomValue?: boolean; /** Text direction for keyboard navigation. @default from Soma config or 'ltr' */ dir?: Direction; }> & @@ -154,3 +160,9 @@ export type ComboboxArrowProps = WithChild<{ /** Arrow height in px. @default 5 */ height?: number; }>; + +export type ComboboxClearButtonProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.test.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.test.ts index e635a1634..74e3b5887 100644 --- a/src/uix/soma/components/number-field/number-field-provider.svelte.test.ts +++ b/src/uix/soma/components/number-field/number-field-provider.svelte.test.ts @@ -249,6 +249,9 @@ describe('NumberFieldProvider', () => { const onCommit = vi.fn(); opts.value.current = 10; opts.step.current = 2; + // Pin LTR so this test exercises the non-mirrored horizontal scrub path. + // (Harness default Soma.prefs.getDir() returns 'rtl'.) + opts.dir.current = 'ltr'; opts.onValueCommit.current = onCommit; const { result: provider, cleanup } = withEffectRoot(() => NumberFieldProvider.create(opts)); @@ -289,4 +292,88 @@ describe('NumberFieldProvider', () => { cleanup(); dom.dispose(); }); + + it('inverts horizontal scrub delta when direction is rtl', () => { + const { dom } = installSomaHarness(); + const opts = createNumberFieldOpts(); + const onCommit = vi.fn(); + opts.value.current = 10; + opts.step.current = 2; + // Explicit RTL (also matches the harness default, but be explicit). + opts.dir.current = 'rtl'; + opts.onValueCommit.current = onCommit; + + const { result: provider, cleanup } = withEffectRoot(() => NumberFieldProvider.create(opts)); + vi.spyOn(NumberFieldProvider.ctx, 'get').mockReturnValue(provider); + + const scrubber = NumberFieldScrubberProvider.create({ + id: state('number-field-scrubber'), + ref: state(null), + direction: state<'horizontal' | 'vertical'>('horizontal'), + sensitivity: state(5) + }); + const target = { setPointerCapture: vi.fn() }; + + scrubber.onpointerdown({ + button: 0, + clientX: 0, + clientY: 0, + preventDefault: vi.fn() + } as never); + scrubber.onpointermove({ + clientX: 15, + clientY: 0, + pointerId: 1, + currentTarget: target, + preventDefault: vi.fn() + } as never); + + // RTL inverts the sign: same +15px drag → decrement instead of increment. + expect(opts.value.current).toBe(4); + + scrubber.onpointerup(); + expect(onCommit).toHaveBeenCalledWith(4); + + cleanup(); + dom.dispose(); + }); + + it('does not invert vertical scrub delta when direction is rtl', () => { + const { dom } = installSomaHarness(); + const opts = createNumberFieldOpts(); + opts.value.current = 10; + opts.step.current = 2; + opts.dir.current = 'rtl'; + + const { result: provider, cleanup } = withEffectRoot(() => NumberFieldProvider.create(opts)); + vi.spyOn(NumberFieldProvider.ctx, 'get').mockReturnValue(provider); + + const scrubber = NumberFieldScrubberProvider.create({ + id: state('number-field-scrubber'), + ref: state(null), + direction: state<'horizontal' | 'vertical'>('vertical'), + sensitivity: state(5) + }); + const target = { setPointerCapture: vi.fn() }; + + scrubber.onpointerdown({ + button: 0, + clientX: 0, + clientY: 0, + preventDefault: vi.fn() + } as never); + // Drag upward (negative clientY) → increment, unaffected by RTL. + scrubber.onpointermove({ + clientX: 0, + clientY: -15, + pointerId: 1, + currentTarget: target, + preventDefault: vi.fn() + } as never); + + expect(opts.value.current).toBe(16); + + cleanup(); + dom.dispose(); + }); }); diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.ts index 89bcc5006..8f7251b85 100644 --- a/src/uix/soma/components/number-field/number-field-provider.svelte.ts +++ b/src/uix/soma/components/number-field/number-field-provider.svelte.ts @@ -820,7 +820,12 @@ export class NumberFieldScrubberProvider { const step = this.provider.step; const isHorizontal = this.opts.direction.current === 'horizontal'; - const delta = isHorizontal ? e.clientX - this.startX : -(e.clientY - this.startY); + // In RTL layout, horizontal scrubbing is mirrored: dragging right should + // DECREMENT (value visually moves backward). Vertical scrubbing is + // unaffected by writing direction. + const isRtlHorizontal = isHorizontal && this.provider.dir === 'rtl'; + const rawDelta = isHorizontal ? e.clientX - this.startX : -(e.clientY - this.startY); + const delta = isRtlHorizontal ? -rawDelta : rawDelta; const steps = Math.round(delta / sensitivity); const newValue = this.startValue + steps * step; diff --git a/src/uix/soma/components/search-field/components/search-field-icon.svelte b/src/uix/soma/components/search-field/components/search-field-icon.svelte new file mode 100644 index 000000000..605f94377 --- /dev/null +++ b/src/uix/soma/components/search-field/components/search-field-icon.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/soma/components/search-field/components/search-field-loading-indicator.svelte b/src/uix/soma/components/search-field/components/search-field-loading-indicator.svelte new file mode 100644 index 000000000..84619db34 --- /dev/null +++ b/src/uix/soma/components/search-field/components/search-field-loading-indicator.svelte @@ -0,0 +1,38 @@ + + +{#if state.isPresent || forceMount} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {@render children?.()} + + {/if} +{/if} diff --git a/src/uix/soma/components/search-field/components/search-field.svelte b/src/uix/soma/components/search-field/components/search-field.svelte index 6d23fe19b..bd5286885 100644 --- a/src/uix/soma/components/search-field/components/search-field.svelte +++ b/src/uix/soma/components/search-field/components/search-field.svelte @@ -23,6 +23,8 @@ readonly = false, required = false, invalid = false, + loading = false, + debounceMs = 0, name, placeholder, 'aria-label': ariaLabel, @@ -50,6 +52,8 @@ readonly: readableActive(() => readonly), required: readableActive(() => required), invalid: readableActive(() => invalid), + loading: readableActive(() => loading), + debounceMs: readableActive(() => debounceMs), name: readableActive(() => name), placeholder: readableActive(() => placeholder), ariaLabel: readableActive(() => ariaLabel) diff --git a/src/uix/soma/components/search-field/exports.ts b/src/uix/soma/components/search-field/exports.ts index 11c95fb95..0c7b4dc70 100644 --- a/src/uix/soma/components/search-field/exports.ts +++ b/src/uix/soma/components/search-field/exports.ts @@ -1,10 +1,14 @@ export { default as Provider } from './components/search-field.svelte'; export { default as Input } from './components/search-field-input.svelte'; export { default as ClearTrigger } from './components/search-field-clear-trigger.svelte'; +export { default as Icon } from './components/search-field-icon.svelte'; +export { default as LoadingIndicator } from './components/search-field-loading-indicator.svelte'; export type { SearchFieldProps as ProviderProps, SearchFieldInputProps as InputProps, SearchFieldClearTriggerProps as ClearTriggerProps, + SearchFieldIconProps as IconProps, + SearchFieldLoadingIndicatorProps as LoadingIndicatorProps, SearchFieldProviderSnippetProps as ProviderSnippetProps } from './types'; diff --git a/src/uix/soma/components/search-field/search-field-provider.svelte.test.ts b/src/uix/soma/components/search-field/search-field-provider.svelte.test.ts index 465557b72..136e96ee4 100644 --- a/src/uix/soma/components/search-field/search-field-provider.svelte.test.ts +++ b/src/uix/soma/components/search-field/search-field-provider.svelte.test.ts @@ -56,6 +56,8 @@ function searchOpts(root = document.createElement('div')) { readonly: state(false), required: state(false), invalid: state(false), + loading: state(false), + debounceMs: state(0), name: state('query'), placeholder: state('Find'), clearOnEscape: state(true), diff --git a/src/uix/soma/components/search-field/search-field-provider.svelte.ts b/src/uix/soma/components/search-field/search-field-provider.svelte.ts index 895532883..e13126394 100644 --- a/src/uix/soma/components/search-field/search-field-provider.svelte.ts +++ b/src/uix/soma/components/search-field/search-field-provider.svelte.ts @@ -21,6 +21,7 @@ import { SEARCH_FIELD_LANGS } from './langs'; import { searchFieldMorfo } from '../../../morfo/components/search-field'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import type { TimerHandle } from '$timer'; // ── Root provider ────────────────────────────────────────────────────────── @@ -34,6 +35,8 @@ interface SearchFieldOpts readonly: boolean; required: boolean; invalid: boolean; + loading: boolean; + debounceMs: number; name: string | undefined; placeholder: string | undefined; clearOnEscape: boolean; @@ -65,6 +68,11 @@ export class SearchFieldProvider { inputRef = state(null); focused = $state(false); + /** Pending debounce timer for `onValueChange`. Cancelled on clear/submit. */ + private debounceTimer: TimerHandle | null = null; + /** Latest value waiting to be emitted to `onValueChange`. */ + private pendingValue: string | null = null; + private constructor(opts: SearchFieldOpts) { this.opts = opts; this.soma = Soma.require(); @@ -76,6 +84,7 @@ export class SearchFieldProvider { required: () => this.isRequired, focused: () => this.focused, empty: () => this.isEmpty, + loading: () => this.opts.loading.current, clearDisabled: () => this.isDisabled || this.isReadonly || this.isEmpty, clearOnEscape: () => this.opts.clearOnEscape.current }, @@ -95,6 +104,37 @@ export class SearchFieldProvider { // `for` attribute lands on the interactive element. Direct assign — // NOT $effect (A30). if (this.field) this.field.inputId.current = opts.inputId.current; + + // Cancel any pending debounce when the provider unmounts (A6). + $effect(() => { + return () => this.cancelDebounce(); + }); + } + + // ── Debounce plumbing ─────────────────────────────────────────────────── + + private get debounceTimerKey() { + return `soma:search-field:${this.opts.id.current}:debounce`; + } + + private cancelDebounce() { + if (this.debounceTimer) { + this.debounceTimer.cancel(); + this.debounceTimer = null; + } + this.pendingValue = null; + } + + /** Fire the pending `onValueChange` (if any) immediately. */ + private flushDebounce() { + if (!this.debounceTimer && this.pendingValue === null) return; + const value = this.pendingValue; + if (this.debounceTimer) { + this.debounceTimer.cancel(); + this.debounceTimer = null; + } + this.pendingValue = null; + if (value !== null) this.opts.onValueChange.current?.(value); } // ── Field-aware flags (OR-merge) ───────────────────────────────────────── @@ -133,11 +173,36 @@ export class SearchFieldProvider { if (this.isDisabled || this.isReadonly) return; if (next === this.opts.value.current) return; this.opts.value.current = next; + + const delay = this.opts.debounceMs.current; + if (delay > 0) { + // Replace any prior pending callback — only the latest value fires. + if (this.debounceTimer) { + this.debounceTimer.cancel(); + this.debounceTimer = null; + } + this.pendingValue = next; + this.debounceTimer = this.soma.uix.timers.schedule( + this.debounceTimerKey, + delay, + () => { + const value = this.pendingValue; + this.debounceTimer = null; + this.pendingValue = null; + if (value !== null) this.opts.onValueChange.current?.(value); + }, + { replace: true, meta: { component: 'search-field', action: 'debounce' } } + ); + return; + } + this.opts.onValueChange.current?.(next); } private applyClear() { if (this.isDisabled || this.isReadonly) return; + // Drop any pending debounce — `clear` semantics are immediate. + this.cancelDebounce(); const wasNonEmpty = this.opts.value.current !== ''; this.opts.value.current = ''; if (wasNonEmpty) { @@ -154,6 +219,9 @@ export class SearchFieldProvider { private applySubmit() { if (this.isDisabled) return; + // Flush any pending debounced change first so subscribers see the + // latest value before `onSubmit` fires. + this.flushDebounce(); this.opts.onSubmit.current?.(this.opts.value.current); } @@ -314,3 +382,66 @@ export class SearchFieldClearTriggerProvider { } as const) ); } + +// ── Icon ─────────────────────────────────────────────────────────────────── + +interface SearchFieldIconOpts extends WithRefOpts {} + +export class SearchFieldIconProvider { + readonly opts: SearchFieldIconOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: SearchFieldIconOpts) { + return new SearchFieldIconProvider(opts); + } + + readonly provider: SearchFieldProvider; + + private constructor(opts: SearchFieldIconOpts) { + this.opts = opts; + this.provider = SearchFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('icon', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props + } as const) + ); +} + +// ── LoadingIndicator ─────────────────────────────────────────────────────── + +interface SearchFieldLoadingIndicatorOpts extends WithRefOpts {} + +export class SearchFieldLoadingIndicatorProvider { + readonly opts: SearchFieldLoadingIndicatorOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: SearchFieldLoadingIndicatorOpts) { + return new SearchFieldLoadingIndicatorProvider(opts); + } + + readonly provider: SearchFieldProvider; + + private constructor(opts: SearchFieldLoadingIndicatorOpts) { + this.opts = opts; + this.provider = SearchFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('loading-indicator', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + /** Mount only while the provider's `loading` prop is truthy. */ + readonly isPresent = $derived.by(() => this.provider.opts.loading.current); + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props + } as const) + ); +} diff --git a/src/uix/soma/components/search-field/types.ts b/src/uix/soma/components/search-field/types.ts index a080c9418..8f300b0c4 100644 --- a/src/uix/soma/components/search-field/types.ts +++ b/src/uix/soma/components/search-field/types.ts @@ -64,6 +64,20 @@ export type SearchFieldProps = WithChild< name?: string; /** Placeholder forwarded to the Input. */ placeholder?: string; + + /** + * Debounce window for `onValueChange` (ms). The bindable `value` updates + * synchronously on every keystroke; only the callback is delayed. Set + * `0` to fire immediately. `clear()` and `submit()` always flush + * pending debounces. @default 0 + */ + debounceMs?: number; + /** + * When `true`, projects `data-loading` on the root and renders any + * `LoadingIndicator` part. Purely visual — consumer drives the flag + * around its async search. @default false + */ + loading?: boolean; /** * Accessible name for the search input. Ignored when inside a * `Field.Provider` with a `Field.Label` (the Label wins via @@ -112,3 +126,26 @@ export type SearchFieldClearTriggerProps = WithChild<{ 'aria-label'?: string; }> & Without; + +// ── Icon ────────────────────────────────────────────────────────────────── + +/** + * Props for `SearchField.Icon` — purely decorative prefix slot (e.g. a + * magnifier glyph). Emits `aria-hidden='true'` so screen readers ignore it. + */ +export type SearchFieldIconProps = WithChild<{ id?: string }> & + Without; + +// ── LoadingIndicator ────────────────────────────────────────────────────── + +/** + * Props for `SearchField.LoadingIndicator` — rendered only while the + * Provider's `loading` prop is `true`. Consumer controls the flag while an + * async search is in flight. + */ +export type SearchFieldLoadingIndicatorProps = WithChild<{ + id?: string; + /** When `true`, the indicator stays mounted even if `loading=false`. */ + forceMount?: boolean; +}> & + Without; diff --git a/web/routes/uix/components/combobox/+page.svelte b/web/routes/uix/components/combobox/+page.svelte index c16b9979a..0d036394d 100644 --- a/web/routes/uix/components/combobox/+page.svelte +++ b/web/routes/uix/components/combobox/+page.svelte @@ -62,6 +62,7 @@ let disabled = $state(params.get('perm.disabled') === 'true'); let required = $state(false); let allowDeselect = $state(true); + let allowsCustomValue = $state(false); let loop = $state(false); let size = $state('md'); let variant = $state('surface'); @@ -257,6 +258,7 @@ {disabled} {required} {allowDeselect} + {allowsCustomValue} {loop} {size} {variant} @@ -270,6 +272,7 @@ data-perm-mode={!disabled ? 'type="re"' : undefined} data-perm-label={!disabled ? 'type query' : undefined} /> + @@ -379,6 +382,10 @@ allowDeselect + - {#each ['clearOnEscape', 'disabled', 'readonly', 'required', 'invalid'] as flag} + {#each ['clearOnEscape', 'disabled', 'readonly', 'required', 'invalid', 'loading'] as flag} {/each} +
@@ -392,7 +429,7 @@ - {#each searchFieldMorfo.parts[1].keyboard ?? [] as key} + {#each (searchFieldMorfo.parts.find((p) => p.kebab === 'input')?.keyboard) ?? [] as key} {/each}
KeyAction
{key.key}{key.action}