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
parent
24e226bdda
commit
68105e46a6
@ -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…
Reference in new issue