Add TagsInput component

Headless tags/chips input with full keyboard support:
- Enter/delimiter → add tag, Backspace on empty → remove last
- ArrowLeft/Right → navigate between tags, Delete/Backspace on tag → remove
- Clear button, max tags limit, duplicate prevention
- Form submission via hidden inputs (name prop)
- Parts: Root, Input, Tag, TagText, TagRemove, Clear

Also fix dati_engine.svelte.ts DateOrder import after linter edit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
dev 6 months ago
parent 24e226bdda
commit 68105e46a6

@ -5,7 +5,8 @@
* Recibe un getter de locale externo para la dependencia reactiva de idioma.
*/
import type { DatiConfig, DatiInstance, DatiTimeFormat, DateOrder } from './dati_types.ts';
import type { DatiConfig, DatiInstance, DatiTimeFormat } from './dati_types.ts';
import type { DateOrder } from '$lib/util/dates/core/types';
import type { LocaleResolver } from './comn_types.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
import { createDati } from './dati_engine.ts';

@ -0,0 +1,180 @@
<script lang="ts">
import { TagsInput } from '$uix/terra';
let tags = $state<string[]>(['svelte', 'typescript']);
let inputValue = $state('');
let maxTags = $state<string[]>(['uno', 'dos', 'tres']);
let maxInput = $state('');
let formTags = $state<string[]>(['alpha']);
let formInput = $state('');
let disabledTags = $state<string[]>(['no-editable']);
let disabledInput = $state('');
</script>
<div class="page">
<h1>Tags Input</h1>
<!-- Base -->
<section class="card">
<h2>Base — Enter o coma para añadir</h2>
<TagsInput.Root bind:value={tags} bind:inputValue class="field">
{#each tags as tag, i}
<TagsInput.Tag value={tag} index={i} class="tag">
<TagsInput.TagText class="tag-text" />
<TagsInput.TagRemove class="tag-remove">×</TagsInput.TagRemove>
</TagsInput.Tag>
{/each}
<TagsInput.Input placeholder="Añadir tag…" class="input" />
<TagsInput.Clear class="clear">✕</TagsInput.Clear>
</TagsInput.Root>
<p class="hint">Valor: <code>{JSON.stringify(tags)}</code></p>
</section>
<!-- Max -->
<section class="card">
<h2>Máximo 3 tags</h2>
<TagsInput.Root bind:value={maxTags} bind:inputValue={maxInput} max={3} class="field">
{#each maxTags as tag, i}
<TagsInput.Tag value={tag} index={i} class="tag">
<TagsInput.TagText class="tag-text" />
<TagsInput.TagRemove class="tag-remove">×</TagsInput.TagRemove>
</TagsInput.Tag>
{/each}
<TagsInput.Input placeholder={maxTags.length >= 3 ? 'Máximo alcanzado' : 'Añadir…'} class="input" />
</TagsInput.Root>
<p class="hint">{maxTags.length}/3 tags</p>
</section>
<!-- Form -->
<section class="card">
<h2>Envío de formulario</h2>
<form onsubmit={(e) => { e.preventDefault(); alert(`Tags: ${formTags.join(', ')}`); }}>
<TagsInput.Root bind:value={formTags} bind:inputValue={formInput} name="tags" class="field">
{#each formTags as tag, i}
<TagsInput.Tag value={tag} index={i} class="tag">
<TagsInput.TagText class="tag-text" />
<TagsInput.TagRemove class="tag-remove">×</TagsInput.TagRemove>
</TagsInput.Tag>
{/each}
<TagsInput.Input placeholder="Añadir tag…" class="input" />
</TagsInput.Root>
<button type="submit" class="btn">Enviar</button>
</form>
</section>
<!-- Disabled -->
<section class="card">
<h2>Deshabilitado</h2>
<TagsInput.Root bind:value={disabledTags} bind:inputValue={disabledInput} disabled class="field field--disabled">
{#each disabledTags as tag, i}
<TagsInput.Tag value={tag} index={i} class="tag">
<TagsInput.TagText class="tag-text" />
<TagsInput.TagRemove class="tag-remove">×</TagsInput.TagRemove>
</TagsInput.Tag>
{/each}
<TagsInput.Input placeholder="Deshabilitado" class="input" />
</TagsInput.Root>
</section>
</div>
<style>
.page {
max-width: 560px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: 'DM Mono', monospace;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: .05em; }
.card {
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
h2 { font-size: .65rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #999; }
.hint { font-size: .68rem; color: #aaa; }
:global(.field) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .35rem;
padding: .45rem .6rem;
border: 1.5px solid #d4d0c8;
border-radius: 4px;
background: #fafaf8;
min-height: 2.4rem;
cursor: text;
}
:global(.field:focus-within) { border-color: #6366f1; outline: 2px solid #6366f120; }
:global(.field--disabled) { opacity: .5; cursor: not-allowed; }
:global(.tag) {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .15rem .5rem .15rem .55rem;
background: #6366f1;
color: #fff;
border-radius: 3px;
font-size: .78rem;
line-height: 1.6;
cursor: default;
}
:global(.tag:focus) { outline: 2px solid #6366f1; outline-offset: 2px; }
:global(.tag-text) { user-select: none; }
:global(.tag-remove) {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: #ffffffcc;
font-size: .9rem;
cursor: pointer;
padding: 0 .1rem;
line-height: 1;
}
:global(.tag-remove:hover) { color: #fff; }
:global(.input) {
flex: 1;
min-width: 8rem;
border: none;
background: transparent;
font-family: inherit;
font-size: .85rem;
outline: none;
color: #333;
}
:global(.clear) {
background: transparent;
border: none;
color: #aaa;
font-size: .85rem;
cursor: pointer;
padding: .1rem .3rem;
border-radius: 2px;
}
:global(.clear:hover:not(:disabled)) { color: #555; }
:global(.clear:disabled) { opacity: 0; pointer-events: none; }
.btn {
margin-top: .5rem;
padding: .4rem .9rem;
background: #6366f1;
color: #fff;
border: none;
border-radius: 3px;
font-family: inherit;
font-size: .82rem;
cursor: pointer;
}
.btn:hover { background: #5254cc; }
</style>

@ -25,6 +25,7 @@ export * as Tabs from './tabs';
export * as Toggle from './toggle';
export * as ToggleGroup from './toggle-group';
export * as NumberField from './number-field';
export * as TagsInput from './tags-input';
export * as PinInput from './pin-input';
export * as ScrollArea from './scroll-area';
export * as Slider from './slider';

@ -0,0 +1,30 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { TagsInputClearProps } from '../types';
import { TagsInputClearState } from '../tags-input.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
children,
child,
...restProps
}: TagsInputClearProps = $props();
const clearState = TagsInputClearState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, clearState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,27 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { TagsInputInputProps } from '../types';
import { TagsInputInputState } from '../tags-input.svelte';
const uid = $props.id();
let {
ref = $bindable<HTMLInputElement | null>(null),
id = createId(uid),
child,
...restProps
}: TagsInputInputProps = $props();
const inputState = TagsInputInputState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, inputState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<input {...mergedProps} />
{/if}

@ -0,0 +1,61 @@
<script lang="ts">
import { noop } from '$lib/util/funcs';
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { TagsInputRootProps } from '../types';
import { TagsInputRootState } from '../tags-input.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
value = $bindable<string[]>([]),
onValueChange = noop,
inputValue = $bindable(''),
onInputValueChange = noop,
delimiter = [','],
max = undefined,
allowDuplicates = false,
disabled = false,
readonly = false,
invalid = false,
name = undefined,
children,
child,
...restProps
}: TagsInputRootProps = $props();
const delimiters = $derived(Array.isArray(delimiter) ? delimiter : [delimiter]);
// svelte-ignore state_referenced_locally
const rootState = TagsInputRootState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
value: writableActive(() => value, (v) => (value = v)),
inputValue: writableActive(() => inputValue, (v) => (inputValue = v)),
onValueChange: readableActive(() => onValueChange),
onInputValueChange: readableActive(() => onInputValueChange),
delimiter: readableActive(() => delimiters),
max: readableActive(() => max),
allowDuplicates: readableActive(() => allowDuplicates),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
{#if name}
{#each value as tag}
<input type="hidden" {name} value={tag} />
{/each}
{/if}
</div>
{/if}

@ -0,0 +1,30 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { TagsInputTagRemoveProps } from '../types';
import { TagsInputTagRemoveState } from '../tags-input.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
children,
child,
...restProps
}: TagsInputTagRemoveProps = $props();
const removeState = TagsInputTagRemoveState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, removeState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,28 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { TagsInputTagTextProps } from '../types';
import { TagsInputTagTextState, TagsInputTagContext } from '../tags-input.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
...restProps
}: TagsInputTagTextProps = $props();
const textState = TagsInputTagTextState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const tag = TagsInputTagContext.get();
const mergedProps = $derived(mergeProps(restProps, textState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>{tag.opts.value.current}</span>
{/if}

@ -0,0 +1,34 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { TagsInputTagProps } from '../types';
import { TagsInputTagState } from '../tags-input.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
value,
index,
children,
child,
...restProps
}: TagsInputTagProps = $props();
const tagState = TagsInputTagState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
value: readableActive(() => value),
index: readableActive(() => index),
});
const mergedProps = $derived(mergeProps(restProps, tagState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...tagState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,15 @@
export { default as Root } from './components/tags-input-root.svelte';
export { default as Input } from './components/tags-input-input.svelte';
export { default as Tag } from './components/tags-input-tag.svelte';
export { default as TagText } from './components/tags-input-tag-text.svelte';
export { default as TagRemove } from './components/tags-input-tag-remove.svelte';
export { default as Clear } from './components/tags-input-clear.svelte';
export type {
TagsInputRootProps as RootProps,
TagsInputInputProps as InputProps,
TagsInputTagProps as TagProps,
TagsInputTagTextProps as TagTextProps,
TagsInputTagRemoveProps as TagRemoveProps,
TagsInputClearProps as ClearProps,
} from './types';

@ -0,0 +1,2 @@
export * as TagsInput from './exports';
export * from './exports';

@ -0,0 +1,398 @@
import { watch } from 'runed';
import { createTerraContext } from '$terra/utils/context';
import { attachRef, kbd } from '$terra/utils';
import type {
Active,
ActiveProps,
StateProps,
RefAttachment,
WithRefOpts,
TerraFocusEvent,
TerraKeyboardEvent,
TerraInputEvent,
OnChangeFn,
} from '$terra/utils';
import { boolToEmptyStrOrUndef, createTerraAttrs } from '../utils/attrs';
// ─── Attrs ────────────────────────────────────────────────────────────────────
export const tagsInputAttrs = createTerraAttrs({
component: 'tags-input',
parts: ['root', 'input', 'tag', 'tagText', 'tagRemove', 'clear'] as const,
});
// ─── Context ──────────────────────────────────────────────────────────────────
export const TagsInputRootContext = createTerraContext<TagsInputRootState>('TagsInput.Root');
export const TagsInputTagContext = createTerraContext<TagsInputTagState>('TagsInput.Tag');
// ─── Root ─────────────────────────────────────────────────────────────────────
interface TagsInputRootStateOpts
extends WithRefOpts,
ActiveProps<{
onValueChange: OnChangeFn<string[]> | undefined;
onInputValueChange: OnChangeFn<string> | undefined;
delimiter: string[];
max: number | undefined;
allowDuplicates: boolean;
disabled: boolean;
readonly: boolean;
invalid: boolean;
name: string | undefined;
}>,
StateProps<{
value: string[];
inputValue: string;
}> {}
export class TagsInputRootState {
static create(opts: TagsInputRootStateOpts) {
return TagsInputRootContext.set(new TagsInputRootState(opts));
}
readonly opts: TagsInputRootStateOpts;
readonly attachment: RefAttachment;
/** Índice del tag con foco (-1 = ninguno / el input tiene el foco). */
#focusedIndex = $state(-1);
private constructor(opts: TagsInputRootStateOpts) {
this.opts = opts;
this.attachment = attachRef(opts.ref);
}
// ── Computed ──────────────────────────────────────────────────────────────
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly isReadonly = $derived.by(() => this.opts.readonly.current);
readonly isInvalid = $derived.by(() => this.opts.invalid.current);
readonly #tags = $derived.by(() => this.opts.value.current);
readonly #max = $derived.by(() => this.opts.max.current);
readonly atMax = $derived.by(() => this.#max !== undefined && this.#tags.length >= this.#max);
get focusedIndex() { return this.#focusedIndex; }
// ── Actions ───────────────────────────────────────────────────────────────
addTag(raw: string): void {
if (this.isDisabled || this.isReadonly) return;
const tag = raw.trim();
if (!tag || this.atMax) return;
if (!this.opts.allowDuplicates.current && this.#tags.includes(tag)) return;
const next = [...this.#tags, tag];
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
this.#clearInput();
}
removeTag(index: number): void {
if (this.isDisabled || this.isReadonly) return;
const next = this.#tags.filter((_, i) => i !== index);
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
if (this.#focusedIndex >= next.length) {
this.#focusedIndex = next.length - 1;
}
}
removeLastTag(): void {
if (this.#tags.length > 0) this.removeTag(this.#tags.length - 1);
}
clear(): void {
if (this.isDisabled || this.isReadonly) return;
this.opts.value.current = [];
this.opts.onValueChange.current?.([]);
this.#clearInput();
}
setInputValue(v: string): void {
this.opts.inputValue.current = v;
this.opts.onInputValueChange.current?.(v);
}
focusTag(index: number): void {
this.#focusedIndex = index;
}
blurTag(): void {
this.#focusedIndex = -1;
}
#clearInput(): void {
this.opts.inputValue.current = '';
this.opts.onInputValueChange.current?.('');
}
// ── Props ─────────────────────────────────────────────────────────────────
readonly props = $derived.by(() => ({
id: this.opts.id.current,
role: 'group' as const,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
[tagsInputAttrs.root]: '',
...this.attachment,
}) as const);
}
// ─── Input ────────────────────────────────────────────────────────────────────
interface TagsInputInputStateOpts extends WithRefOpts {}
export class TagsInputInputState {
static create(opts: TagsInputInputStateOpts) {
return new TagsInputInputState(opts, TagsInputRootContext.get());
}
readonly opts: TagsInputInputStateOpts;
readonly root: TagsInputRootState;
readonly attachment: RefAttachment<HTMLInputElement>;
private constructor(opts: TagsInputInputStateOpts, root: TagsInputRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(opts.ref as any);
}
readonly #handleKeydown = (e: TerraKeyboardEvent<HTMLInputElement>): void => {
if (this.root.isDisabled || this.root.isReadonly) return;
const input = e.currentTarget;
switch (e.key) {
case kbd.ENTER:
e.preventDefault();
this.root.addTag(input.value);
break;
case kbd.BACKSPACE:
if (!input.value) {
e.preventDefault();
this.root.removeLastTag();
}
break;
case kbd.ARROW_LEFT:
if (input.selectionStart === 0 && input.selectionEnd === 0) {
e.preventDefault();
const last = this.root.opts.value.current.length - 1;
if (last >= 0) this.root.focusTag(last);
}
break;
}
};
readonly #handleInput = (e: TerraInputEvent<HTMLInputElement>): void => {
const raw = e.currentTarget.value;
const delimiters = this.root.opts.delimiter.current;
if (raw.length > 0 && delimiters.includes(raw.slice(-1))) {
this.root.addTag(raw.slice(0, -1));
} else {
this.root.setInputValue(raw);
}
};
readonly #handleFocus = (_e: TerraFocusEvent<HTMLInputElement>): void => {
this.root.blurTag();
};
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'text' as const,
value: this.root.opts.inputValue.current,
disabled: this.root.isDisabled || undefined,
readonly: this.root.isReadonly || undefined,
'aria-invalid': this.root.isInvalid ? ('true' as const) : undefined,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
'data-invalid': boolToEmptyStrOrUndef(this.root.isInvalid),
[tagsInputAttrs.input]: '',
onkeydown: this.#handleKeydown,
oninput: this.#handleInput,
onfocus: this.#handleFocus,
...this.attachment,
}) as const);
}
// ─── Tag ──────────────────────────────────────────────────────────────────────
interface TagsInputTagStateOpts extends WithRefOpts {
index: Active<number>;
value: Active<string>;
}
export class TagsInputTagState {
static create(opts: TagsInputTagStateOpts) {
return TagsInputTagContext.set(new TagsInputTagState(opts, TagsInputRootContext.get()));
}
readonly opts: TagsInputTagStateOpts;
readonly root: TagsInputRootState;
readonly attachment: RefAttachment;
private constructor(opts: TagsInputTagStateOpts, root: TagsInputRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(opts.ref);
// Foco programático cuando `focusedIndex` coincide con este tag.
watch.pre(
() => root.focusedIndex,
(idx) => {
if (idx === opts.index.current) {
(opts.ref.current as HTMLElement | null)?.focus();
}
},
);
}
readonly #isDisabled = $derived.by(() => this.root.isDisabled);
readonly #handleKeydown = (e: TerraKeyboardEvent<HTMLElement>): void => {
if (this.root.isDisabled || this.root.isReadonly) return;
switch (e.key) {
case kbd.DELETE:
case kbd.BACKSPACE: {
e.preventDefault();
this.root.removeTag(this.opts.index.current);
break;
}
case kbd.ARROW_LEFT: {
e.preventDefault();
const prev = this.opts.index.current - 1;
this.root.focusTag(prev >= 0 ? prev : -1);
break;
}
case kbd.ARROW_RIGHT: {
e.preventDefault();
const next = this.opts.index.current + 1;
const len = this.root.opts.value.current.length;
this.root.focusTag(next < len ? next : -1);
break;
}
}
};
readonly #handleBlur = (_e: TerraFocusEvent<HTMLElement>): void => {
this.root.blurTag();
};
readonly props = $derived.by(() => ({
id: this.opts.id.current,
tabindex: 0,
'aria-label': this.opts.value.current,
'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
'data-tag-value': this.opts.value.current,
[tagsInputAttrs.tag]: '',
onkeydown: this.#handleKeydown,
onblur: this.#handleBlur,
...this.attachment,
}) as const);
readonly snippetProps = $derived.by(() => ({
value: this.opts.value.current,
index: this.opts.index.current,
}));
}
// ─── TagText ──────────────────────────────────────────────────────────────────
interface TagsInputTagTextStateOpts extends WithRefOpts {}
export class TagsInputTagTextState {
static create(opts: TagsInputTagTextStateOpts) {
return new TagsInputTagTextState(opts, TagsInputTagContext.get());
}
readonly opts: TagsInputTagTextStateOpts;
readonly tag: TagsInputTagState;
readonly attachment: RefAttachment;
private constructor(opts: TagsInputTagTextStateOpts, tag: TagsInputTagState) {
this.opts = opts;
this.tag = tag;
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
[tagsInputAttrs.tagText]: '',
...this.attachment,
}) as const);
}
// ─── TagRemove ────────────────────────────────────────────────────────────────
interface TagsInputTagRemoveStateOpts extends WithRefOpts {}
export class TagsInputTagRemoveState {
static create(opts: TagsInputTagRemoveStateOpts) {
return new TagsInputTagRemoveState(opts, TagsInputTagContext.get());
}
readonly opts: TagsInputTagRemoveStateOpts;
readonly tag: TagsInputTagState;
readonly attachment: RefAttachment;
private constructor(opts: TagsInputTagRemoveStateOpts, tag: TagsInputTagState) {
this.opts = opts;
this.tag = tag;
this.attachment = attachRef(opts.ref);
}
readonly #isDisabled = $derived.by(() => this.tag.root.isDisabled || this.tag.root.isReadonly);
readonly #handleClick = (): void => {
this.tag.root.removeTag(this.tag.opts.index.current);
};
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'button' as const,
tabindex: -1,
disabled: this.#isDisabled || undefined,
'aria-label': `Remove ${this.tag.opts.value.current}`,
'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
[tagsInputAttrs.tagRemove]: '',
onclick: this.#handleClick,
...this.attachment,
}) as const);
}
// ─── Clear ────────────────────────────────────────────────────────────────────
interface TagsInputClearStateOpts extends WithRefOpts {}
export class TagsInputClearState {
static create(opts: TagsInputClearStateOpts) {
return new TagsInputClearState(opts, TagsInputRootContext.get());
}
readonly opts: TagsInputClearStateOpts;
readonly root: TagsInputRootState;
readonly attachment: RefAttachment;
private constructor(opts: TagsInputClearStateOpts, root: TagsInputRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(opts.ref);
}
readonly #isDisabled = $derived.by(
() => this.root.isDisabled || this.root.isReadonly || this.root.opts.value.current.length === 0,
);
readonly #handleClick = (): void => { this.root.clear(); };
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'button' as const,
tabindex: -1,
disabled: this.#isDisabled || undefined,
'aria-label': 'Clear all tags',
'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
[tagsInputAttrs.clear]: '',
onclick: this.#handleClick,
...this.attachment,
}) as const);
}

@ -0,0 +1,100 @@
import type { WithChild, Without, OnChangeFn } from '$terra/utils';
import type {
TerraPrimitiveDivAttributes,
TerraPrimitiveInputAttributes,
TerraPrimitiveButtonAttributes,
TerraPrimitiveSpanAttributes,
} from '$terra/utils';
// ─── Root ─────────────────────────────────────────────────────────────────────
type BaseTagsInputRootProps = {
/**
* Lista de tags activos.
* @bindable
*/
value?: string[];
/** Callback invocado cuando cambia la lista de tags. */
onValueChange?: OnChangeFn<string[]>;
/**
* Texto actual del input.
* @bindable
*/
inputValue?: string;
/** Callback invocado cuando cambia el texto del input. */
onInputValueChange?: OnChangeFn<string>;
/**
* Carácter(es) que confirman la entrada de un tag.
* @defaultValue [',']
*/
delimiter?: string | string[];
/** Número máximo de tags permitidos. Sin límite por defecto. */
max?: number;
/**
* Permite tags duplicados.
* @defaultValue false
*/
allowDuplicates?: boolean;
/** Deshabilita el campo completo. */
disabled?: boolean;
/** Pone el campo en modo solo lectura. */
readonly?: boolean;
/** Fuerza el estado inválido visualmente. */
invalid?: boolean;
/**
* Nombre del campo para envío de formulario.
* Genera un `<input type="hidden">` por cada tag.
*/
name?: string;
};
export type TagsInputRootPropsWithoutHTML = WithChild<BaseTagsInputRootProps>;
export type TagsInputRootProps = TagsInputRootPropsWithoutHTML &
Without<TerraPrimitiveDivAttributes, TagsInputRootPropsWithoutHTML>;
// ─── Input ────────────────────────────────────────────────────────────────────
export type TagsInputInputPropsWithoutHTML = WithChild;
export type TagsInputInputProps = TagsInputInputPropsWithoutHTML &
Without<TerraPrimitiveInputAttributes, TagsInputInputPropsWithoutHTML>;
// ─── Tag ─────────────────────────────────────────────────────────────────────
type BaseTagsInputTagProps = {
/** Valor del tag que representa este elemento. */
value: string;
/** Posición del tag en la lista (necesario para eliminación por índice). */
index: number;
};
export type TagsInputTagPropsWithoutHTML = WithChild<BaseTagsInputTagProps>;
export type TagsInputTagProps = TagsInputTagPropsWithoutHTML &
Without<TerraPrimitiveDivAttributes, TagsInputTagPropsWithoutHTML>;
// ─── TagText ─────────────────────────────────────────────────────────────────
export type TagsInputTagTextPropsWithoutHTML = WithChild;
export type TagsInputTagTextProps = TagsInputTagTextPropsWithoutHTML &
Without<TerraPrimitiveSpanAttributes, TagsInputTagTextPropsWithoutHTML>;
// ─── TagRemove ───────────────────────────────────────────────────────────────
export type TagsInputTagRemovePropsWithoutHTML = WithChild;
export type TagsInputTagRemoveProps = TagsInputTagRemovePropsWithoutHTML &
Without<TerraPrimitiveButtonAttributes, TagsInputTagRemovePropsWithoutHTML>;
// ─── Clear ───────────────────────────────────────────────────────────────────
export type TagsInputClearPropsWithoutHTML = WithChild;
export type TagsInputClearProps = TagsInputClearPropsWithoutHTML &
Without<TerraPrimitiveButtonAttributes, TagsInputClearPropsWithoutHTML>;
Loading…
Cancel
Save

Powered by TurnKey Linux.