feat(number-field, search-field, combobox): audit-driven improvements

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 <SearchField.Icon><Search /></SearchField.Icon>.
- 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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 7bc17485dc
commit 9fbf8e48b5

@ -0,0 +1,29 @@
<script lang="ts">
/**
* Eidos `Combobox.ClearButton` — auto-X icon when no children are provided.
* Inline SVG keeps the wrapper self-contained (mirrors `Drawer.Close`).
*/
import * as Combobox from '$soma/components/combobox';
import type { ComboboxClearButtonProps } from './types';
let { children, ...rest }: ComboboxClearButtonProps = $props();
</script>
<Combobox.ClearButton {...rest}>
{#if children}
{@render children()}
{:else}
<svg
width="12"
height="12"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
{/if}
</Combobox.ClearButton>

@ -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],

@ -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

@ -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

@ -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);

@ -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,

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

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

@ -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;
}
}

@ -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 };

@ -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;

@ -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'

@ -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',

@ -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',

@ -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(() => {})
};

@ -46,6 +46,7 @@ interface ComboboxOpts
name: string;
loop: boolean;
allowDeselect: boolean;
allowsCustomValue: boolean;
dir: Direction;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
@ -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<HTMLElement>) => {
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)
);
}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ComboboxClearButtonProvider } from '../combobox-provider.svelte';
import type { ComboboxClearButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'combobox-clear-button'),
children,
child,
...restProps
}: ComboboxClearButtonProps = $props();
const state = ComboboxClearButtonProvider.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}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -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)
});

@ -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';

@ -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;

@ -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<PrimitiveButtonAttributes, {}>;

@ -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<HTMLElement | null>(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<HTMLElement | null>(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();
});
});

@ -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;

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { SearchFieldIconProvider } from '../search-field-provider.svelte';
import type { SearchFieldIconProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'search-field-icon'),
children,
child,
...restProps
}: SearchFieldIconProps = $props();
const state = SearchFieldIconProvider.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}

@ -0,0 +1,38 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { SearchFieldLoadingIndicatorProvider } from '../search-field-provider.svelte';
import type { SearchFieldLoadingIndicatorProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'search-field-loading-indicator'),
forceMount = false,
children,
child,
...restProps
}: SearchFieldLoadingIndicatorProps = $props();
const state = SearchFieldLoadingIndicatorProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isPresent || forceMount}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}
{/if}

@ -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)

@ -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';

@ -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<string | undefined>('query'),
placeholder: state<string | undefined>('Find'),
clearOnEscape: state(true),

@ -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<HTMLInputElement | null>(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)
);
}

@ -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<PrimitiveButtonAttributes, { 'aria-label'?: string }>;
// ── 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<PrimitiveDivAttributes, { 'aria-hidden'?: unknown }>;
// ── 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<PrimitiveDivAttributes, { 'aria-hidden'?: unknown; 'aria-live'?: unknown }>;

@ -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<ComboboxSize>('md');
let variant = $state<ComboboxVariant>('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}
/>
<Combobox.ClearButton />
<Combobox.Trigger>
<Combobox.Indicator />
</Combobox.Trigger>
@ -379,6 +382,10 @@
<input type="checkbox" bind:checked={allowDeselect} />
<span data-uix-switch-label>allowDeselect</span>
</label>
<label data-uix-switch>
<input type="checkbox" bind:checked={allowsCustomValue} />
<span data-uix-switch-label>allowsCustomValue</span>
</label>
<label data-uix-switch>
<input type="checkbox" bind:checked={loop} />
<span data-uix-switch-label>loop</span>

@ -5,7 +5,7 @@
type SearchFieldSize,
type SearchFieldVariant
} from '$uix/eidos/components/search-field';
import { X } from '$uix/eidos/components/icon';
import { Search, X } from '$uix/eidos/components/icon';
import { compileMorfo } from '$uix/morfo';
import { searchFieldMorfo } from '@/uix/morfo/components/search-field';
import { getActiveUix } from '$active-uix';
@ -26,7 +26,11 @@
let size = $state<SearchFieldSize>('md');
let variant = $state<SearchFieldVariant>('surface');
let color = $state<SearchFieldColor>('primary');
let loading = $state(false);
let debounceMs = $state(0);
let lastSubmit = $state('none');
let lastValueChangeAt = $state<number | null>(null);
let lastValueChange = $state('—');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
@ -99,8 +103,12 @@
`<SearchField bind:value placeholder="Search components" size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
loading && ' loading',
debounceMs > 0 && ` debounceMs={${debounceMs}}`,
'>',
' <SearchField.Icon><Search size="1em" decorative /></SearchField.Icon>',
' <SearchField.Input />',
' <SearchField.LoadingIndicator />',
' <SearchField.ClearTrigger aria-label="Clear"><X size="1em" decorative /></SearchField.ClearTrigger>',
'</SearchField>'
]
@ -139,13 +147,23 @@
{readonly}
{required}
{invalid}
{loading}
{debounceMs}
{size}
{variant}
{color}
onValueChange={(next) => {
lastValueChange = next === '' ? 'empty' : next;
lastValueChangeAt = Date.now();
}}
onSubmit={(next) => (lastSubmit = next || 'empty')}
onClear={() => (lastSubmit = 'cleared')}
>
<SearchField.Icon>
<Search size="1em" decorative />
</SearchField.Icon>
<SearchField.Input />
<SearchField.LoadingIndicator />
<SearchField.ClearTrigger
aria-label={uix.langs.ts('#?components.search-field.clear|Clear')}
>
@ -171,6 +189,12 @@
<span data-uix-stage-trace-key>value</span>
<span data-uix-stage-trace-event>{isEmpty ? 'empty' : value}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>onChange</span>
<span data-uix-stage-trace-event>{lastValueChange}</span>
{#if lastValueChangeAt !== null}
<span style="color: var(--uix-text-faint)">{fmtTime(lastValueChangeAt)}</span>
{/if}
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>last</span>
{lastSubmit}
</span>
@ -215,7 +239,7 @@
<span data-uix-control-label>placeholder</span>
<input type="text" bind:value={placeholder} />
</label>
{#each ['clearOnEscape', 'disabled', 'readonly', 'required', 'invalid'] as flag}
{#each ['clearOnEscape', 'disabled', 'readonly', 'required', 'invalid', 'loading'] as flag}
<label data-uix-control>
<span data-uix-control-label>{flag}</span>
<span data-uix-switch>
@ -231,13 +255,26 @@
{:else if flag === 'required'}
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>{required ? 'on' : 'off'}</span>
{:else}
{:else if flag === 'invalid'}
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>{invalid ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={loading} />
<span data-uix-switch-label>{loading ? 'on' : 'off'}</span>
{/if}
</span>
</label>
{/each}
<label data-uix-control>
<span data-uix-control-label>debounceMs</span>
<input
type="number"
min="0"
step="50"
bind:value={debounceMs}
style="inline-size: 6rem;"
/>
</label>
</div>
<div data-uix-subsection-head>
@ -392,7 +429,7 @@
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
{#each searchFieldMorfo.parts[1].keyboard ?? [] as key}
{#each (searchFieldMorfo.parts.find((p) => p.kebab === 'input')?.keyboard) ?? [] as key}
<tr><td class="name">{key.key}</td><td>{key.action}</td></tr>
{/each}
</tbody>

Loading…
Cancel
Save

Powered by TurnKey Linux.