feat(combobox): SelectedTags inline chips for multi mode

New parts `<Combobox.SelectedTags>`, `<Combobox.SelectedTag>` and
`<Combobox.SelectedTagRemove>` render the current selection as
removable chips inside `<Combobox.Control>` 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 `<Combobox.SelectedTags />` inside Control when
  `mode === 'multiple'`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 26daa9e22e
commit 30e9517a9a

@ -0,0 +1,29 @@
<script lang="ts">
/**
* Eidos `Combobox.SelectedTagRemove` — X button inside a chip. Inline SVG
* keeps the wrapper self-contained (mirrors `Combobox.ClearButton`).
*/
import * as Combobox from '$soma/components/combobox';
import type { ComboboxSelectedTagRemoveProps } from './types';
let { children, ...rest }: ComboboxSelectedTagRemoveProps = $props();
</script>
<Combobox.SelectedTagRemove {...rest}>
{#if children}
{@render children()}
{:else}
<svg
width="10"
height="10"
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.SelectedTagRemove>

@ -0,0 +1,14 @@
<script lang="ts">
/**
* Eidos `Combobox.SelectedTag` — visual chip wrapper. Pure pass-through;
* styling lives in `combobox.css` via `[data-combobox-selected-tag]`.
*/
import * as Combobox from '$soma/components/combobox';
import type { ComboboxSelectedTagProps } from './types';
let { children, ...rest }: ComboboxSelectedTagProps = $props();
</script>
<Combobox.SelectedTag {...rest}>
{@render children?.()}
</Combobox.SelectedTag>

@ -0,0 +1,30 @@
<script lang="ts">
/**
* Eidos `Combobox.SelectedTags` — renders inline chips for every selected
* value when `type='multiple'`. If the consumer doesn't supply a children
* snippet, a default chip + remove button is rendered per item (Material
* UI Autocomplete pattern). Consumers can override the rendering by
* passing their own snippet that receives `{ items }`.
*/
import * as Combobox from '$soma/components/combobox';
import SelectedTag from './combobox-selected-tag.svelte';
import SelectedTagRemove from './combobox-selected-tag-remove.svelte';
import type { ComboboxSelectedTagsProps } from './types';
let { children, ...rest }: ComboboxSelectedTagsProps = $props();
</script>
<Combobox.SelectedTags {...rest}>
{#snippet children(snippetProps)}
{#if children}
{@render children(snippetProps)}
{:else}
{#each snippetProps.items as item (item.value)}
<SelectedTag value={item.value} label={item.label}>
<span>{item.label}</span>
<SelectedTagRemove />
</SelectedTag>
{/each}
{/if}
{/snippet}
</Combobox.SelectedTags>

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

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

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

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

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

@ -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 `<Combobox.SelectedTags>` 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
* `<Combobox.SelectedTagRemove>` 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
// `<Combobox.SelectedTags>` 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<ComboboxSelectedTagProvider>('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<HTMLElement>) => {
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 {}

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

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ComboboxSelectedTagProvider } from '../combobox-provider.svelte';
import type { ComboboxSelectedTagProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'combobox-selected-tag'),
value,
label = '',
disabled = false,
children,
child,
...restProps
}: ComboboxSelectedTagProps = $props();
const state = ComboboxSelectedTagProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
label: readableActive(() => label),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,36 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ComboboxSelectedTagsProvider } from '../combobox-provider.svelte';
import type { ComboboxSelectedTagsProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'combobox-selected-tags'),
children,
child,
...restProps
}: ComboboxSelectedTagsProps = $props();
const state = ComboboxSelectedTagsProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ items: state.items });
</script>
{#if child}
{@render child({ props: mergedProps, ...snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(snippetProps)}
</div>
{/if}

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

@ -166,3 +166,41 @@ export type ComboboxClearButtonProps = WithChild<{
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
/**
* Container for the inline selected-value chips that render inside
* `<Combobox.Control>` when `type === 'multiple'`. The `children` snippet
* receives `{ items: Array<{ value, label }> }` — the consumer iterates
* and composes one `<Combobox.SelectedTag>` 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<PrimitiveDivAttributes, {}>;
/** 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<PrimitiveDivAttributes, {}>;
/** X button inside a `<Combobox.SelectedTag>` that removes its value from the selection. */
export type ComboboxSelectedTagRemoveProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;

@ -266,6 +266,9 @@
name="framework"
>
<Combobox.Control>
{#if mode === 'multiple'}
<Combobox.SelectedTags />
{/if}
<Combobox.Input
placeholder="Search framework..."
data-perm-step={!disabled ? '0' : undefined}

Loading…
Cancel
Save

Powered by TurnKey Linux.