From 30e9517a9add22770d6d1121a430ae14bc3eb5de Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 14:26:14 +0200 Subject: [PATCH] feat(combobox): SelectedTags inline chips for multi mode New parts ``, `` and `` render the current selection as removable chips inside `` when `type='multiple'` (Material UI Autocomplete pattern): - Morfo: 3 new public parts with kebab names + texts entries. - Langs: es/en entries `selected-tags` / `selected-tag-remove`. - Soma: `ComboboxSelectedTagsProvider` exposes `items` derived from the provider's label registry; `ComboboxSelectedTagProvider` surfaces `data-value` / `data-label`; `ComboboxSelectedTagRemoveProvider` resolves its parent tag via context and calls `provider.removeValue` on click. Provider gains `getSelectedItems()` and `removeValue()` helpers; `selectedTagsEmpty` runtime prop drives `[data-empty]`. - Soma item registry: stop unregistering labels on Item cleanup so chips keep proper labels after the listbox closes/lazy-unmounts. - Eidos: default snippet loops items and renders a chip + X icon per entry; consumer can override the snippet for custom rendering. - CSS: chip styling, wrap-friendly Control with breathing padding when chips are present, ClearButton pinned right via auto margin, input flex basis reduced to 4rem so it shares a row with chips. - Demo: renders `` inside Control when `mode === 'multiple'`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../combobox-selected-tag-remove.svelte | 29 ++++ .../combobox/combobox-selected-tag.svelte | 14 ++ .../combobox/combobox-selected-tags.svelte | 30 ++++ .../eidos/components/combobox/combobox.css | 91 +++++++++- src/uix/eidos/components/combobox/index.ts | 12 ++ src/uix/eidos/components/combobox/types.ts | 6 + src/uix/langs/components/combobox.ts | 8 + src/uix/morfo/components/combobox.ts | 55 +++++- .../combobox/combobox-provider.svelte.ts | 159 +++++++++++++++++- .../combobox-selected-tag-remove.svelte | 35 ++++ .../components/combobox-selected-tag.svelte | 41 +++++ .../components/combobox-selected-tags.svelte | 36 ++++ src/uix/soma/components/combobox/exports.ts | 8 +- src/uix/soma/components/combobox/types.ts | 38 +++++ .../uix/components/combobox/+page.svelte | 3 + 15 files changed, 558 insertions(+), 7 deletions(-) create mode 100644 src/uix/eidos/components/combobox/combobox-selected-tag-remove.svelte create mode 100644 src/uix/eidos/components/combobox/combobox-selected-tag.svelte create mode 100644 src/uix/eidos/components/combobox/combobox-selected-tags.svelte create mode 100644 src/uix/soma/components/combobox/components/combobox-selected-tag-remove.svelte create mode 100644 src/uix/soma/components/combobox/components/combobox-selected-tag.svelte create mode 100644 src/uix/soma/components/combobox/components/combobox-selected-tags.svelte diff --git a/src/uix/eidos/components/combobox/combobox-selected-tag-remove.svelte b/src/uix/eidos/components/combobox/combobox-selected-tag-remove.svelte new file mode 100644 index 000000000..92c146f48 --- /dev/null +++ b/src/uix/eidos/components/combobox/combobox-selected-tag-remove.svelte @@ -0,0 +1,29 @@ + + + + {#if children} + {@render children()} + {:else} + + {/if} + diff --git a/src/uix/eidos/components/combobox/combobox-selected-tag.svelte b/src/uix/eidos/components/combobox/combobox-selected-tag.svelte new file mode 100644 index 000000000..539d12876 --- /dev/null +++ b/src/uix/eidos/components/combobox/combobox-selected-tag.svelte @@ -0,0 +1,14 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/combobox/combobox-selected-tags.svelte b/src/uix/eidos/components/combobox/combobox-selected-tags.svelte new file mode 100644 index 000000000..f7f70d0c7 --- /dev/null +++ b/src/uix/eidos/components/combobox/combobox-selected-tags.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children(snippetProps)} + {#if children} + {@render children(snippetProps)} + {:else} + {#each snippetProps.items as item (item.value)} + + {item.label} + + + {/each} + {/if} + {/snippet} + diff --git a/src/uix/eidos/components/combobox/combobox.css b/src/uix/eidos/components/combobox/combobox.css index d28e1d923..dba1ed552 100644 --- a/src/uix/eidos/components/combobox/combobox.css +++ b/src/uix/eidos/components/combobox/combobox.css @@ -88,6 +88,7 @@ box-sizing: border-box; display: flex; align-items: center; + flex-wrap: wrap; gap: var(--_combobox-control-gap); inline-size: 100%; min-inline-size: 0; @@ -164,9 +165,11 @@ [data-combobox-input] { box-sizing: border-box; - flex: 1 1 auto; - inline-size: 100%; - min-inline-size: 0; + /* Basis `4rem` lets the input share a wrapped row with chips + + * clear/trigger affordances while still growing to fill any remaining + * space. Pure `inline-size: 100%` forces a new line under flex-wrap. */ + flex: 1 1 4rem; + min-inline-size: 4rem; border: 0; background: transparent; color: inherit; @@ -489,3 +492,85 @@ transition: none; } } + +/* SelectedTags — inline chip strip inside the Control for multi mode. + * Hidden when empty so the control collapses back to plain input chrome. */ +[data-combobox-selected-tags] { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1-5); + min-inline-size: 0; +} + +[data-combobox-selected-tags][data-empty] { + display: none; +} + +/* Make the Control breathe vertically when chips are present so the + * input + chips stack visually instead of clipping. */ +[data-combobox-control]:has([data-combobox-selected-tags]:not([data-empty])) { + padding-block: var(--space-1); +} + +/* When chips are present, pin the right-side affordances (ClearButton + + * Trigger) to the end of the row so they don't visually float in the + * middle when the input + chips wrap. */ +[data-combobox-control]:has([data-combobox-selected-tags]:not([data-empty])) + [data-combobox-clear-button] { + margin-inline-start: auto; +} + +[data-combobox-selected-tag] { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding-inline: var(--space-2) var(--space-0-5); + min-block-size: calc(var(--_combobox-control-height) - var(--space-2)); + border: var(--border-width) solid var(--color-border-default); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + color: var(--color-content-primary); + font-size: calc(var(--_combobox-font-size) - 1px); + line-height: var(--leading-ui); + white-space: nowrap; +} + +[data-combobox-selected-tag][data-disabled] { + opacity: 0.56; +} + +[data-combobox-selected-tag-remove] { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--space-4); + block-size: var(--space-4); + margin-inline-start: var(--space-0-5); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + opacity: 0.7; + cursor: pointer; + transition: + background var(--duration-fast) var(--ease-default), + opacity var(--duration-fast) var(--ease-default); +} + +[data-combobox-selected-tag-remove]:hover { + background: var(--color-surface-overlay); + opacity: 1; +} + +[data-combobox-selected-tag-remove]:focus-visible { + outline: var(--focus-ring-width) solid var(--color-primary-border); + outline-offset: var(--focus-ring-offset); + opacity: 1; +} + +[data-combobox-selected-tag-remove][data-disabled], +[data-combobox-selected-tag-remove]:disabled { + cursor: not-allowed; + opacity: 0.4; +} diff --git a/src/uix/eidos/components/combobox/index.ts b/src/uix/eidos/components/combobox/index.ts index 32d240a7f..f2ef71dc4 100644 --- a/src/uix/eidos/components/combobox/index.ts +++ b/src/uix/eidos/components/combobox/index.ts @@ -12,6 +12,9 @@ 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 SelectedTags from './combobox-selected-tags.svelte' +import SelectedTag from './combobox-selected-tag.svelte' +import SelectedTagRemove from './combobox-selected-tag-remove.svelte' import { Portal } from '$soma/components/internal' type ComboboxNamespace = typeof ComboboxComponent & { @@ -29,6 +32,9 @@ type ComboboxNamespace = typeof ComboboxComponent & { Separator: typeof Separator Arrow: typeof Arrow ClearButton: typeof ClearButton + SelectedTags: typeof SelectedTags + SelectedTag: typeof SelectedTag + SelectedTagRemove: typeof SelectedTagRemove } const Combobox = ComboboxComponent as ComboboxNamespace @@ -46,6 +52,9 @@ Combobox.GroupHeading = GroupHeading Combobox.Separator = Separator Combobox.Arrow = Arrow Combobox.ClearButton = ClearButton +Combobox.SelectedTags = SelectedTags +Combobox.SelectedTag = SelectedTag +Combobox.SelectedTagRemove = SelectedTagRemove export { Combobox } @@ -66,6 +75,9 @@ export type { ComboboxSeparatorProps as SeparatorProps, ComboboxArrowProps as ArrowProps, ComboboxClearButtonProps as ClearButtonProps, + ComboboxSelectedTagsProps as SelectedTagsProps, + ComboboxSelectedTagProps as SelectedTagProps, + ComboboxSelectedTagRemoveProps as SelectedTagRemoveProps, ComboboxSize, ComboboxVariant, ComboboxColor diff --git a/src/uix/eidos/components/combobox/types.ts b/src/uix/eidos/components/combobox/types.ts index 6f46d2a32..b5fa4f799 100644 --- a/src/uix/eidos/components/combobox/types.ts +++ b/src/uix/eidos/components/combobox/types.ts @@ -7,6 +7,9 @@ import type { InputProps, ItemProps, ProviderProps, + SelectedTagsProps, + SelectedTagProps, + SelectedTagRemoveProps, SeparatorProps, TriggerProps, ViewportProps @@ -70,3 +73,6 @@ export type ComboboxGroupHeadingProps = GroupHeadingProps export type ComboboxSeparatorProps = SeparatorProps export type ComboboxArrowProps = ArrowProps export type ComboboxClearButtonProps = ClearButtonProps +export type ComboboxSelectedTagsProps = SelectedTagsProps +export type ComboboxSelectedTagProps = SelectedTagProps +export type ComboboxSelectedTagRemoveProps = SelectedTagRemoveProps diff --git a/src/uix/langs/components/combobox.ts b/src/uix/langs/components/combobox.ts index 8d259e7e0..b4f3e9fde 100644 --- a/src/uix/langs/components/combobox.ts +++ b/src/uix/langs/components/combobox.ts @@ -19,5 +19,13 @@ export const comboboxLangs = { clear: { es: 'Borrar valor', en: 'Clear value' + }, + 'selected-tags': { + es: 'Valores seleccionados', + en: 'Selected values' + }, + 'selected-tag-remove': { + es: 'Quitar', + en: 'Remove' } } satisfies LangNode; diff --git a/src/uix/morfo/components/combobox.ts b/src/uix/morfo/components/combobox.ts index 19084660b..b3fbb5952 100644 --- a/src/uix/morfo/components/combobox.ts +++ b/src/uix/morfo/components/combobox.ts @@ -9,7 +9,9 @@ export const comboboxMorfo = { texts: { label: '#?components.combobox.label|Combobox', toggle: '#?components.combobox.toggle|Toggle', - clear: '#?components.combobox.clear|Clear value' + clear: '#?components.combobox.clear|Clear value', + selectedTags: '#?components.combobox.selected-tags|Selected values', + selectedTagRemove: '#?components.combobox.selected-tag-remove|Remove' }, events: [ { @@ -242,6 +244,57 @@ export const comboboxMorfo = { optional: true, data: [], aria: [] + }, + { + name: 'SelectedTags', + kebab: 'selected-tags', + archetype: 'group', + kind: 'public', + defaultElement: 'div', + role: 'group', + optional: true, + data: [ + { attr: 'data-empty', value: v.propRef('selectedTagsEmpty'), severity: 'optional' } + ], + aria: [ + { + attr: 'aria-label', + value: v.translationRef('#?components.combobox.selected-tags|Selected values'), + severity: 'recommended' + } + ] + }, + { + name: 'SelectedTag', + kebab: 'selected-tag', + archetype: 'item', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [ + { attr: 'data-value' }, + { attr: 'data-label', severity: 'optional' }, + { attr: 'data-disabled', severity: 'optional' } + ], + aria: [] + }, + { + name: 'SelectedTagRemove', + kebab: 'selected-tag-remove', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [{ attr: 'data-disabled', severity: 'optional' }], + aria: [ + { attr: 'type', value: v.literal('button') }, + { + attr: 'aria-label', + value: v.translationRef('#?components.combobox.selected-tag-remove|Remove'), + severity: 'recommended' + } + ] } ] } as const satisfies Morfo; diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.ts index b2b1f06cf..55fb23f60 100644 --- a/src/uix/soma/components/combobox/combobox-provider.svelte.ts +++ b/src/uix/soma/components/combobox/combobox-provider.svelte.ts @@ -112,7 +112,8 @@ export class ComboboxProvider { type: () => this.opts.type.current, multiple: () => this.opts.type.current === 'multiple', empty: () => this.isEmpty, - clearDisabled: () => this.opts.disabled.current || this.isEmpty + clearDisabled: () => this.opts.disabled.current || this.isEmpty, + selectedTagsEmpty: () => this.opts.value.current.length === 0 }, parts: { input: () => this.inputId.current || undefined, @@ -298,6 +299,30 @@ export class ComboboxProvider { return this.opts.value.current.map((v) => this.labelRegistry.get(v) ?? v).join(', '); } + /** + * Snapshot of currently-selected `{ value, label }` pairs in selection + * order. Drives `` chip rendering. Reads the + * label registry so chips reflect the same labels the menu shows. + */ + getSelectedItems(): Array<{ value: string; label: string }> { + return this.opts.value.current.map((v) => ({ + value: v, + label: this.labelRegistry.get(v) ?? v + })); + } + + /** + * Remove a single value from the selection. Used by + * `` chip X buttons. No-op if the combobox + * is disabled. + */ + removeValue(value: string) { + if (this.opts.disabled.current) return; + const current = this.opts.value.current; + if (!current.includes(value)) return; + this.opts.value.current = current.filter((v) => v !== value); + } + readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const)); } @@ -723,9 +748,14 @@ export class ComboboxItemProvider { // Maintain value → label registry without a ref-reading mount effect. // Item text fallback belongs to consumers; label is the stable headless // source for display text when content unmounts. + // + // We register on mount but DO NOT unregister on cleanup — keeping the + // label around after the listbox closes/lazy-unmounts lets + // `` render the proper label for previously- + // rendered values even when their `Item` is no longer in the DOM. + // Re-registration on remount is idempotent (SvelteMap.set). const value = opts.value.current; this.provider.registerLabel(value, opts.label.current || value); - $effect(() => () => this.provider.unregisterLabel(value)); } readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current)); @@ -893,6 +923,131 @@ export class ComboboxArrowProvider { ); } +// ── SelectedTags ──────────────────────────────────────────────────────────── + +interface ComboboxSelectedTagsOpts extends WithRefOpts {} + +export class ComboboxSelectedTagsProvider { + readonly opts: ComboboxSelectedTagsOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: ComboboxSelectedTagsOpts) { + return new ComboboxSelectedTagsProvider(opts); + } + + readonly provider: ComboboxProvider; + + private constructor(opts: ComboboxSelectedTagsOpts) { + this.opts = opts; + this.provider = ComboboxProvider.require(); + this.runtimePart = this.provider.runtime.part('selected-tags', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + /** Live snapshot of `{ value, label }` for every selected entry. Drives the chip loop. */ + readonly items = $derived.by(() => this.provider.getSelectedItems()); + + readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const)); +} + +// ── SelectedTag ───────────────────────────────────────────────────────────── + +interface ComboboxSelectedTagOpts + extends + WithRefOpts, + ActiveProps<{ + value: string; + label: string; + disabled: boolean; + }> {} + +export class ComboboxSelectedTagProvider { + readonly opts: ComboboxSelectedTagOpts; + readonly runtimePart: SomaRuntimePart; + static readonly ctx = context('ComboboxSelectedTag'); + static get(): ComboboxSelectedTagProvider | undefined { + return this.ctx.getOr(undefined) as ComboboxSelectedTagProvider | undefined; + } + static require(): ComboboxSelectedTagProvider { + return this.ctx.get(); + } + + static create(opts: ComboboxSelectedTagOpts) { + return new ComboboxSelectedTagProvider(opts); + } + + readonly provider: ComboboxProvider; + + private constructor(opts: ComboboxSelectedTagOpts) { + this.opts = opts; + this.provider = ComboboxProvider.require(); + this.runtimePart = this.provider.runtime.part('selected-tag', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ComboboxSelectedTagProvider.ctx + }); + } + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + 'data-value': this.opts.value.current, + 'data-label': this.opts.label.current || undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current) + } as const) + ); +} + +// ── SelectedTagRemove ─────────────────────────────────────────────────────── + +interface ComboboxSelectedTagRemoveOpts extends WithRefOpts {} + +export class ComboboxSelectedTagRemoveProvider { + readonly opts: ComboboxSelectedTagRemoveOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: ComboboxSelectedTagRemoveOpts) { + return new ComboboxSelectedTagRemoveProvider(opts); + } + + readonly provider: ComboboxProvider; + readonly tag: ComboboxSelectedTagProvider; + + private constructor(opts: ComboboxSelectedTagRemoveOpts) { + this.opts = opts; + this.tag = ComboboxSelectedTagProvider.require(); + this.provider = this.tag.provider; + this.runtimePart = this.provider.runtime.part('selected-tag-remove', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + readonly disabled = $derived.by( + () => this.provider.opts.disabled.current || this.tag.opts.disabled.current + ); + + readonly onclick = (e: SomaMouseEvent) => { + if (this.disabled) return; + e.preventDefault(); + e.stopPropagation(); + this.provider.removeValue(this.tag.opts.value.current); + }; + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + type: 'button' as const, + 'data-disabled': boolToEmptyStrOrUndef(this.disabled), + disabled: this.disabled || undefined, + onclick: this.onclick + } as const) + ); +} + // ── ClearButton ───────────────────────────────────────────────────────────── interface ComboboxClearButtonOpts extends WithRefOpts {} diff --git a/src/uix/soma/components/combobox/components/combobox-selected-tag-remove.svelte b/src/uix/soma/components/combobox/components/combobox-selected-tag-remove.svelte new file mode 100644 index 000000000..a46c2276e --- /dev/null +++ b/src/uix/soma/components/combobox/components/combobox-selected-tag-remove.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/combobox/components/combobox-selected-tag.svelte b/src/uix/soma/components/combobox/components/combobox-selected-tag.svelte new file mode 100644 index 000000000..77e3227af --- /dev/null +++ b/src/uix/soma/components/combobox/components/combobox-selected-tag.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/soma/components/combobox/components/combobox-selected-tags.svelte b/src/uix/soma/components/combobox/components/combobox-selected-tags.svelte new file mode 100644 index 000000000..dff3f8501 --- /dev/null +++ b/src/uix/soma/components/combobox/components/combobox-selected-tags.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps, ...snippetProps })} +{:else} +
+ {@render children?.(snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/combobox/exports.ts b/src/uix/soma/components/combobox/exports.ts index 9d342808c..7eb6eff4d 100644 --- a/src/uix/soma/components/combobox/exports.ts +++ b/src/uix/soma/components/combobox/exports.ts @@ -9,6 +9,9 @@ export { default as GroupHeading } from './components/combobox-group-heading.sve 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 { default as SelectedTags } from './components/combobox-selected-tags.svelte'; +export { default as SelectedTag } from './components/combobox-selected-tag.svelte'; +export { default as SelectedTagRemove } from './components/combobox-selected-tag-remove.svelte'; export type { ComboboxType, @@ -22,5 +25,8 @@ export type { ComboboxGroupHeadingProps as GroupHeadingProps, ComboboxSeparatorProps as SeparatorProps, ComboboxArrowProps as ArrowProps, - ComboboxClearButtonProps as ClearButtonProps + ComboboxClearButtonProps as ClearButtonProps, + ComboboxSelectedTagsProps as SelectedTagsProps, + ComboboxSelectedTagProps as SelectedTagProps, + ComboboxSelectedTagRemoveProps as SelectedTagRemoveProps } from './types'; diff --git a/src/uix/soma/components/combobox/types.ts b/src/uix/soma/components/combobox/types.ts index 7925f09c1..e914b6671 100644 --- a/src/uix/soma/components/combobox/types.ts +++ b/src/uix/soma/components/combobox/types.ts @@ -166,3 +166,41 @@ export type ComboboxClearButtonProps = WithChild<{ id?: string; }> & Without; + +/** + * Container for the inline selected-value chips that render inside + * `` when `type === 'multiple'`. The `children` snippet + * receives `{ items: Array<{ value, label }> }` — the consumer iterates + * and composes one `` per entry. + * + * In multi mode, hides itself (via `[data-empty]`) when no values are + * selected so the control collapses back to plain input chrome. + */ +export type ComboboxSelectedTagsProps = WithChild< + { + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + }, + { items: Array<{ value: string; label: string }> } +> & + Without; + +/** A single chip rendered for one selected value. Hosts label + remove button. */ +export type ComboboxSelectedTagProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Required. The value this chip represents. */ + value: string; + /** Display label. Falls back to `value`. */ + label?: string; + /** @default false */ + disabled?: boolean; +}> & + Without; + +/** X button inside a `` that removes its value from the selection. */ +export type ComboboxSelectedTagRemoveProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; diff --git a/web/routes/uix/components/combobox/+page.svelte b/web/routes/uix/components/combobox/+page.svelte index 0d036394d..f35616e91 100644 --- a/web/routes/uix/components/combobox/+page.svelte +++ b/web/routes/uix/components/combobox/+page.svelte @@ -266,6 +266,9 @@ name="framework" > + {#if mode === 'multiple'} + + {/if}