diff --git a/src/lib/glob/lib/dati_engine.svelte.ts b/src/lib/glob/lib/dati_engine.svelte.ts index 8c13602c1..288cc9b5c 100644 --- a/src/lib/glob/lib/dati_engine.svelte.ts +++ b/src/lib/glob/lib/dati_engine.svelte.ts @@ -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'; diff --git a/src/routes/test/tags-input/+page.svelte b/src/routes/test/tags-input/+page.svelte new file mode 100644 index 000000000..da15fa574 --- /dev/null +++ b/src/routes/test/tags-input/+page.svelte @@ -0,0 +1,180 @@ + + +
+

Tags Input

+ + +
+

Base — Enter o coma para añadir

+ + {#each tags as tag, i} + + + × + + {/each} + + ✕ + +

Valor: {JSON.stringify(tags)}

+
+ + +
+

Máximo 3 tags

+ + {#each maxTags as tag, i} + + + × + + {/each} + = 3 ? 'Máximo alcanzado' : 'Añadir…'} class="input" /> + +

{maxTags.length}/3 tags

+
+ + +
+

Envío de formulario

+
{ e.preventDefault(); alert(`Tags: ${formTags.join(', ')}`); }}> + + {#each formTags as tag, i} + + + × + + {/each} + + + +
+
+ + +
+

Deshabilitado

+ + {#each disabledTags as tag, i} + + + × + + {/each} + + +
+
+ + diff --git a/src/uix/terra/index.ts b/src/uix/terra/index.ts index 6cfb906dd..828a203e8 100644 --- a/src/uix/terra/index.ts +++ b/src/uix/terra/index.ts @@ -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'; diff --git a/src/uix/terra/tags-input/components/tags-input-clear.svelte b/src/uix/terra/tags-input/components/tags-input-clear.svelte new file mode 100644 index 000000000..c482ac2d1 --- /dev/null +++ b/src/uix/terra/tags-input/components/tags-input-clear.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/tags-input/components/tags-input-input.svelte b/src/uix/terra/tags-input/components/tags-input-input.svelte new file mode 100644 index 000000000..9d55559a4 --- /dev/null +++ b/src/uix/terra/tags-input/components/tags-input-input.svelte @@ -0,0 +1,27 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/tags-input/components/tags-input-root.svelte b/src/uix/terra/tags-input/components/tags-input-root.svelte new file mode 100644 index 000000000..117392188 --- /dev/null +++ b/src/uix/terra/tags-input/components/tags-input-root.svelte @@ -0,0 +1,61 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} + {#if name} + {#each value as tag} + + {/each} + {/if} +
+{/if} diff --git a/src/uix/terra/tags-input/components/tags-input-tag-remove.svelte b/src/uix/terra/tags-input/components/tags-input-tag-remove.svelte new file mode 100644 index 000000000..805272d62 --- /dev/null +++ b/src/uix/terra/tags-input/components/tags-input-tag-remove.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/tags-input/components/tags-input-tag-text.svelte b/src/uix/terra/tags-input/components/tags-input-tag-text.svelte new file mode 100644 index 000000000..a35711eae --- /dev/null +++ b/src/uix/terra/tags-input/components/tags-input-tag-text.svelte @@ -0,0 +1,28 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + {tag.opts.value.current} +{/if} diff --git a/src/uix/terra/tags-input/components/tags-input-tag.svelte b/src/uix/terra/tags-input/components/tags-input-tag.svelte new file mode 100644 index 000000000..9e05dcb35 --- /dev/null +++ b/src/uix/terra/tags-input/components/tags-input-tag.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps, ...tagState.snippetProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/terra/tags-input/exports.ts b/src/uix/terra/tags-input/exports.ts new file mode 100644 index 000000000..65db2e554 --- /dev/null +++ b/src/uix/terra/tags-input/exports.ts @@ -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'; diff --git a/src/uix/terra/tags-input/index.ts b/src/uix/terra/tags-input/index.ts new file mode 100644 index 000000000..61c3992bc --- /dev/null +++ b/src/uix/terra/tags-input/index.ts @@ -0,0 +1,2 @@ +export * as TagsInput from './exports'; +export * from './exports'; diff --git a/src/uix/terra/tags-input/tags-input.svelte.ts b/src/uix/terra/tags-input/tags-input.svelte.ts new file mode 100644 index 000000000..9a2f6385e --- /dev/null +++ b/src/uix/terra/tags-input/tags-input.svelte.ts @@ -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('TagsInput.Root'); +export const TagsInputTagContext = createTerraContext('TagsInput.Tag'); + +// ─── Root ───────────────────────────────────────────────────────────────────── + +interface TagsInputRootStateOpts + extends WithRefOpts, + ActiveProps<{ + onValueChange: OnChangeFn | undefined; + onInputValueChange: OnChangeFn | 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; + + private constructor(opts: TagsInputInputStateOpts, root: TagsInputRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly #handleKeydown = (e: TerraKeyboardEvent): 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): 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): 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; + value: Active; +} + +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): 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): 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); +} diff --git a/src/uix/terra/tags-input/types.ts b/src/uix/terra/tags-input/types.ts new file mode 100644 index 000000000..444ebb85f --- /dev/null +++ b/src/uix/terra/tags-input/types.ts @@ -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; + + /** + * Texto actual del input. + * @bindable + */ + inputValue?: string; + + /** Callback invocado cuando cambia el texto del input. */ + onInputValueChange?: OnChangeFn; + + /** + * 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 `` por cada tag. + */ + name?: string; +}; + +export type TagsInputRootPropsWithoutHTML = WithChild; +export type TagsInputRootProps = TagsInputRootPropsWithoutHTML & + Without; + +// ─── Input ──────────────────────────────────────────────────────────────────── + +export type TagsInputInputPropsWithoutHTML = WithChild; +export type TagsInputInputProps = TagsInputInputPropsWithoutHTML & + Without; + +// ─── 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; +export type TagsInputTagProps = TagsInputTagPropsWithoutHTML & + Without; + +// ─── TagText ───────────────────────────────────────────────────────────────── + +export type TagsInputTagTextPropsWithoutHTML = WithChild; +export type TagsInputTagTextProps = TagsInputTagTextPropsWithoutHTML & + Without; + +// ─── TagRemove ─────────────────────────────────────────────────────────────── + +export type TagsInputTagRemovePropsWithoutHTML = WithChild; +export type TagsInputTagRemoveProps = TagsInputTagRemovePropsWithoutHTML & + Without; + +// ─── Clear ─────────────────────────────────────────────────────────────────── + +export type TagsInputClearPropsWithoutHTML = WithChild; +export type TagsInputClearProps = TagsInputClearPropsWithoutHTML & + Without;