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 @@
| Key | Action |
- {#each searchFieldMorfo.parts[1].keyboard ?? [] as key}
+ {#each (searchFieldMorfo.parts.find((p) => p.kebab === 'input')?.keyboard) ?? [] as key}
| {key.key} | {key.action} |
{/each}