Stabilize terra architecture and runtime contracts

glm-5
dev 6 months ago
parent 0766cfe49f
commit a86ebdcf69

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
export const ID_PREFIX = '#?';
export const ID_FALLBACK_SEPARATOR = '|';
export const DEFAULT_ISO_LOCALE = 'es' as const;

@ -12,7 +12,7 @@ import {LING_ERRORS} from "./errors.ts";
import {ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP} from "./consts.ts";
import {isIDLing, isLingRecord} from "./guards.ts";
import {pluralRule} from "./plural_rules.ts";
import {resolvePath} from "./helpers.ts";
import { parseIDLing, resolvePath } from './helpers.ts';
@ -134,7 +134,8 @@ export function createLing<S extends LingNode>(
}
if (isIDLing(value)) {
const path = value.substring(ID_PREFIX.length);
const parsed = parseIDLing(value);
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
const seen = visited ?? new Set<string>();
if (seen.has(path)) {
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(path));
@ -142,6 +143,15 @@ export function createLing<S extends LingNode>(
}
seen.add(path);
const resolved = resolvePath(currentSchema, path);
if (resolved === undefined) {
if (parsed?.fallback !== undefined) {
if (DEV) logger.warn(LOGGER_CATEGORY, `${LING_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`);
return parsed.fallback;
}
return undefined;
}
return resolveValue(resolved, args, depth + 1, seen);
}

@ -2,8 +2,9 @@ import type {IDLing, LingRecord, LingString} from "./types.ts";
import {DEFAULT_ISO_LOCALE, ID_PREFIX} from "./consts.ts";
/**
* Guard para identificar referencias.
* Usamos un chequeo de longitud para evitar que "#?" vacío sea válido.
* Guard para identificar referencias `IDLing`.
* Acepta tanto `#?path` como `#?path|fallback`.
* Usamos un chequeo de longitud para evitar que `#?` vacío sea válido.
*/
export function isIDLing(value: unknown): value is IDLing {
return (

@ -2,7 +2,7 @@
// HELPERS
// ==============================
import type {LingRecord, LingString} from "./types.ts";
import {DEFAULT_ISO_LOCALE} from "./consts.ts";
import { DEFAULT_ISO_LOCALE, ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts';
import { isLingString } from "./guards.ts";
@ -13,6 +13,24 @@ export function resolvePath(obj: any, path: string): any {
export function parseIDLing(value: string): { path: string; fallback?: string } | null {
if (!value.startsWith(ID_PREFIX) || value.length <= ID_PREFIX.length) return null;
const raw = value.slice(ID_PREFIX.length);
const separatorIndex = raw.indexOf(ID_FALLBACK_SEPARATOR);
if (separatorIndex === -1) {
return { path: raw };
}
return {
path: raw.slice(0, separatorIndex),
fallback: raw.slice(separatorIndex + 1),
};
}
export function makeLingRecord (text?: string) : LingRecord {
return {
[DEFAULT_ISO_LOCALE]: text ?? ''

@ -14,7 +14,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isIDLing, isLingRecord, isLingString } from '../lib/guards';
import { resolvePath, makeLingRecord } from '../lib/helpers';
import { parseIDLing, resolvePath, makeLingRecord } from '../lib/helpers';
import { pluralRule } from '../lib/plural_rules';
import { createLing, p } from '../lib/engine';
import { lingNodeToJSON, JSONToLingNode } from '../lib/json';
@ -114,6 +114,25 @@ describe('isLingString', () => {
// HELPERS
// ============================================================================
describe('parseIDLing', () => {
it('extrae path sin fallback', () => {
expect(parseIDLing('#?common.ok')).toEqual({ path: 'common.ok' });
});
it('extrae path y fallback literal', () => {
expect(parseIDLing('#?terra.calendar.month|month')).toEqual({
path: 'terra.calendar.month',
fallback: 'month',
});
});
it('devuelve null para strings no IDLing', () => {
expect(parseIDLing('terra.calendar.month')).toBeNull();
expect(parseIDLing('#?')).toBeNull();
});
});
describe('resolvePath', () => {
const obj = { a: { b: { c: 'valor' } } };
@ -320,6 +339,18 @@ describe('createLing — ts()', () => {
it('resuelve referencias IDLing', () => {
expect(ling.ts('#?common.ok')).toBe('Aceptar');
});
it('usa fallback literal cuando la key IDLing no existe', () => {
expect(ling.ts('#?terra.calendar.month|month')).toBe('month');
});
it('prioriza la key del schema sobre el fallback literal', () => {
expect(ling.ts('#?common.ok|fallback')).toBe('Aceptar');
});
it('mantiene strings normales como literales sin lookup implicito', () => {
expect(ling.ts('terra.calendar.month')).toBe('terra.calendar.month');
});
});

@ -26,7 +26,7 @@
<TestLayout
title="Spinner"
description="Indicadores de carga visual con múltiples variantes, tamaños y velocidades. Componente visual del sistema Air."
description="Indicadores de carga visual con múltiples variantes, tamaños y velocidades. Componente visual del sistema Air"
badges={['Air', 'v1.0']}
{breadcrumbs}
>
@ -34,7 +34,7 @@
<!-- Demo 1: Playground -->
<DemoSection
title="Playground"
description="Configura la variante, tamaño y velocidad del spinner."
description="Configura la variante, tamaño y velocidad del spinner"
>
<div class="playground">
<div class="spinner-hero">
@ -72,7 +72,7 @@
<!-- Demo 2: Variants -->
<DemoSection
title="Variants"
description="Todas las variantes visuales disponibles."
description="Todas las variantes visuales disponibles"
variant="bordered"
>
<div class="grid grid--variants">
@ -88,7 +88,7 @@
<!-- Demo 3: Sizes -->
<DemoSection
title="Sizes"
description="Todos los tamaños disponibles con la variante circular."
description="Todos los tamaños disponibles con la variante circular"
>
<div class="grid grid--sizes">
{#each sizes as item}
@ -103,7 +103,7 @@
<!-- Demo 4: In context -->
<DemoSection
title="In context"
description="Ejemplos de uso en contexto con botones y estados.",
description="Ejemplos de uso en contexto con botones y estados."
variant="bordered"
>
<div class="panel">

@ -32,7 +32,7 @@
<!-- Demo 1: Button element -->
<DemoSection
title="Elemento &lt;button&gt;"
description="Sin <code>href</code> renderiza un <code>&lt;button&gt;</code> nativo con <code>data-button-root</code>."
description="Sin <code>href</code> renderiza un <code>&lt;button&gt;</code> nativo con <code>data-button</code>."
>
<div class="demo-row">
<Button.Root class="btn btn--indigo" onclick={() => log('button · default')}>

@ -1,6 +1,7 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { TerraConfig, Calendar } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/config';
import type { DateOrder } from '$lib/util/dates';
import type { DateValue } from '$lib/util/dates';
import { today, getLocalTimeZone, CalendarDate, isSameDay } from '$lib/util/dates';
@ -25,8 +26,14 @@
{ id: 'ja-JP', label: 'ja-JP' },
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
});
const DATE_ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
});
// Disabled functions
function isWeekend(date: DateValue): boolean {
@ -469,7 +476,7 @@
<p>Este calendario hereda locale y dateOrder desde TerraConfig sin props directas.</p>
</header>
<TerraConfig defaultLocale={locale} defaultDateOrder={dateOrder}>
<TerraConfig {translator} {dateTimeFormatter}>
<Calendar.Root type="single" bind:value={configValue} class="cal">
{#snippet children({ months, weekdays, dateOrder })}
<Calendar.Header class="cal-hd cal-hd--sel">

@ -319,7 +319,7 @@
}
/* VirtualList */
:global([data-command-root]) {
:global([data-command]) {
display: flex;
flex-direction: column;
background: var(--color-surface);
@ -513,7 +513,7 @@
overflow: hidden;
}
:global(.dialog-command) :global([data-command-root]) {
:global(.dialog-command) :global([data-command]) {
display: flex;
flex-direction: column;
height: 400px;

@ -1,6 +1,7 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { TerraConfig, DatePicker } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/config';
import { CalendarDate, type DateValue, getLocalTimeZone, today, type DateOrder } from '$lib/util/dates';
const tz = getLocalTimeZone();
@ -14,7 +15,13 @@
const ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
});
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
});
let value1 = $state<DateValue | undefined>(new CalendarDate(now.year, now.month, now.day));
let value2 = $state<DateValue | undefined>(new CalendarDate(now.year, now.month, Math.min(now.day + 3, 28)));
let value3 = $state<DateValue | undefined>(undefined);
@ -194,7 +201,7 @@
description="Locale y dateOrder heredados desde TerraConfig."
variant="bordered"
>
<TerraConfig defaultLocale={locale} defaultDateOrder={dateOrder}>
<TerraConfig {translator} {dateTimeFormatter}>
<DatePicker.Root bind:value={value3} bind:open={open3}>
<DatePicker.Label class="dp-label">Fecha heredada</DatePicker.Label>
<div class="dp-row">

@ -1,6 +1,7 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { TerraConfig, DateRangeField } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/config';
import type { DateOrder, HourCycle } from '$lib/util/dates';
import type { DateValue } from '$lib/util/dates';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dates';
@ -18,9 +19,15 @@
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
});
const DATE_ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
const TIME_FORMATS: HourCycle[] = [24, 12];
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
});
let timeFormat = $state<HourCycle>(24);
// States
@ -305,7 +312,7 @@
variant="bordered"
>
<div class="field-wrap">
<TerraConfig defaultLocale={locale} defaultDateOrder={dateOrder} defaultTimeFormat={timeFormat}>
<TerraConfig {translator} {dateTimeFormatter}>
<DateRangeField.Root bind:value={value6} class="drf">
<DateRangeField.Label class="drf-label">Rango desde config</DateRangeField.Label>
<div class="drf-inputs">

@ -41,7 +41,7 @@
<TestLayout
title="Editable"
description="Inline edit component with preview/input modes, action buttons, and validation."
description="Inline edit component with preview/input modes, action buttons, and validation"
badges={['Terra', 'v1.0']}
{breadcrumbs}
>
@ -49,7 +49,7 @@
<!-- Demo 1: Base -->
<DemoSection
title="Base - click to edit"
description="Click preview to activate input. Enter to confirm, Escape to cancel."
description="Click preview to activate input. Enter to confirm, Escape to cancel"
>
<Editable.Root
bind:value={name}
@ -76,7 +76,7 @@
<!-- Demo 2: With action buttons -->
<DemoSection
title="With action buttons"
description="EditTrigger opens edit mode. SubmitTrigger saves. CancelTrigger discards changes."
description="EditTrigger opens edit mode. SubmitTrigger saves. CancelTrigger discards changes"
variant="bordered"
>
<Editable.Root bind:value={bio} bind:editing={bioEdit} class="editable editable--block">
@ -95,7 +95,7 @@
<!-- Demo 3: No submit on blur -->
<DemoSection
title="submitOnBlur = false"
description="Blur cancels instead of saving. Press Enter to confirm changes."
description="Blur cancels instead of saving. Press Enter to confirm changes"
>
<Editable.Root bind:value={tag} bind:editing={tagEdit} submitOnBlur={false} class="editable">
<Editable.Area class="area">
@ -120,7 +120,7 @@
<!-- Demo 4: With placeholder -->
<DemoSection
title="With placeholder"
description="When empty, Preview shows placeholder with different style.",
description="When empty, Preview shows placeholder with different style."
variant="bordered"
>
<Editable.Root bind:value={note} bind:editing={noteEdit} placeholder="Type a note..." class="editable">
@ -134,7 +134,7 @@
<!-- Demo 5: maxLength -->
<DemoSection
title="maxLength = 20"
description="Character limit validation."
description="Character limit validation"
>
<Editable.Root bind:value={limited} bind:editing={limEdit} maxLength={20} class="editable">
<Editable.Area class="area">
@ -148,7 +148,7 @@
<!-- Demo 6: Readonly / Disabled -->
<DemoSection
title="States"
description="Readonly and disabled states.",
description="Readonly and disabled states."
variant="bordered"
>
<div class="states-grid">

@ -43,7 +43,7 @@
<TestLayout
title="FileUpload"
description="Componente para subir archivos con drag & drop, validacion de tipo/tamano, barra de progreso y gestion de rechazos."
description="Componente para subir archivos con drag & drop, validacion de tipo/tamano, barra de progreso y gestion de rechazos"
badges={['Terra', 'v1.0']}
{breadcrumbs}
>
@ -51,7 +51,7 @@
<!-- Demo 1: Base -->
<DemoSection
title="Base - arrastrar o clic"
description="Multiples archivos sin filtros. Arrastra archivos o haz clic para seleccionar."
description="Multiples archivos sin filtros. Arrastra archivos o haz clic para seleccionar"
>
<FileUpload.Root bind:files onReject={(r) => (rejects = r)} class="upload">
<FileUpload.Dropzone class="dropzone">
@ -90,7 +90,7 @@
<!-- Demo 2: Solo imágenes -->
<DemoSection
title="Solo imágenes - 2 MB max 4"
description="Validacion de tipo, tamano y cantidad de archivos.",
description="Validacion de tipo, tamano y cantidad de archivos."
variant="bordered"
>
<FileUpload.Root
@ -124,7 +124,7 @@
<!-- Demo 3: Archivo único -->
<DemoSection
title="Archivo único"
description="Seleccionar un nuevo archivo reemplaza el anterior."
description="Seleccionar un nuevo archivo reemplaza el anterior"
>
<FileUpload.Root bind:files={singleFile} multiple={false} class="upload">
<FileUpload.Dropzone class="dropzone dropzone--sm">
@ -148,7 +148,7 @@
<!-- Demo 4: Con progreso -->
<DemoSection
title="Con barra de progreso"
description="Selecciona archivos y haz clic en Subir para simular la carga.",
description="Selecciona archivos y haz clic en Subir para simular la carga."
variant="bordered"
>
<FileUpload.Root bind:files={uploadFiles} class="upload">
@ -180,7 +180,7 @@
<!-- Demo 5: Disabled -->
<DemoSection
title="Deshabilitado"
description="Componente deshabilitado que no permite interaccion."
description="Componente deshabilitado que no permite interaccion"
>
<FileUpload.Root bind:files={disabledFiles} disabled class="upload">
<FileUpload.Dropzone class="dropzone dropzone--sm">
@ -385,7 +385,7 @@
opacity: 0.9;
}
:global([data-file-upload-root][data-disabled]) {
:global([data-file-upload][data-disabled]) {
opacity: 0.6;
pointer-events: none;
}

@ -1,8 +1,12 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { TerraConfig, LinkPreview } from '$uix/terra';
import { createTerraTranslator } from '$uix/terra/config';
let controlledOpen = $state(false);
const translator = createTerraTranslator({
locale: () => 'es-ES',
});
const linkPreviewProps = [
{ name: 'open', type: 'boolean', default: 'undefined', description: 'Controlled open state (bind:open)' },
@ -18,7 +22,7 @@
<TestLayout
title="LinkPreview"
description="Link preview popover that appears on hover or focus with custom content."
description="Link preview popover that appears on hover or focus with custom content"
badges={['Terra', 'v1.0']}
{breadcrumbs}
>
@ -26,7 +30,7 @@
<!-- Demo 1: Basic -->
<DemoSection
title="Basic"
description="Hover or focus the link to open the preview popover."
description="Hover or focus the link to open the preview popover"
>
<LinkPreview.Root>
<LinkPreview.Trigger href="https://example.com" class="link-trigger">example.com</LinkPreview.Trigger>
@ -43,7 +47,7 @@
<!-- Demo 2: Positions -->
<DemoSection
title="Positions"
description="Preview can appear on any side of the trigger element."
description="Preview can appear on any side of the trigger element"
variant="bordered"
>
<div class="positions-grid">
@ -64,7 +68,7 @@
<!-- Demo 3: Controlled -->
<DemoSection
title="Controlled"
description="Bind open state to external checkbox control.",
description="Bind open state to external checkbox control."
>
<div class="controlled-wrapper">
<label class="toggle-row">
@ -89,7 +93,7 @@
description="Static content without extra floating wrapper"
variant="bordered"
>
<TerraConfig defaultLocale="es-ES">
<TerraConfig {translator}>
<div class="static-wrapper">
<LinkPreview.Root openDelay={150} closeDelay={150}>
<LinkPreview.Trigger href="https://example.com/static" class="link-trigger">Static preview</LinkPreview.Trigger>

@ -205,7 +205,7 @@
}
/* ── Menubar root ────────────────────────────────────────────────────────── */
:global([data-menubar-root]) {
:global([data-menubar]) {
display: flex;
align-items: center;
gap: 2px;

@ -1,6 +1,7 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { TerraConfig, RangeCalendar } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/config';
import type { DateOrder } from '$lib/util/dates';
import type { DateValue } from '$lib/util/dates';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dates';
@ -18,8 +19,14 @@
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
});
const DATE_ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
});
// States
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
@ -442,7 +449,7 @@
description="Este rango hereda locale y dateOrder desde TerraConfig sin pasarlos por props."
variant="bordered"
>
<TerraConfig defaultLocale={locale} defaultDateOrder={dateOrder}>
<TerraConfig {translator} {dateTimeFormatter}>
<RangeCalendar.Root bind:value={value6} class="cal">
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">

@ -150,7 +150,7 @@
.vertical-wrap { display: inline-flex; justify-content: center; height: 200px; width: 20px; }
/* ── SLIDER ROOT ────────────────────────────────────────────────────── */
:global([data-slider-root]) {
:global([data-slider]) {
display: block;
position: relative;
width: 100%;
@ -159,11 +159,11 @@
border-radius: var(--radius-full);
cursor: pointer;
}
:global([data-slider-root][data-orientation="vertical"]) {
:global([data-slider][data-orientation="vertical"]) {
width: 6px;
height: 100%;
}
:global([data-slider-root][data-disabled]) { opacity: .5; cursor: default; }
:global([data-slider][data-disabled]) { opacity: .5; cursor: default; }
/* ── RANGE ──────────────────────────────────────────────────────────── */
:global([data-slider-range]) {
@ -171,7 +171,7 @@
border-radius: var(--radius-full);
height: 100%;
}
:global([data-slider-root][data-orientation="vertical"] [data-slider-range]) {
:global([data-slider][data-orientation="vertical"] [data-slider-range]) {
width: 100%;
height: auto;
}
@ -193,7 +193,7 @@
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
:global([data-slider-root][data-orientation="vertical"] [data-slider-thumb]) {
:global([data-slider][data-orientation="vertical"] [data-slider-thumb]) {
top: auto;
margin-top: 0;
left: 50%;

@ -170,7 +170,7 @@
}
/* Splitter Root */
:global([data-splitter-root]) {
:global([data-splitter]) {
display: flex;
overflow: hidden;
border-radius: var(--radius-md);
@ -180,11 +180,11 @@
height: 100%;
}
:global([data-splitter-root][data-orientation='horizontal']) {
:global([data-splitter][data-orientation='horizontal']) {
flex-direction: row;
}
:global([data-splitter-root][data-orientation='vertical']) {
:global([data-splitter][data-orientation='vertical']) {
flex-direction: column;
}

@ -43,7 +43,7 @@
<TestLayout
title="Stepper"
description="Componente para guiar al usuario a través de múltiples pasos en un proceso. Soporta orientación horizontal/vertical, navegación lineal y callbacks."
description="Componente para guiar al usuario a través de múltiples pasos en un proceso. Soporta orientación horizontal/vertical, navegación lineal y callbacks"
badges={['Terra', 'v1.0']}
{breadcrumbs}
>
@ -51,7 +51,7 @@
<!-- Demo 1: Horizontal básico -->
<DemoSection
title="Horizontal — 3 pasos"
description="Navegación horizontal con indicadores de estado y separadores."
description="Navegación horizontal con indicadores de estado y separadores"
>
<Stepper.Root bind:step={step1} bind:completedSteps={completed1} class="stepper">
<Stepper.List class="list">
@ -86,7 +86,7 @@
<!-- Demo 2: Con descripción -->
<DemoSection
title="Con descripción"
description="Incluye descripción debajo del título de cada paso.",
description="Incluye descripción debajo del título de cada paso."
variant="bordered"
>
<Stepper.Root bind:step={step2} bind:completedSteps={completed2} class="stepper">
@ -129,7 +129,7 @@
<!-- Demo 3: Vertical -->
<DemoSection
title="Vertical"
description="Orientación vertical con contenido a la derecha de los pasos."
description="Orientación vertical con contenido a la derecha de los pasos"
>
<Stepper.Root bind:step={step3} bind:completedSteps={completed3} orientation="vertical" class="stepper stepper--v">
<Stepper.List class="list list--v">
@ -164,7 +164,7 @@
<!-- Demo 4: Linear -->
<DemoSection
title="Linear — no se puede saltar pasos"
description="Los triggers de pasos futuros están deshabilitados hasta completar el anterior.",
description="Los triggers de pasos futuros están deshabilitados hasta completar el anterior."
variant="bordered"
>
<Stepper.Root bind:step={step4} bind:completedSteps={completed4} linear class="stepper">
@ -200,7 +200,7 @@
<!-- Demo 5: Controlled con callbacks -->
<DemoSection
title="Controlado con callbacks"
description="Los callbacks registran cada cambio de paso o completado.",
description="Los callbacks registran cada cambio de paso o completado."
variant="bordered"
>
<Stepper.Root
@ -438,7 +438,7 @@
}
/* Vertical */
:global(.stepper--v[data-stepper-root]) {
:global(.stepper--v[data-stepper]) {
display: flex;
gap: var(--spacing-6);
align-items: flex-start;

@ -1,6 +1,4 @@
<script lang="ts">
import '../_components/design-system.css';
type DemoItem = {
name: string;
path: string;
@ -14,6 +12,11 @@
};
const groups: DemoGroup[] = [
{
title: 'Config / Runtime',
description: 'Infraestructura transversal: TerraConfig, adapters y puentes reactivos.',
items: [{ name: 'Ling Adapter', path: '/test/terra/ling', status: 'stable' }],
},
{
title: 'Date / Time',
description: 'Campos, pickers y calendarios reactivos con la presentation config nueva.',
@ -38,7 +41,7 @@
{ name: 'ContextMenu', path: '/test/context-menu', status: 'stable' },
{ name: 'Menubar', path: '/test/menubar', status: 'stable' },
{ name: 'Dialog', path: '/test/dialog', status: 'stable' },
{ name: 'Drawer', path: '/test/drawer', status: 'wip' },
{ name: 'Drawer', path: '/test/drawer', status: 'wip' },
{ name: 'AlertDialog', path: '/test/alert-dialog', status: 'stable' },
{ name: 'Toast', path: '/test/toast', status: 'stable' },
{ name: 'Stepper', path: '/test/stepper', status: 'stable' },
@ -51,6 +54,7 @@
description: 'Primitivas y compuestos de interacción y formularios.',
items: [
{ name: 'Checkbox', path: '/test/checkbox', status: 'stable' },
{ name: 'Form', path: '/test/form', status: 'wip' },
{ name: 'Field', path: '/test/field', status: 'wip' },
{ name: 'NumberField', path: '/test/number-field', status: 'stable' },
@ -101,59 +105,56 @@
</script>
<svelte:head>
<title>Terra Tests - Component Hub</title>
<title>Terra Tests</title>
</svelte:head>
<div class="test-page terra-hub">
<!-- Header -->
<header class="terra-hub-header">
<div class="terra-hub-header-inner">
<div>
<p class="terra-hub-eyebrow">Terra</p>
<h1>Test hub</h1>
<p class="terra-hub-lead">
Punto único para recorrer las demos nuevas de la librería y seguir la migración con menos ruido.
</p>
<div class="page">
<header class="hero">
<div>
<p class="eyebrow">Terra</p>
<h1>Test hub</h1>
<p class="lead">
Punto único para recorrer las demos nuevas de la librería y seguir la migración con
menos ruido.
</p>
</div>
<div class="stats">
<div class="stat">
<span class="stat-label">Total</span>
<strong>{totals.all}</strong>
</div>
<div class="terra-hub-stats">
<div class="terra-hub-stat">
<span class="terra-hub-stat-label">Total</span>
<strong>{totals.all}</strong>
</div>
<div class="terra-hub-stat terra-hub-stat--stable">
<span class="terra-hub-stat-label">Estables</span>
<strong>{totals.stable}</strong>
</div>
<div class="terra-hub-stat terra-hub-stat--wip">
<span class="terra-hub-stat-label">WIP</span>
<strong>{totals.wip}</strong>
</div>
<div class="stat">
<span class="stat-label">Estables</span>
<strong>{totals.stable}</strong>
</div>
<div class="stat stat--wip">
<span class="stat-label">WIP</span>
<strong>{totals.wip}</strong>
</div>
</div>
</header>
<!-- Top Links -->
<nav class="terra-hub-top-links">
<a href="/test">← Índice general</a>
<a href="/test/tooltip">Tooltip</a>
<a href="/test/link-preview">LinkPreview</a>
<nav class="top-links">
<a href="/test">Indice general</a>
<a href="/test/tooltip">Último floating</a>
<a href="/test/link-preview">Último link-preview</a>
<a href="/test/terra/ling">Ling adapter</a>
<a href="/test/air/spinner">Spinner (air)</a>
</nav>
<!-- Grid -->
<main class="terra-hub-grid">
<main class="grid">
{#each groups as group}
<section class="terra-hub-card">
<header class="terra-hub-card-header">
<section class="card">
<header class="card-header">
<h2>{group.title}</h2>
<p>{group.description}</p>
</header>
<div class="terra-hub-links">
<div class="links">
{#each group.items as item}
<a class="terra-hub-link" href={item.path}>
<a class="link" href={item.path}>
<span>{item.name}</span>
<span class:terra-hub-badge={true} class:terra-hub-badge--wip={item.status === 'wip'}>
<span class:badge={true} class:badge--wip={item.status === 'wip'}>
{item.status === 'wip' ? 'WIP' : 'OK'}
</span>
</a>
@ -165,189 +166,169 @@
</div>
<style>
.terra-hub {
min-height: 100vh;
padding: var(--spacing-8);
:global(*, *::before, *::after) {
box-sizing: border-box;
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.terra-hub-header {
margin-bottom: var(--spacing-8);
.page {
min-height: 100vh;
padding: 2rem;
background: #0b1020;
color: #e5e7eb;
font-family: Inter, system-ui, sans-serif;
}
.terra-hub-header-inner {
.hero {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
gap: 1.5rem;
align-items: flex-start;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: linear-gradient(180deg, rgba(30, 27, 75, 0.5), rgba(9, 9, 11, 0.8));
margin-bottom: 1.5rem;
padding: 1.5rem;
border: 1px solid #1f2a44;
border-radius: 18px;
background: linear-gradient(180deg, rgba(20, 31, 56, 0.96), rgba(10, 15, 30, 0.96));
}
.terra-hub-eyebrow {
margin: 0 0 var(--spacing-2);
font-size: var(--text-xs);
.eyebrow {
margin: 0 0 .35rem;
font-size: .72rem;
font-weight: 700;
letter-spacing: 0.14em;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--color-primary);
color: #7dd3fc;
}
.terra-hub-header h1 {
h1 {
margin: 0;
font-size: var(--text-3xl);
font-weight: 700;
letter-spacing: -0.025em;
font-size: 2rem;
line-height: 1.1;
}
.terra-hub-lead {
.lead {
max-width: 60ch;
margin: var(--spacing-3) 0 0;
color: var(--color-text-muted);
line-height: 1.6;
margin: .65rem 0 0;
color: #94a3b8;
line-height: 1.5;
}
.terra-hub-stats {
.stats {
display: flex;
gap: var(--spacing-4);
gap: .75rem;
flex-wrap: wrap;
}
.terra-hub-stat {
min-width: 100px;
padding: var(--spacing-4);
border-radius: var(--radius-lg);
background: var(--color-surface);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
align-items: center;
.stat {
min-width: 96px;
padding: .85rem 1rem;
border-radius: 14px;
background: #111827;
border: 1px solid #24324f;
}
.terra-hub-stat-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-bottom: var(--spacing-1);
.stat-label {
display: block;
font-size: .72rem;
color: #94a3b8;
margin-bottom: .25rem;
}
.terra-hub-stat strong {
font-size: var(--text-xl);
font-weight: 700;
.stat strong {
font-size: 1.15rem;
}
.terra-hub-stat--stable strong {
color: var(--color-success);
.stat--wip strong {
color: #fbbf24;
}
.terra-hub-stat--wip strong {
color: var(--color-warning);
}
/* ── Top Links ─────────────────────────────────────────────────────────────── */
.terra-hub-top-links {
.top-links {
display: flex;
gap: var(--spacing-3);
gap: .75rem;
flex-wrap: wrap;
margin-bottom: var(--spacing-6);
margin-bottom: 1.25rem;
}
.terra-hub-top-links a,
.terra-hub-link {
.top-links a,
.link {
color: inherit;
text-decoration: none;
}
.terra-hub-top-links a {
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-full);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: var(--text-sm);
transition: all var(--transition-fast);
.top-links a {
padding: .55rem .85rem;
border-radius: 999px;
background: #111827;
border: 1px solid #24324f;
color: #cbd5e1;
}
.terra-hub-top-links a:hover,
.terra-hub-link:hover {
border-color: var(--color-primary);
background: var(--color-primary-bg);
.top-links a:hover,
.link:hover {
border-color: #38bdf8;
}
/* ── Grid ─────────────────────────────────────────────────────────────────── */
.terra-hub-grid {
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--spacing-5);
}
/* ── Card ─────────────────────────────────────────────────────────────────── */
.terra-hub-card {
padding: var(--spacing-6);
border-radius: var(--radius-lg);
background: var(--color-surface);
border: 1px solid var(--color-border);
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.terra-hub-card:hover {
border-color: var(--color-border-focus);
box-shadow: var(--shadow-md);
.card {
padding: 1.15rem;
border-radius: 18px;
background: #0f172a;
border: 1px solid #1f2a44;
}
.terra-hub-card-header h2 {
margin: 0 0 var(--spacing-2);
font-size: var(--text-lg);
font-weight: 600;
.card-header h2 {
margin: 0;
font-size: 1rem;
}
.terra-hub-card-header p {
margin: 0;
font-size: var(--text-sm);
line-height: 1.5;
color: var(--color-text-muted);
.card-header p {
margin: .45rem 0 0;
font-size: .88rem;
line-height: 1.45;
color: #94a3b8;
}
/* ── Links ────────────────────────────────────────────────────────────────── */
.terra-hub-links {
.links {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
margin-top: var(--spacing-5);
gap: .6rem;
margin-top: 1rem;
}
.terra-hub-link {
.link {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--radius-md);
background: var(--color-background);
border: 1px solid var(--color-border-muted);
transition: all var(--transition-fast);
}
.terra-hub-link:hover {
border-color: var(--color-primary);
background: var(--color-primary-bg);
gap: .75rem;
padding: .75rem .85rem;
border-radius: 14px;
background: #111827;
border: 1px solid #24324f;
}
.terra-hub-badge {
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
.badge {
padding: .18rem .48rem;
border-radius: 999px;
font-size: .72rem;
font-weight: 700;
letter-spacing: 0.04em;
background: rgba(74, 222, 128, 0.1);
color: var(--color-success);
border: 1px solid rgba(74, 222, 128, 0.2);
letter-spacing: .04em;
background: rgba(34, 197, 94, .14);
color: #86efac;
}
.terra-hub-badge--wip {
background: rgba(251, 191, 36, 0.1);
color: var(--color-warning);
border-color: rgba(251, 191, 36, 0.2);
.badge--wip {
background: rgba(251, 191, 36, .14);
color: #fbbf24;
}
</style>

@ -0,0 +1,195 @@
<script lang="ts">
import { createACtx } from '../../../../lib/actx/lib/index.ts';
import { createLing, type LingNode } from '../../../../lib/ling/lib/index.ts';
import { TerraConfig } from '$uix/terra';
import { createTerraTranslator, extendTerraTranslationModule } from '$uix/terra/config';
import TerraLingPanel from './terra-ling-panel.svelte';
const ling = createLing(
{
app: {
title: {
es: 'Demo reactiva Terra + Ling',
en: 'Reactive Terra + Ling demo',
},
},
},
'es'
);
const actx = createACtx({ ling });
const terraModule = {
dialog: {
close: {
es: 'Cerrar',
en: 'Close',
},
},
fileUpload: {
removeFile: {
es: 'Eliminar archivo',
en: 'Remove file',
},
},
calendar: {
nextMonth: {
es: 'Mes siguiente',
en: 'Next month',
},
prevMonth: {
es: 'Mes anterior',
en: 'Previous month',
},
},
} satisfies LingNode;
extendTerraTranslationModule(actx.ling, terraModule);
const translator = createTerraTranslator({
locale: () => actx.locale,
translate: () => actx.ts,
translatePath: () => actx.t,
runtime: () => actx.ling,
});
let stepperInjected = $state(false);
function setLocale(locale: 'es' | 'en') {
actx.ling.setLocale(locale);
}
function injectStepperModule() {
if (stepperInjected) return;
extendTerraTranslationModule(translator, {
stepper: {
next: {
es: 'Siguiente paso',
en: 'Next step',
},
previous: {
es: 'Paso anterior',
en: 'Previous step',
},
},
});
stepperInjected = true;
}
</script>
<svelte:head>
<title>Terra Translator</title>
</svelte:head>
<div class="page">
<header class="hero">
<p class="eyebrow">TerraConfig</p>
<h1>Bridge reactivo con Ling</h1>
<p class="lead">
La demo arranca con un módulo `terra` base ya inyectado en Ling, lo expone a través de
`actx` y lo consume desde `TerraConfig` mediante `createTerraTranslator(...)`.
</p>
</header>
<div class="actions">
<button type="button" onclick={() => setLocale('es')}>Locale es</button>
<button type="button" onclick={() => setLocale('en')}>Locale en</button>
<button type="button" onclick={injectStepperModule} disabled={stepperInjected}>
{stepperInjected ? 'Stepper ya inyectado' : 'Extender schema con stepper'}
</button>
</div>
<TerraConfig {translator}>
<TerraLingPanel {stepperInjected} />
</TerraConfig>
<article class="notes">
<h2>Qué demuestra</h2>
<ul>
<li>`actx.locale` sigue siendo reactivo dentro de `terra`</li>
<li>`LingString` e IDs `#?terra.*` se resuelven desde el adapter desde el primer render</li>
<li>`ling.extend('terra', module)` invalida el panel en caliente con el módulo `stepper`</li>
</ul>
</article>
</div>
<style>
.page {
min-height: 100vh;
padding: 2rem;
background: #0b1020;
color: #e5e7eb;
font-family: Inter, system-ui, sans-serif;
}
.hero {
margin-bottom: 1.25rem;
padding: 1.5rem;
border-radius: 18px;
border: 1px solid #1f2a44;
background: linear-gradient(180deg, rgba(20, 31, 56, 0.96), rgba(10, 15, 30, 0.96));
}
.eyebrow {
margin: 0 0 .35rem;
font-size: .72rem;
font-weight: 700;
letter-spacing: .14em;
text-transform: uppercase;
color: #7dd3fc;
}
h1 {
margin: 0;
font-size: 2rem;
}
.lead {
max-width: 62ch;
margin: .65rem 0 0;
line-height: 1.55;
color: #94a3b8;
}
.actions {
display: flex;
gap: .75rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
button {
padding: .7rem 1rem;
border: 1px solid #24324f;
border-radius: 999px;
background: #111827;
color: #e5e7eb;
cursor: pointer;
}
button:disabled {
opacity: .55;
cursor: default;
}
.notes {
margin-top: 1.25rem;
padding: 1rem 1.15rem;
border-radius: 16px;
border: 1px solid #1f2a44;
background: #0f172a;
}
.notes h2 {
margin-top: 0;
font-size: 1rem;
}
.notes ul {
margin: .75rem 0 0;
padding-left: 1.1rem;
color: #cbd5e1;
}
</style>

@ -0,0 +1,75 @@
<script lang="ts">
import { getTerraConfig } from '$uix/terra/config';
let { stepperInjected = false }: { stepperInjected?: boolean } = $props();
const config = getTerraConfig();
const translator = $derived(config.translator.current);
const locale = $derived(translator?.getLocale() ?? 'n/a');
const closeLabel = $derived(translator?.translate('#?terra.dialog.close|Close') ?? '(sin translator)');
const removeFileLabel = $derived(translator?.translatePath('terra.fileUpload.removeFile') ?? '(sin translator)');
const nextMonthLabel = $derived(translator?.translate('#?terra.calendar.nextMonth|Next month') ?? '(sin translator)');
const stepperNextLabel = $derived(
stepperInjected && translator
? translator.translate('#?terra.stepper.next|Next step')
: 'Pulsa "Extender schema con stepper"'
);
</script>
<div class="panel">
<div class="row">
<span>Locale reactivo</span>
<strong>{locale}</strong>
</div>
<div class="row">
<span>`ts(#?terra.dialog.close)`</span>
<strong>{closeLabel}</strong>
</div>
<div class="row">
<span>`t(terra.fileUpload.removeFile)`</span>
<strong>{removeFileLabel}</strong>
</div>
<div class="row">
<span>`ts(#?terra.calendar.nextMonth)`</span>
<strong>{nextMonthLabel}</strong>
</div>
<div class="row row--hinted">
<span>`ts(#?terra.stepper.next)`</span>
<strong>{stepperNextLabel}</strong>
</div>
</div>
<style>
.panel {
display: grid;
gap: .75rem;
padding: 1rem;
border-radius: 16px;
border: 1px solid #24324f;
background: #0f172a;
}
.row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: .8rem .9rem;
border-radius: 12px;
background: #111827;
border: 1px solid #1f2a44;
}
.row--hinted strong {
color: #7dd3fc;
}
span {
color: #94a3b8;
font-size: .92rem;
}
strong {
font-size: .95rem;
}
</style>

@ -1,6 +1,7 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { TerraConfig, TimeField } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/config';
import { Time, type TimeValue, type HourCycle } from '$lib/util/dates';
const LOCALES = [
@ -12,7 +13,13 @@
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
});
let timeFormat = $state<HourCycle>(24);
const dateTimeFormatter = createTerraDateTimeFormatter({
timeFormat: () => timeFormat,
});
let value1 = $state<Time | undefined>(new Time(9, 30, 0));
let value2 = $state<Time | undefined>(new Time(14, 45, 20));
@ -131,7 +138,7 @@
description="Este ejemplo hereda locale y timeFormat desde TerraConfig."
variant="bordered"
>
<TerraConfig defaultLocale={locale} defaultTimeFormat={timeFormat}>
<TerraConfig {translator} {dateTimeFormatter}>
<TimeField.Root bind:value={value4} granularity="minute">
<TimeField.Label class="tf-label">Hora heredada</TimeField.Label>
<TimeField.Input class="tf-input">

@ -1,6 +1,7 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { TerraConfig, TimeRangeField } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/config';
import { Time, type HourCycle } from '$lib/util/dates';
const LOCALES = [
@ -11,7 +12,13 @@
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
});
let timeFormat = $state<HourCycle>(24);
const dateTimeFormatter = createTerraDateTimeFormatter({
timeFormat: () => timeFormat,
});
let value1 = $state<{ start: Time | undefined; end: Time | undefined } | undefined>({
start: new Time(9, 30, 0),
@ -132,7 +139,7 @@
description="Este ejemplo hereda locale y timeFormat desde TerraConfig."
variant="bordered"
>
<TerraConfig defaultLocale={locale} defaultTimeFormat={timeFormat}>
<TerraConfig {translator} {dateTimeFormatter}>
<TimeRangeField.Root bind:value={value3} granularity="minute">
<TimeRangeField.Label class="tf-label">Horario heredado</TimeRangeField.Label>
<div class="pair">

@ -141,7 +141,7 @@
<style>
/* Toast global styles */
:global([data-toast-root]) {
:global([data-toast]) {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
@ -157,33 +157,33 @@
transition: opacity 0.2s, transform 0.2s;
}
:global([data-toast-root][data-state='closed']) {
:global([data-toast][data-state='closed']) {
opacity: 0;
transform: translateY(6px) scale(0.97);
pointer-events: none;
}
:global([data-toast-root][data-type='success']) {
:global([data-toast][data-type='success']) {
background: var(--color-success-bg);
color: var(--color-success);
}
:global([data-toast-root][data-type='error']) {
:global([data-toast][data-type='error']) {
background: var(--color-error-bg);
color: var(--color-error);
}
:global([data-toast-root][data-type='warning']) {
:global([data-toast][data-type='warning']) {
background: var(--color-warning-bg);
color: var(--color-warning);
}
:global([data-toast-root][data-type='info']) {
:global([data-toast][data-type='info']) {
background: var(--color-info-bg);
color: var(--color-info);
}
:global([data-toast-root] > *) {
:global([data-toast] > *) {
flex: 1 1 auto;
}

@ -108,7 +108,7 @@
<!-- Demo 2: Lista variable + load more -->
<DemoSection
title="Lista variable + load more"
description="Usa VirtualList.Item para medir altura real y dispara carga cuando se acerca al final.",
description="Usa VirtualList.Item para medir altura real y dispara carga cuando se acerca al final."
variant="bordered"
>
<div class="section-header">

@ -82,7 +82,7 @@ src/uix/terra/
│ ├── state-machine.ts
│ ├── types.ts ← tipos internos transversales
│ └── index.ts ← barrel principal ($terra/utils)
├── adapters/
├── external/
│ └── dates/ ← frontera con libreria de fechas externa
├── errors.ts
└── index.ts ← barrel publico ($uix/terra)
@ -95,8 +95,8 @@ src/uix/terra/
| `$uix/terra` | `src/uix/terra` |
| `$terra/utils` | `src/uix/terra/utils/index.ts` (barrel) |
| `$terra/utils/context` | `src/uix/terra/utils/context.ts` |
| `$terra/utils/logger` | `src/uix/terra/utils/logger.ts` |
| `$terra/adapters/dates` | `src/uix/terra/adapters/dates/index.ts` |
| `$terra/config` | `src/uix/terra/config/exports.ts` |
| `$terra/external/dates` | `src/uix/terra/external/dates/index.ts` |
Regla: los archivos dentro de `terra/` siempre importan internamente via `$terra/utils` o por ruta relativa directa. Nunca importan desde `$uix/terra` (barrel publico).
@ -290,7 +290,7 @@ export class MyRootState {
}
readonly props = $derived.by(() => ({
'data-my-component-root': '',
'data-my-component': '',
'data-state': this.opts.open.current ? 'open' : 'closed',
}) as const);
}
@ -418,22 +418,25 @@ Ficheros en raiz de `utils/`:
## 12. `config/`: configuracion transversal
`TerraConfig` aporta defaults compartidos sin acoplar `terra` a `actx`.
`TerraConfig` aporta servicios y defaults de presentacion sin acoplar `terra` a `actx`.
Soporta:
- `defaultLocale`
- `defaultPortalTo`
- `defaultDir`
- `defaultDateOrder`
- `defaultTimeFormat`
- `translator`
- `portalTo`
- `dir`
- `dateTimeFormatter`
- `numberFormatter`
- `currencyFormatter`
- `unitFormatter`
- `logger`
### Cadena de prioridad
Para props como `locale`, `dateOrder` o `timeFormat`:
Para props como `locale`, `dateOrder`, `timeFormat` o `dir`:
1. valor explicito del componente
2. valor en `TerraConfig`
2. valor resuelto por `TerraConfig`
3. fallback interno de libreria
### Resolvers
@ -451,7 +454,10 @@ import {
### Uso en la app
```svelte
<TerraConfig defaultLocale="es-ES" defaultTimeFormat="24h">
<TerraConfig
translator={translator}
dateTimeFormatter={dateTimeFormatter}
>
<TimeField.Root />
</TerraConfig>
```
@ -462,6 +468,105 @@ Y tambien debe funcionar sin TerraConfig:
<TimeField.Root locale="en-US" timeFormat="12h" />
```
### Puente reactivo con el traductor
Cuando `terra` necesita resolver texto o IDs `#?terra.*`, no debe leer `actx` directamente desde cada componente. La integracion entra por `TerraConfig` mediante `translator`. `Ling` es un bridge posible, pero no la semantica interna de `terra`.
```ts
import {
createTerraTranslator,
createTerraDateTimeFormatter,
extendTerraTranslationModule,
} from '$uix/terra/config';
const terraTranslations = {
dialog: {
close: { es: 'Cerrar', en: 'Close' },
},
};
extendTerraTranslationModule(actx.ling, terraTranslations);
const translator = createTerraTranslator({
locale: () => actx.locale,
translate: () => actx.ts,
translatePath: () => actx.t,
runtime: () => actx.ling,
});
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => actx.dati.options.dateOrder,
timeFormat: () => actx.dati.options.timeFormat,
});
```
Uso:
```svelte
<script lang="ts">
import { TerraConfig } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/config';
const translator = createTerraTranslator({
locale: () => actx.locale,
translate: () => actx.ts,
translatePath: () => actx.t,
runtime: () => actx.ling,
});
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => actx.dati.options.dateOrder,
timeFormat: () => actx.dati.options.timeFormat,
});
</script>
<TerraConfig {translator} {dateTimeFormatter}>
<Dialog.Root />
</TerraConfig>
```
Notas:
- `createTerraTranslator(...)` preserva la reactividad del locale porque toca `actx.locale` en cada resolucion
- si el runtime conectado al `translator` expone `onSchemaChange()`, el traductor invalida derivados al extender el schema
- los modulos de traduccion de `terra` deben vivir en una capa comun de config, no dispersos en cada wrapper
- `terra` sigue desacoplada de `actx`: el bridge vive en `TerraConfig`, no en los componentes
- `translator` decide el locale activo; `dateTimeFormatter` solo aporta politica de presentacion temporal
Reglas de resolucion importantes:
- `translator.translate('#?terra.dialog.close')` resuelve una entrada del schema porque `#?` indica un ID del runtime de traduccion
- `translator.translate({ es: 'Cerrar', en: 'Close' })` resuelve un record localizado si el bridge lo soporta
- `translator.translate('Cerrar')` devuelve el string literal; no hace lookup en el schema
- `translator.translatePath('terra.dialog.close')` resuelve una path del schema de forma explicita
Existe una demo de referencia con `Ling` en `src/routes/test/terra/ling/+page.svelte`.
### Regla de Propiedad del Texto
`terra` puede tener **texto funcional interno** cuando ese texto forma parte de la semantica del primitive y el consumidor no deberia tener que cablearlo siempre a mano.
Pertenece a `terra`:
- `aria-label` de botones internos como `Close`, `Cancel`, `Dismiss notification`
- labels de navegacion como `Next`, `Previous`
- announcements para screen readers como `Selected date` o `Selected dates`
- labels tecnicos de primitives complejos como `Hue`, `Opacity`, `Color value`, `AM/PM`, `timezone`, `Empty`
- labels neutros por defecto como `Calendar`
No pertenece a `terra`:
- copy editorial o de producto
- mensajes de negocio de la app
- placeholders opinionated del dominio
- textos visibles que cambian segun la feature concreta del consumer
- defaults de contenido como `Event`, `Order`, `Customer`, etc.
Regla practica:
- si el texto describe comportamiento, accesibilidad o semantica interna del primitive, puede vivir en `terra`
- si el texto describe el dominio del producto, debe venir del consumer, `air` o una capa superior
---
## 13. Props publicas vs props derivadas
@ -476,7 +581,7 @@ readonly props = $derived.by(() => ({
id: this.opts.id.current,
role: 'dialog',
'aria-modal': 'true',
'data-terra-dialog-content': '',
'data-dialog-content': '',
'data-state': getDataOpenClosed(this.root.opts.open.current),
}) as const);
```
@ -501,7 +606,7 @@ const drawerAttrs = createTerraAttrs({
parts: ['content', 'trigger', 'overlay', 'close'] as const,
});
// genera: data-terra-drawer-content, data-terra-drawer-trigger, etc.
// genera: data-drawer-content, data-drawer-trigger, etc.
```
Helpers booleanos:
@ -512,100 +617,6 @@ Helpers booleanos:
---
## 14.1. Sistema de contratos data-*
Los atributos `data-*` generados por Terra son **API pública**. `air` (y cualquier consumidor) depende de ellos para aplicar estilos. Para evitar cambios breaking no intencionados, Terra usa un sistema de contratos formales.
### Contrato por componente
Cada componente declara su contrato en `src/uix/terra/utils/contracts.ts`:
```ts
export const CONTRACTS: Record<string, ComponentContract> = {
Switch: {
name: 'Switch',
version: 1,
parts: {
root: [
{ attr: 'data-switch', description: 'Siempre presente en el root' },
{ attr: 'data-state', values: ['checked', 'unchecked'], description: 'Estado' },
{ attr: 'data-disabled', description: 'Presente cuando disabled=true' },
],
thumb: [
{ attr: 'data-switch-thumb', description: 'Siempre presente en el thumb' },
],
},
},
// ... resto de componentes
};
```
### Validación en desarrollo
Los componentes pueden validar sus attrs contra el contrato usando `assertDataContract`:
```ts
import { assertDataContract } from '$terra/utils/contracts';
readonly props = $derived.by(() => {
const props = {
"data-switch": "",
"data-state": getDataChecked(this.opts.checked.current),
// ...
} as const;
// Solo valida en development; no-op en producción
assertDataContract('Switch', 'root', props);
return props;
});
```
Si un componente genera un `data-*` que no está en el contrato, o un valor no válido, la consola mostrará un warning en desarrollo.
### Generación de documentación
Para generar `DATA_ATTRS.md` con toda la documentación de contratos:
```bash
npm run generate:contracts-docs
```
Esto genera un markdown con todos los `data-*` attrs de cada componente, listo para referencia de `air` o consumidores.
### Reglas
1. **Todo `data-*` debe estar en el contrato** antes de ser considerado API estable
2. **Cambiar un valor existente es breaking** — incrementa `version` del contrato
3. **Añadir nuevos attrs es backwards compatible** — no requiere incrementar versión
4. **Los contratos se validan solo en dev** — cero overhead en producción
---
## 14.2. Convenciones de nomenclatura
Todas las clases y componentes de Terra siguen convenciones estrictas definidas en `NAMING_CONVENTIONS.md`. Reglas clave:
- **Event handlers DOM**: `onclick`, `onfocus`, `onkeydown` (lowercase)
- **User callbacks**: `onComplete`, `onInteractOutside` (camelCase)
- **Campos derivados**: `readonly name = $derived.by(...)` — sin `#`
- **Estado**: `count = $state(0)` — sin `#` con Svelte runes
- **Boolean props**: usar helpers `boolToTrueOrUndef()`, `boolToEmptyStrOrUndef()`
```ts
// ✅ CORRECTO
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
focused = $state(false);
// ❌ INCORRECTO
readonly #isDisabled = $derived.by(() => this.opts.disabled.current);
#focused = $state(false);
```
**¿Por qué sin `#`?** Los campos marcados como privados con `#` se exponen vía atributos `data-*` (el contrato público). Usar `#` crearía ambigüedad sobre qué es realmente público vs privado.
---
## 15. `child` y `children`
Muchos componentes aceptan ambos snippets:
@ -647,13 +658,13 @@ Reglas:
---
## 17. `adapters/dates/`: frontera de dominio
## 17. `external/dates/`: frontera de dominio
Cuando `terra` depende de una libreria de fechas externa, no importa directamente desde cada componente. El adapter actua como frontera:
```ts
// Correcto
import { DateValue, CalendarDate } from '$terra/adapters/dates';
import { DateValue, CalendarDate } from '$terra/external/dates';
// Incorrecto
import { DateValue } from '@internationalized/date';
@ -672,7 +683,7 @@ Si la libreria externa cambia, el impacto se concentra en el adapter.
| Helper generico sin pieza visual ni contexto | `utils/` |
| Opera sobre el DOM real (requiere browser) | `dom/` |
| Default compartido entre componentes | `config/` |
| Frontera con libreria externa de dominio | `adapters/` |
| Frontera con libreria externa de dominio | `external/` |
---
@ -682,12 +693,10 @@ Si la libreria externa cambia, el impacto se concentra en el adapter.
- duplicar resolucion de props si ya existe un resolver en `config/prop-resolvers.ts`
- acoplar componentes de `terra` a `actx`
- usar subcomponentes fuera de su `Root`
- importar `@internationalized/date` directamente en lugar de usar `$terra/adapters/dates`
- importar `@internationalized/date` directamente en lugar de usar `$terra/external/dates`
- crear un componente sin demo si tiene comportamiento no trivial
- inventar nombres de subcomponentes sin revisar la anatomia de componentes similares
- poner logica DOM-dependiente en `utils/` en vez de `dom/`
- usar `#` para campos privados en clases State (ver `NAMING_CONVENTIONS.md`)
- usar ternarios para boolean attrs en vez de `boolToTrueOrUndef()` / `boolToStr()`
---
@ -826,10 +835,7 @@ drawer/
- `mergeProps` mezcla `restProps` con `state.props` en cada wrapper
- accesibilidad base (`role`, `aria-*`, `data-*`) sale del estado, no del usuario final
- si soporta defaults globales, usa `config/prop-resolvers.ts`
- si es date/time, importa desde `$terra/adapters/dates`
- seguir `NAMING_CONVENTIONS.md` (sin `#` en campos, eventos lowercase, etc.)
- **contrato data-*: añadir entrada en `utils/contracts.ts`**
- **validación: usar `assertDataContract` en cada clase State que genera data-***
- si es date/time, importa desde `$terra/external/dates`
- existe demo en `src/routes/test/`
---
@ -882,3 +888,4 @@ Preguntas guia:
---
Si actualizamos la arquitectura, este README tambien debe actualizarse. La documentacion no es un extra: es parte del sistema.

@ -0,0 +1,412 @@
# Terra Architecture
Documento de referencia para `src/uix/terra`.
`terra` es la capa de primitives headless del sistema. Su responsabilidad es resolver comportamiento, accesibilidad, composicion, estado y contrato publico estable. La apariencia vive fuera de `terra`.
## 1. Proposito
`terra` existe para dar una base comun sobre la que construir interfaces complejas sin repetir la misma logica de:
- contexto compartido
- control de foco
- teclado y puntero
- `aria-*`
- `data-*`
- sincronizacion de estado
- integracion con configuracion transversal
Su objetivo no es ser una capa visual ni de producto. `terra` define primitives reutilizables y predecibles; otras capas deciden el look and feel.
## 2. Motivacion
La motivacion de `terra` es evitar tres problemas habituales:
1. que cada componente invente su propio patron de estado y composicion
2. que la accesibilidad y los contratos de estilo se resuelvan ad hoc en cada wrapper
3. que la UI quede acoplada al runtime de app o a librerias externas concretas
`terra` responde a eso con una arquitectura uniforme:
- wrappers `.svelte` finos
- comportamiento real en `*.svelte.ts`
- contexto Svelte para estado compartido
- contratos `data-*` formalizados y validados
- `TerraConfig` para servicios transversales
- fronteras explicitas con dependencias externas
## 3. Principios de diseno
`terra` sigue estas reglas:
- la API publica debe ser composable y declarativa
- el estado complejo vive fuera del markup, en clases `State`
- los hijos leen estado por contexto, no por prop drilling
- el wrapper `.svelte` recibe props, crea estado, mezcla props y renderiza
- los `data-*` son parte del contrato publico
- la accesibilidad base no se delega al consumer
- `terra` no se acopla a `actx`; consume contratos propios via `TerraConfig`
- la apariencia no pertenece a `terra`
## 4. Superficie publica
La API publica de `terra` incluye:
- exports del barrel publico
- props publicas de cada modulo
- snippet props expuestas por los componentes
- `data-*`
- `aria-*` derivadas relevantes
- comportamiento observable del primitive
Cambiar cualquiera de estas piezas es un cambio de contrato.
## 5. Estructura general
```text
src/uix/terra/
├── [componentes]/ primitive o familia concreta
│ ├── components/ wrappers Svelte publicos
│ ├── types.ts props publicas
│ ├── [modulo].svelte.ts estado, contexto y comportamiento
│ ├── exports.ts barrel del modulo
│ └── index.ts reexport del modulo
├── config/ configuracion transversal y servicios de runtime
├── layers/ infraestructura UI interna
├── dom/ utilitarios DOM y focus
├── utils/ helpers puros o casi puros
├── external/ fronteras con dependencias externas
├── errors.ts
└── index.ts barrel publico
```
Regla interna:
- dentro de `terra` se importa por ruta relativa o por aliases `$terra/...`
- nunca se importa internamente desde `$uix/terra`
## 6. Modelo de componente
La forma base de `terra` es `Elemento.SubElemento`.
Ejemplos:
- `Dialog.Root`
- `Dialog.Trigger`
- `Dialog.Content`
- `Tabs.Root`
- `Tabs.Trigger`
- `DateField.Root`
- `DateField.Segment`
### Root
El `Root` crea el estado central del componente y, cuando hace falta, lo pone en contexto.
### Subcomponentes
Los subcomponentes no reimplementan la logica del sistema. Leen el estado del `Root` y derivan de el sus props, `aria-*`, `data-*` y comportamiento.
### Portal
Cuando un componente usa `Portal`, los contextos se mantienen para que `Content`, `Overlay` y otras partes sigan viendo el estado del `Root` aunque rendericen fuera del arbol DOM inmediato.
## 7. Wrapper y state
### Wrapper `.svelte`
Responsabilidades del wrapper:
- leer props con `$props()`
- crear ids y refs bindables
- instanciar el `State`
- combinar props externas con props derivadas via `mergeProps`
- renderizar DOM o snippets
El wrapper debe ser pequeno. Si empieza a concentrar demasiada logica, la logica pertenece al `State`.
### `*.svelte.ts`
Responsabilidades del `State`:
- crear o leer contexto
- modelar derived state
- manejar teclado, puntero y focus
- producir `aria-*`, `role` y `data-*`
- coordinar subcomponentes
- exponer props derivadas para el wrapper
## 8. Sistema reactivo
`terra` usa el sistema de `Active` y `State` de `utils/state/` para desacoplar comportamiento y markup sin perder reactividad.
Primitivos principales:
- `state(...)`
- `readableActive(...)`
- `writableActive(...)`
- `readonly(...)`
La idea es simple:
- los valores complejos no viajan como primitivos sueltos
- viajan como cajas reactivas con `.current`
- eso permite componer estado entre clases, helpers y wrappers
## 9. Contrato `data-*`
`terra` trata los `data-*` como API publica formal.
La convencion oficial es:
- root real: `data-{component}`
- parte: `data-{component}-{part}`
- estado compartido: `data-state`, `data-disabled`, `data-selected`, `data-orientation`, `data-side`, etc.
Reglas:
- no usar `data-*-root`
- no usar `data-terra-*`
- no mezclar sistemas hibridos
- si un componente no renderiza un root real, no se inventa un root attr solo por simetria
Ejemplos correctos:
- `data-dialog`
- `data-dialog-content`
- `data-select-item`
- `data-tabs-trigger`
- `data-state='open'`
- `data-disabled`
## 10. Contratos formales y validacion
El sistema de contratos de `terra` vive en:
- `src/uix/terra/utils/contracts.ts`
Y se proyecta a documentacion generada en:
- `DATA_ATTRS.md`
La validacion runtime en desarrollo se hace mediante:
- `assertDataContract(...)`
Regla practica:
- si un `State` genera `data-*` publicos, debe validar contra el contrato
- `contracts.ts` es la fuente de verdad del contrato publico
- `DATA_ATTRS.md` es documentacion generada, no editada a mano
## 11. Texto funcional y accesibilidad
`terra` puede tener texto propio solo cuando forma parte de la semantica funcional del primitive.
Ejemplos legitimos:
- `Close`
- `Cancel`
- `Next month`
- `Remove file`
- `Dismiss notification`
- labels de segmentos de fecha/hora
No pertenece a `terra`:
- copy editorial de producto
- mensajes de negocio
- placeholders de app
- contenido opinionated de una UI concreta
## 12. `Trans` y `translator`
`terra` expone un componente `Trans` y un servicio `translator` en `TerraConfig`.
Su objetivo es resolver texto funcional sin acoplar `terra` a un runtime global concreto.
El contrato de `translator` es propio de `terra`:
- `getLocale()`
- `translate(value)`
- `translatePath(path, ...args)`
- `extend(...)` opcional
`terra` no depende de `Ling`, `actx` ni de otra libreria concreta desde los primitives. Si el ecosistema quiere enchufar un runtime de traduccion, lo hace a traves del contrato `translator`.
## 13. `TerraConfig`
`TerraConfig` es la puerta de entrada para servicios transversales. No es un saco de defaults historicos; es un contenedor de capacidades de runtime y presentacion.
Hoy `TerraConfig` admite:
- `translator`
- `dateTimeFormatter`
- `numberFormatter`
- `currencyFormatter`
- `unitFormatter`
- `logger`
- `dir`
- `portalTo`
### `translator`
Responsable de:
- locale activa
- traduccion de `TerraTranslationValue`
- traduccion por path
- extension opcional de modulos de traduccion
### `dateTimeFormatter`
Responsable de:
- politica de orden de fecha
- politica de formato horario
### `numberFormatter`, `currencyFormatter`, `unitFormatter`
Responsables de la presentacion numerica y de dominio, sin acoplar los primitives a una libreria concreta de formato.
### `logger`
Servicio de logging inyectable de `terra`.
### `dir`
Direccion visual efectiva para primitives que dependen de LTR o RTL.
### `portalTo`
Target por defecto de montaje para primitives que usan portal.
## 14. Como interactua `TerraConfig` con `terra`
La integracion sigue este flujo:
1. `TerraConfig` recibe servicios y valores en su wrapper Svelte
2. el wrapper los convierte a `Active<T>` con `readableActive(...)`
3. `useTerraConfig(...)` los inserta en contexto
4. `getTerraConfig()` expone la configuracion resuelta al resto de `terra`
5. cada primitive decide si consume o no cada servicio
La resolucion sigue prioridad por capas:
1. prop explicita del componente
2. valor disponible en `TerraConfig`
3. fallback interno del primitive
Ademas, `TerraConfig` soporta anidacion. Un `TerraConfig` hijo puede sobrescribir solo una parte del contexto y heredar el resto del padre.
## 15. Fronteras externas
`terra` distingue entre:
- `config/`: configuracion y servicios inyectables
- `external/`: fronteras con dependencias externas
Ejemplo:
- `external/dates/` existe para desacoplar `terra` de la libreria de fechas concreta
La idea es que un primitive no importe directamente una dependencia de dominio externa si puede hacerlo a traves de una frontera formal.
## 16. Layers, DOM y utils
### `layers/`
Contiene primitives internas de infraestructura como:
- portal
- floating
- presence
- dismissible
- focus-scope
- arrow
No son necesariamente API publica de consumo directo, pero sostienen muchos primitives.
### `dom/`
Contiene logica dependiente del DOM real:
- focus helpers
- navegacion por flechas
- roving focus
- utilitarios browser
### `utils/`
Contiene helpers puros o casi puros:
- attrs
- merge de props
- eventos
- estado reactivo
- datetime helpers
- types internos
Regla:
- si una pieza depende del DOM real, no pertenece a `utils/`
- si es configuracion/runtime inyectable, no pertenece a `utils/`
## 17. Frontera con `air`
La frontera entre `terra` y `air` es deliberada.
`terra` aporta:
- comportamiento
- accesibilidad
- contratos `data-*`
- contexto y composicion
- servicios de runtime
`air` debe aportar:
- apariencia
- tono visual
- decisiones de diseno opinionated
- estilos basados en contratos publicos de `terra`
`air` no debe depender de detalles privados de implementacion de `terra`.
## 18. Anti-patrones
Evitar en `terra`:
- logica compleja dentro del wrapper `.svelte`
- props drilling cuando el patron correcto es contexto
- `data-*` fuera de contrato
- inventar nombres de parts sin revisar anatomias similares
- acoplar primitives a `actx`
- meter copy de producto dentro del primitive
- usar internamente `$uix/terra` para imports
- mezclar configuracion inyectable con fronteras externas
## 19. Regla de estabilidad
Un primitive de `terra` se considera estable cuando:
- su API publica esta clara
- sus `data-*` estan en `contracts.ts`
- valida con `assertDataContract(...)`
- el wrapper y el `State` siguen el patron general
- su accesibilidad base esta resuelta
- no depende de hacks locales o de una demo para sostenerse
## 20. Resumen
`terra` no es una coleccion de componentes visuales. Es una base arquitectonica para primitives headless del sistema.
Su valor esta en combinar:
- contratos publicos claros
- comportamiento reutilizable
- accesibilidad integrada
- configuracion transversal estable
- desacoplo respecto al runtime global y a librerias externas
Si una nueva pieza no mejora esa base comun, probablemente no pertenece a `terra`.

@ -105,16 +105,19 @@ abstract class AccordionBaseState {
abstract includesItem(item: string): boolean;
abstract toggleItem(item: string): void;
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"data-orientation": this.opts.orientation.current,
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
[accordionAttrs.root]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-orientation': this.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
[accordionAttrs.root]: '',
...this.attachment,
} as const;
assertDataContract('Accordion', 'root', props);
return props;
});
}
class AccordionSingleState extends AccordionBaseState {
@ -203,17 +206,20 @@ export class AccordionItemState {
this.root.toggleItem(this.opts.value.current);
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"data-state": getDataOpenClosed(this.isActive),
"data-disabled": boolToEmptyStrOrUndef(this.isDisabled),
"data-orientation": this.root.opts.orientation.current,
[accordionAttrs.item]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-state': getDataOpenClosed(this.isActive),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-orientation': this.root.opts.orientation.current,
[accordionAttrs.item]: '',
...this.attachment,
} as const;
assertDataContract('Accordion', 'item', props);
return props;
});
}
export class AccordionTriggerState {
@ -259,25 +265,29 @@ export class AccordionTriggerState {
this.root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e);
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
disabled: this.isDisabled,
"aria-expanded": boolToStr(this.itemState.isActive),
"aria-disabled": boolToStr(this.isDisabled),
"data-disabled": boolToEmptyStrOrUndef(this.isDisabled),
"data-state": getDataOpenClosed(this.itemState.isActive),
"data-orientation": this.root.opts.orientation.current,
[accordionAttrs.trigger]: "",
tabindex: this.opts.tabindex.current,
onclick: this.onclick,
onkeydown: this.onkeydown,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
disabled: this.isDisabled,
'aria-expanded': boolToStr(this.itemState.isActive),
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-state': getDataOpenClosed(this.itemState.isActive),
'data-orientation': this.root.opts.orientation.current,
[accordionAttrs.trigger]: '',
tabindex: this.opts.tabindex.current,
onclick: this.onclick,
onkeydown: this.onkeydown,
...this.attachment,
} as const;
assertDataContract('Accordion', 'trigger', props);
return props;
});
}
export class AccordionContentState {
readonly opts: AccordionContentStateOpts;
readonly item: AccordionItemState;
@ -366,36 +376,37 @@ export class AccordionContentState {
readonly snippetProps = $derived.by(() => ({ open: this.item.isActive }));
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"data-state": getDataOpenClosed(this.item.isActive),
"data-disabled": boolToEmptyStrOrUndef(this.item.isDisabled),
"data-orientation": this.item.root.opts.orientation.current,
[accordionAttrs.content]: "",
style: {
"--terra-accordion-content-height": `${this.dimensions.height}px`,
"--terra-accordion-content-width": `${this.dimensions.width}px`,
},
hidden:
this.opts.hiddenUntilFound.current && !this.item.isActive
? "until-found"
: undefined,
...(this.opts.hiddenUntilFound.current && !this.shouldRender
? {}
: {
hidden: this.opts.hiddenUntilFound.current
? !this.shouldRender
: this.opts.forceMount.current
? undefined
: !this.shouldRender,
}),
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-state': getDataOpenClosed(this.item.isActive),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
'data-orientation': this.item.root.opts.orientation.current,
[accordionAttrs.content]: '',
style: {
'--terra-accordion-content-height': `${this.dimensions.height}px`,
'--terra-accordion-content-width': `${this.dimensions.width}px`,
},
hidden: this.opts.hiddenUntilFound.current && !this.item.isActive ? 'until-found' : undefined,
...(this.opts.hiddenUntilFound.current && !this.shouldRender
? {}
: {
hidden: this.opts.hiddenUntilFound.current
? !this.shouldRender
: this.opts.forceMount.current
? undefined
: !this.shouldRender,
}),
...this.attachment,
} as const;
assertDataContract('Accordion', 'content', props);
return props;
});
}
export class AccordionHeaderState {
readonly opts: AccordionHeaderStateOpts;
readonly item: AccordionItemState;
@ -411,18 +422,22 @@ export class AccordionHeaderState {
return new AccordionHeaderState(props, AccordionItemState.ctx.get());
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "heading",
"aria-level": this.opts.level.current,
"data-heading-level": this.opts.level.current,
"data-state": getDataOpenClosed(this.item.isActive),
"data-orientation": this.item.root.opts.orientation.current,
[accordionAttrs.header]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'heading',
'aria-level': this.opts.level.current,
'data-heading-level': this.opts.level.current,
'data-state': getDataOpenClosed(this.item.isActive),
'data-orientation': this.item.root.opts.orientation.current,
[accordionAttrs.header]: '',
...this.attachment,
} as const;
assertDataContract('Accordion', 'header', props);
return props;
});
}

@ -17,7 +17,7 @@
*
* ## Selectores CSS
* ```
* [data-alert-dialog-action] → Botón de acción principal
* [data-dialog-action] → Botón de acción principal
* ```
*
* ## Estilos

@ -18,7 +18,7 @@
*
* ## Selectores CSS
* ```
* [data-alert-dialog-cancel] → Botón de cancelar
* [data-dialog-cancel] → Botón de cancelar
* ```
*
* ## Estilos

@ -24,7 +24,7 @@
*
* ## Selectores CSS
* ```
* [data-alert-dialog-content] → Contenedor del diálogo
* [data-dialog-content] → Contenedor del diálogo
* [data-state="open"] → Estado abierto
* [data-state="closed"] → Estado cerrado
* ```

@ -27,12 +27,12 @@
*
* ## Selectores CSS
* ```
* [data-alert-dialog-content] → Contenedor del diálogo
* [data-dialog-content] → Contenedor del diálogo
* [data-state="open"] → Estado abierto
* [data-state="closed"] → Estado cerrado
* [data-alert-dialog-trigger] → Trigger que abre el diálogo
* [data-alert-dialog-cancel] → Botón de cancelar
* [data-alert-dialog-action] → Botón de acción principal
* [data-dialog-trigger] → Trigger que abre el diálogo
* [data-dialog-cancel] → Botón de cancelar
* [data-dialog-action] → Botón de acción principal
* ```
*/
import { readableActive, writableActive } from '$terra/utils';

@ -27,17 +27,23 @@ export class AspectRatioRootState {
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
style: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
[aspectRatioAttrs.root]: "",
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
style: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
},
[aspectRatioAttrs.root]: '',
...this.attachment,
} as const;
assertDataContract('AspectRatio', 'root', props);
return props;
});
}

@ -83,12 +83,18 @@ export class AvatarRootState {
};
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
[avatarAttrs.root]: "",
"data-status": this.opts.loadingStatus.current,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[avatarAttrs.root]: '',
'data-status': this.opts.loadingStatus.current,
...this.attachment,
} as const;
assertDataContract('Avatar', 'root', props);
return props;
});
}
interface AvatarImageStateOpts
@ -131,19 +137,25 @@ export class AvatarImageState {
);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
// La imagen solo es visible cuando está completamente cargada
style: {
display: this.root.opts.loadingStatus.current === "loaded" ? "block" : "none",
},
"data-status": this.root.opts.loadingStatus.current,
[avatarAttrs.image]: "",
src: this.opts.src.current,
crossorigin: this.opts.crossOrigin.current,
referrerpolicy: this.opts.referrerPolicy.current,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
// La imagen solo es visible cuando está completamente cargada
style: {
display: this.root.opts.loadingStatus.current === 'loaded' ? 'block' : 'none',
},
'data-status': this.root.opts.loadingStatus.current,
[avatarAttrs.image]: '',
src: this.opts.src.current,
crossorigin: this.opts.crossOrigin.current,
referrerpolicy: this.opts.referrerPolicy.current,
...this.attachment,
} as const;
assertDataContract('Avatar', 'image', props);
return props;
});
}
interface AvatarFallbackStateOpts extends WithRefOpts {}
@ -163,14 +175,20 @@ export class AvatarFallbackState {
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(() => ({
// Oculto solo cuando la imagen está cargada, visible en "loading" y "error"
style: this.root.opts.loadingStatus.current === "loaded"
? { display: "none" }
: undefined,
"data-status": this.root.opts.loadingStatus.current,
[avatarAttrs.fallback]: "",
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
// Oculto solo cuando la imagen está cargada, visible en "loading" y "error"
style: this.root.opts.loadingStatus.current === 'loaded'
? { display: 'none' }
: undefined,
'data-status': this.root.opts.loadingStatus.current,
[avatarAttrs.fallback]: '',
...this.attachment,
} as const;
assertDataContract('Avatar', 'fallback', props);
return props;
});
}

@ -11,7 +11,7 @@ import {
isSameDay,
isSameMonth,
isToday,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import {
type DateMatcher,
@ -20,7 +20,7 @@ import {
getDateValueType,
isBefore,
dateValueToDate as toDate,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import {
attachRef,
@ -28,13 +28,15 @@ import {
} from '$terra/utils';
import { watch } from "runed";
import { createTerraContext } from '$terra/utils/context';
import { getTerraConfig } from '../config/terra-config';
import { resolveTerraTranslation } from '../config/translator.svelte';
import {
boolToStr,
boolToStrTrueOrUndef,
boolToEmptyStrOrUndef,
} from "../utils/attrs";
import { assertDataContract } from "$terra/utils/contracts";
import { assertDataContract } from '$terra/utils/contracts';
import type {
TerraKeyboardEvent,
TerraMouseEvent,
@ -54,8 +56,8 @@ import {
createFormatter
} from '$terra/utils/datetime';
import { calendarAttrs } from '$terra/utils/datetime/calendar-attrs';
import {
calendarAttrs,
createAccessibleHeading,
createMonths,
getCalendarElementProps,
@ -116,6 +118,7 @@ export class CalendarRootState {
}
readonly opts: CalendarRootStateOpts;
readonly config = getTerraConfig();
readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value));
readonly formatter: Formatter;
readonly accessibleHeadingId = useId();
@ -215,6 +218,10 @@ export class CalendarRootState {
});
}
translate(value: string, fallback = '') {
return resolveTerraTranslation(value, this.config.translator.current, fallback);
}
setMonths = (months: Month<DateValue>[]) => {
this.months = months;
};
@ -357,8 +364,14 @@ export class CalendarRootState {
});
});
readonly resolvedCalendarLabel = $derived.by(() => {
const label = this.opts.calendarLabel.current;
if (label) return label;
return this.translate('#?terra.calendar.label|Calendar', 'Calendar');
});
readonly fullCalendarLabel = $derived.by(() => {
return `${this.opts.calendarLabel.current} ${this.headingValue}`;
return `${this.resolvedCalendarLabel} ${this.headingValue}`;
});
isOutsideVisibleMonths(date: DateValue) {
@ -423,11 +436,19 @@ export class CalendarRootState {
} else if (!Array.isArray(prev)) {
const next = this.handleSingleUpdate(prev, date);
if (!next) {
this.announcer.announce("Selected date is now empty.", "polite", 5000);
this.announcer.announce(
this.translate('#?terra.calendar.selectedDateEmpty|Selected date is now empty.', 'Selected date is now empty.'),
'polite',
5000
);
} else {
const selectedDatePrefix = this.translate(
'#?terra.calendar.selectedDate|Selected date',
'Selected date'
);
this.announcer.announce(
`Selected Date: ${this.formatter.selectedDate(next, false)}`,
"polite"
`${selectedDatePrefix}: ${this.formatter.selectedDate(next, false)}`,
'polite'
);
}
this.opts.value.current = getDateWithPreviousTime(next, prev);
@ -495,7 +516,7 @@ export class CalendarRootState {
dateOrder: this.opts.dateOrder.current,
}));
getAcelAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
getTerraAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
return calendarAttrs.getAttr(part);
};
@ -509,7 +530,7 @@ export class CalendarRootState {
disabled: this.opts.disabled.current,
readonly: this.opts.readonly.current,
}),
[this.getAcelAttr("root")]: "",
[this.getTerraAttr("root")]: "",
onkeydown: this.onkeydown,
...this.attachment,
}) as const
@ -542,7 +563,7 @@ export class CalendarHeadingState {
"aria-hidden": boolToStrTrueOrUndef(true),
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
"data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current),
[this.root.getAcelAttr("heading")]: "",
[this.root.getTerraAttr("heading")]: "",
...this.attachment,
}) as const
);
@ -644,7 +665,7 @@ export class CalendarCellState {
"aria-selected": boolToStr(this.isSelectedDate),
"aria-disabled": boolToStr(this.ariaDisabled),
...this.sharedDataAttrs,
[this.root.getAcelAttr("cell")]: "",
[this.root.getTerraAttr("cell")]: "",
...this.attachment,
}) as const
);
@ -699,7 +720,7 @@ export class CalendarDayState {
"aria-disabled": boolToStr(this.cell.ariaDisabled),
...this.cell.sharedDataAttrs,
tabindex: this.tabindex,
[this.cell.root.getAcelAttr("day")]: "",
[this.cell.root.getTerraAttr("day")]: "",
// Atributo compartido para selección de celdas
"data-calendar-day": "",
onclick: this.onclick,
@ -739,11 +760,11 @@ export class CalendarNextButtonState {
id: this.opts.id.current,
role: "button",
type: "button",
"aria-label": "Next",
"aria-label": this.root.translate('#?terra.calendar.next|Next', "Next"),
"aria-disabled": boolToStr(this.isDisabled),
"data-disabled": boolToEmptyStrOrUndef(this.isDisabled),
disabled: this.isDisabled,
[this.root.getAcelAttr("next-button")]: "",
[this.root.getTerraAttr("next-button")]: "",
onclick: this.onclick,
...this.attachment,
}) as const
@ -781,11 +802,11 @@ export class CalendarPrevButtonState {
id: this.opts.id.current,
role: "button",
type: "button",
"aria-label": "Previous",
"aria-label": this.root.translate('#?terra.calendar.previous|Previous', "Previous"),
"aria-disabled": boolToStr(this.isDisabled),
"data-disabled": boolToEmptyStrOrUndef(this.isDisabled),
disabled: this.isDisabled,
[this.root.getAcelAttr("prev-button")]: "",
[this.root.getTerraAttr("prev-button")]: "",
onclick: this.onclick,
...this.attachment,
}) as const
@ -821,7 +842,7 @@ export class CalendarGridState {
"aria-disabled": boolToStr(this.root.opts.disabled.current),
"data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current),
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
[this.root.getAcelAttr("grid")]: "",
[this.root.getTerraAttr("grid")]: "",
...this.attachment,
}) as const
);
@ -852,7 +873,7 @@ export class CalendarGridBodyState {
id: this.opts.id.current,
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
"data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current),
[this.root.getAcelAttr("grid-body")]: "",
[this.root.getTerraAttr("grid-body")]: "",
...this.attachment,
}) as const
);
@ -883,7 +904,7 @@ export class CalendarGridHeadState {
id: this.opts.id.current,
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
"data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current),
[this.root.getAcelAttr("grid-head")]: "",
[this.root.getTerraAttr("grid-head")]: "",
...this.attachment,
}) as const
);
@ -914,7 +935,7 @@ export class CalendarGridRowState {
id: this.opts.id.current,
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
"data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current),
[this.root.getAcelAttr("grid-row")]: "",
[this.root.getTerraAttr("grid-row")]: "",
...this.attachment,
}) as const
);
@ -945,7 +966,7 @@ export class CalendarHeadCellState {
id: this.opts.id.current,
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
"data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current),
[this.root.getAcelAttr("head-cell")]: "",
[this.root.getTerraAttr("head-cell")]: "",
...this.attachment,
}) as const
);
@ -976,7 +997,7 @@ export class CalendarHeaderState {
id: this.opts.id.current,
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
"data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current),
[this.root.getAcelAttr("header")]: "",
[this.root.getTerraAttr("header")]: "",
...this.attachment,
}) as const
);
@ -1058,7 +1079,7 @@ export class CalendarMonthSelectState {
value: this.currentMonth,
disabled: this.isDisabled,
"data-disabled": boolToEmptyStrOrUndef(this.isDisabled),
[this.root.getAcelAttr("month-select")]: "",
[this.root.getTerraAttr("month-select")]: "",
onchange: this.onchange,
...this.attachment,
}) as const
@ -1144,10 +1165,11 @@ export class CalendarYearSelectState {
value: this.currentYear,
disabled: this.isDisabled,
"data-disabled": boolToEmptyStrOrUndef(this.isDisabled),
[this.root.getAcelAttr("year-select")]: "",
[this.root.getTerraAttr("year-select")]: "",
onchange: this.onchange,
...this.attachment,
}) as const
);
}

@ -74,11 +74,11 @@
*/
import { watch } from "runed";
import { readableActive, writableActive, mergeProps, useId } from "$terra/utils";
import type { DateValue } from "$terra/adapters/dates";
import type { DateValue } from "$terra/external/dates";
import { CalendarRootState } from "../calendar.svelte";
import type { CalendarRootProps } from "../types";
import { noop } from "$lib/util/funcs";
import { getDefaultDate } from "$terra/adapters/dates";
import { getDefaultDate } from "$terra/external/dates";
import { resolveDateOrderProp, resolveLocaleProp } from "../../config/prop-resolvers";
let {

@ -1,4 +1,4 @@
import type { DateValue, DateOrder } from "$terra/adapters/dates";
import type { DateValue, DateOrder } from "$terra/external/dates";
import type { TerraDatePresentationProps } from '../config/types';
import type {
OnChangeFn,
@ -16,7 +16,7 @@ import type {
TerraPrimitiveTrAttributes,
} from '$terra/utils';
import type { DateMatcher, Month, WeekStartsOn } from "$terra/adapters/dates";
import type { DateMatcher, Month, WeekStartsOn } from "$terra/external/dates";

@ -69,16 +69,19 @@ export class CollapsibleRootState {
this.opts.open.current = !this.opts.open.current;
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"data-state": getDataOpenClosed(this.opts.open.current),
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
[collapsibleAttrs.root]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-state': getDataOpenClosed(this.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
[collapsibleAttrs.root]: '',
...this.attachment,
} as const;
assertDataContract('Collapsible', 'root', props);
return props;
});
}
interface CollapsibleContentStateOpts
@ -185,11 +188,10 @@ export class CollapsibleContentState {
open: this.root.opts.open.current,
}));
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
style: {
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
style: {
"--terra-collapsible-content-height": this.height
? `${this.height}px`
: undefined,
@ -201,9 +203,9 @@ export class CollapsibleContentState {
this.opts.hiddenUntilFound.current && !this.root.opts.open.current
? "until-found"
: undefined,
"data-state": getDataOpenClosed(this.root.opts.open.current),
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
[collapsibleAttrs.content]: "",
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
[collapsibleAttrs.content]: '',
...(this.opts.hiddenUntilFound.current && !this.shouldRender
? {}
: {
@ -213,9 +215,13 @@ export class CollapsibleContentState {
? undefined
: !this.shouldRender,
}),
...this.attachment,
}) as const
);
...this.attachment,
} as const;
assertDataContract('Collapsible', 'content', props);
return props;
});
}
interface CollapsibleTriggerStateOpts
@ -255,22 +261,25 @@ export class CollapsibleTriggerState {
}
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
type: "button",
disabled: this.isDisabled,
"aria-controls": this.root.contentId,
"aria-expanded": boolToStr(this.root.opts.open.current),
"data-state": getDataOpenClosed(this.root.opts.open.current),
"data-disabled": boolToEmptyStrOrUndef(this.isDisabled),
[collapsibleAttrs.trigger]: "",
//
onclick: this.onclick,
onkeydown: this.onkeydown,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: 'button',
disabled: this.isDisabled,
'aria-controls': this.root.contentId,
'aria-expanded': boolToStr(this.root.opts.open.current),
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[collapsibleAttrs.trigger]: '',
onclick: this.onclick,
onkeydown: this.onkeydown,
...this.attachment,
} as const;
assertDataContract('Collapsible', 'trigger', props);
return props;
});
}

@ -0,0 +1,6 @@
import { createTerraAttrs } from '../utils/attrs';
export const colorPickerAttrs = createTerraAttrs({
component: 'color-picker',
parts: ['trigger', 'content', 'area', 'area-thumb', 'hue-slider', 'alpha-slider', 'input', 'swatches', 'swatch', 'eyedropper'] as const,
});

@ -1,4 +1,7 @@
import { colorPickerAttrs } from './color-picker-attrs';
import { createTerraContext } from '$terra/utils/context';
import { getTerraConfig } from '../config/terra-config';
import { resolveTerraTranslation } from '../config/translator.svelte';
import {
state,
readableActive,
@ -43,6 +46,7 @@ type ColorPickerInputStateProps = {
format: Active<ColorFormat>;
};
type ColorPickerSwatchesStateProps = {
colors: Active<string[]>;
onSelect: Active<((color: string) => void) | undefined>;
@ -64,6 +68,7 @@ export class ColorPickerRootState {
readonly disabled: Active<boolean>;
readonly format: Active<ColorFormat>;
readonly config = getTerraConfig();
readonly onValueChange: Active<((v: ColorValue) => void) | undefined>;
@ -114,6 +119,10 @@ export class ColorPickerRootState {
this.onValueChange.current?.(color);
}
translate(value: string, fallback = '') {
return resolveTerraTranslation(value, this.config.translator.current, fallback);
}
// ─── Factory ─────────────────────────────────────────────────────────────
static create(props: ColorPickerRootStateProps) {
@ -368,3 +377,8 @@ export class ColorPickerEyeDropperState {
return new ColorPickerEyeDropperState(props);
}
}

@ -1,33 +1,8 @@
<script lang="ts">
/**
* # Color Picker Alpha Slider
*
* Slider horizontal para seleccionar la transparencia (alpha) del color (0 a 1).
*
* ## Uso
* ```svelte
* <ColorPickerAlphaSlider />
* ```
*
* ## Propiedades
* - `class`: string - Clases CSS adicionales
*
* ## Comportamiento
* - Soporta interacción con mouse y touch
* - Navegable con teclado (flechas izquierda/derecha)
* - Muestra un patrón de cuadros para indicar transparencia
* - El fondo usa `--cp-alpha-bg` para mostrar el color con alpha
*
* ## Selectores CSS
* ```
* [data-color-picker-alpha-slider] → Slider de transparencia (alpha) del color
* ```
*
* ## Estilos
* Renderiza un elemento `<div>` con role="slider".
*/
import { ColorPickerAlphaSliderState } from "../color-picker.svelte";
import type { ColorPickerAlphaSliderProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerAlphaSliderState } from '../color-picker.svelte';
import type { ColorPickerAlphaSliderProps } from '../types';
let { class: className }: ColorPickerAlphaSliderProps = $props();
@ -51,30 +26,34 @@
function handlePointerUp() {
isDragging = false;
}
const attrs = $derived.by(() => {
const attrs = {
role: 'slider' as const,
tabindex: 0,
'aria-label': alpha.root.translate('#?terra.colorPicker.opacity|Opacity', 'Opacity'),
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': Math.round(alpha.value.current * 100),
style: `--cp-alpha-bg: ${alpha.backgroundStyle.current}`,
[colorPickerAttrs['alpha-slider']]: '',
} as const;
assertDataContract('ColorPicker', 'alpha-slider', attrs);
return attrs;
});
</script>
<!--
Alpha slider — horizontal track from 0 (transparent) to 1 (opaque).
Background shows a checkered pattern (transparency) with the current color overlaid.
-->
<div
bind:this={sliderEl}
class={className}
role="slider"
tabindex="0"
aria-label="Opacity"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(alpha.value.current * 100)}
style="--cp-alpha-bg: {alpha.backgroundStyle.current}"
{...attrs}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onkeydown={(e) => alpha.handleKeydown(e)}
>
<!-- Thumb -->
<div
style="left: {alpha.thumbStyle.current.left}"
aria-hidden="true"
></div>
</div>
<div style="left: {alpha.thumbStyle.current.left}" aria-hidden="true"></div>
</div>

@ -1,61 +1,42 @@
<script lang="ts">
/**
* # Color Picker Area Thumb
*
* Indicador visual de la posición actual en el área de color.
*
* ## Uso
* ```svelte
* <ColorPickerArea>
* <ColorPickerAreaThumb />
* </ColorPickerArea>
* ```
*
* ## Propiedades
* - `class`: string - Clases CSS adicionales
*
* ## Comportamiento
* - Posicionado con `left` y `top` según la saturación y brillo
* - Soporta navegación con flechas del teclado
* - Usa Shift+flechas para movimientos de 10 unidades
* - Color disponible como `--cp-thumb-color`
*
* ## Selectores CSS
* ```
* [data-color-picker-area-thumb] → Indicador visual de posición en el área de color
* ```
*
* ## Estilos
* Renderiza un elemento `<div>` con role="slider".
*/
import { ColorPickerAreaState, ColorPickerRootState } from "../color-picker.svelte";
import type { ColorPickerAreaThumbProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerAreaState, ColorPickerRootState } from '../color-picker.svelte';
import type { ColorPickerAreaThumbProps } from '../types';
let { class: className }: ColorPickerAreaThumbProps = $props();
const root = ColorPickerRootState.get();
const area = ColorPickerAreaState.create({});
const attrs = $derived.by(() => {
const attrs = {
role: 'slider' as const,
tabindex: 0,
'aria-label': root.translate('#?terra.colorPicker.saturationBrightness|Color saturation and brightness', 'Color saturation and brightness'),
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': Math.round(root.value.current.hsv.s),
style: `left: ${area.thumbStyle.current.left}; top: ${area.thumbStyle.current.top}; --cp-thumb-color: ${root.hex.current};`,
[colorPickerAttrs['area-thumb']]: '',
} as const;
assertDataContract('ColorPicker', 'area-thumb', attrs);
return attrs;
});
</script>
<div
class={className}
role="slider"
tabindex="0"
aria-label="Color saturation and brightness"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(root.value.current.hsv.s)}
style="
left: {area.thumbStyle.current.left};
top: {area.thumbStyle.current.top};
--cp-thumb-color: {root.hex.current};
"
{...attrs}
onkeydown={(e) => {
const { s, v } = root.value.current.hsv;
const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowRight") root.setSaturationValue(Math.min(s + step, 100), v);
if (e.key === "ArrowLeft") root.setSaturationValue(Math.max(s - step, 0), v);
if (e.key === "ArrowUp") root.setSaturationValue(s, Math.min(v + step, 100));
if (e.key === "ArrowDown") root.setSaturationValue(s, Math.max(v - step, 0));
if (e.key === 'ArrowRight') root.setSaturationValue(Math.min(s + step, 100), v);
if (e.key === 'ArrowLeft') root.setSaturationValue(Math.max(s - step, 0), v);
if (e.key === 'ArrowUp') root.setSaturationValue(s, Math.min(v + step, 100));
if (e.key === 'ArrowDown') root.setSaturationValue(s, Math.max(v - step, 0));
}}
></div>

@ -1,45 +1,13 @@
<script lang="ts">
/**
* # Color Picker Area
*
* Área de selección de saturación y brillo del color.
*
* ## Uso
* ```svelte
* <ColorPickerArea>
* <ColorPickerAreaThumb />
* </ColorPickerArea>
* ```
*
* ## Propiedades
* - `class`: string - Clases CSS adicionales
*
* ## Selectores CSS
* ```
* No aplica selectores de datos específicos.
* Use el elemento con role="presentation" para aplicar estilos.
* ```
*
* ## CSS Variables
* ```
* --cp-area-bg → Color de fondo (HSL del tono actual)
* ```
*
* ## Comportamiento
* - Soporta interacción con mouse y touch
* - Mantiene el color de fondo con `--cp-area-bg`
* - Actualiza saturación y brillo del color seleccionado
*
* ## Estilos
* Renderiza un elemento `<div>` con role="presentation".
*/
import { ColorPickerAreaState } from "../color-picker.svelte";
import type { ColorPickerAreaProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerAreaState } from '../color-picker.svelte';
import type { ColorPickerAreaProps } from '../types';
let {
class: className,
children,
}: ColorPickerAreaProps & { children?: import("svelte").Snippet } = $props();
}: ColorPickerAreaProps & { children?: import('svelte').Snippet } = $props();
const area = ColorPickerAreaState.create({});
@ -61,16 +29,28 @@
function handlePointerUp() {
isDragging = false;
}
const attrs = $derived.by(() => {
const attrs = {
role: 'presentation' as const,
style: `--cp-area-bg: ${area.backgroundStyle.current}`,
[colorPickerAttrs.area]: '',
} as const;
assertDataContract('ColorPicker', 'area', attrs);
return attrs;
});
</script>
<div
bind:this={areaEl}
class={className}
role="presentation"
style="--cp-area-bg: {area.backgroundStyle.current}"
{...attrs}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{@render children?.()}
</div>

@ -1,45 +1,30 @@
<script lang="ts">
/**
* # Color Picker Content
*
* Contenedor del panel del selector de color.
*
* ## Uso
* ```svelte
* <ColorPickerContent>
* <ColorPickerArea>
* <ColorPickerAreaThumb />
* </ColorPickerArea>
* <ColorPickerHueSlider />
* <ColorPickerAlphaSlider />
* <ColorPickerInput />
* </ColorPickerContent>
* ```
*
* ## Propiedades
* - `class`: string - Clases CSS adicionales
*
* ## Selectores CSS
* ```
* No aplica selectores de datos específicos.
* Use el elemento contenedor con role="dialog" para aplicar estilos.
* ```
*
* ## Estilos
* Renderiza un elemento `<div>` con role="dialog".
*/
import type { ColorPickerContentProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerRootState } from '../color-picker.svelte';
import type { ColorPickerContentProps } from '../types';
let {
class: className,
children,
}: ColorPickerContentProps & { children?: import("svelte").Snippet } = $props();
}: ColorPickerContentProps & { children?: import('svelte').Snippet } = $props();
const root = ColorPickerRootState.get();
const attrs = $derived.by(() => {
const attrs = {
role: 'dialog' as const,
'aria-label': root.translate('#?terra.colorPicker.dialogLabel|Color picker', 'Color picker'),
[colorPickerAttrs.content]: '',
} as const;
assertDataContract('ColorPicker', 'content', attrs);
return attrs;
});
</script>
<div
class={className}
role="dialog"
aria-label="Color picker"
>
<div class={className} {...attrs}>
{@render children?.()}
</div>

@ -1,49 +1,35 @@
<script lang="ts">
/**
* # Color Picker Eye Dropper
*
* Botón para abrir la herramienta de selección de color del navegador.
*
* ## Uso
* ```svelte
* <ColorPickerEyeDropper>
* <EyeDropperIcon />
* </ColorPickerEyeDropper>
* ```
*
* ## Propiedades
* - `class`: string - Clases CSS adicionales
* - `children`: Snippet - Contenido del botón
*
* ## Comportamiento
* - Solo se renderiza si el navegador soporta la API EyeDropper
* - Al hacer clic, abre el selector de color del sistema
*
* ## Selectores CSS
* ```
* [data-color-picker-eyedropper] → Botón para abrir la herramienta de selección de color
* ```
*
* ## Estilos
* Renderiza un elemento `<button>` si es soportado.
*/
import { ColorPickerEyeDropperState } from "../color-picker.svelte";
import type { ColorPickerEyeDropperProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerEyeDropperState } from '../color-picker.svelte';
import type { ColorPickerEyeDropperProps } from '../types';
let { class: className, children }: ColorPickerEyeDropperProps & {
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
} = $props();
const eyedropper = ColorPickerEyeDropperState.create({});
const attrs = $derived.by(() => {
const attrs = {
type: 'button' as const,
'aria-label': eyedropper.root.translate('#?terra.colorPicker.eyeDropper|Pick color from screen', 'Pick color from screen'),
[colorPickerAttrs.eyedropper]: '',
} as const;
assertDataContract('ColorPicker', 'eyedropper', attrs);
return attrs;
});
</script>
{#if eyedropper.isSupported}
<button
type="button"
class={className}
aria-label="Pick color from screen"
{...attrs}
onclick={() => eyedropper.open()}
>
{@render children?.()}
</button>
{/if}

@ -1,32 +1,8 @@
<script lang="ts">
/**
* # Color Picker Hue Slider
*
* Slider horizontal para seleccionar el matiz (hue) del color (0° a 360°).
*
* ## Uso
* ```svelte
* <ColorPickerHueSlider />
* ```
*
* ## Propiedades
* - `class`: string - Clases CSS adicionales
*
* ## Comportamiento
* - Soporta interacción con mouse y touch
* - Navegable con teclado (flechas izquierda/derecha)
* - El thumb se posiciona según el valor actual de hue
*
* ## Selectores CSS
* ```
* [data-color-picker-hue-slider] → Slider de matiz (hue) del color
* ```
*
* ## Estilos
* Renderiza un elemento `<div>` con role="slider".
*/
import { ColorPickerHueSliderState } from "../color-picker.svelte";
import type { ColorPickerHueSliderProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerHueSliderState } from '../color-picker.svelte';
import type { ColorPickerHueSliderProps } from '../types';
let { class: className }: ColorPickerHueSliderProps = $props();
@ -50,29 +26,33 @@
function handlePointerUp() {
isDragging = false;
}
const attrs = $derived.by(() => {
const attrs = {
role: 'slider' as const,
tabindex: 0,
'aria-label': hue.root.translate('#?terra.colorPicker.hue|Hue', 'Hue'),
'aria-valuemin': 0,
'aria-valuemax': 360,
'aria-valuenow': hue.ariaValueNow.current,
[colorPickerAttrs['hue-slider']]: '',
} as const;
assertDataContract('ColorPicker', 'hue-slider', attrs);
return attrs;
});
</script>
<!--
Hue slider — horizontal track from 0° to 360°.
The thumb position reflects the current hue value.
-->
<div
bind:this={sliderEl}
class={className}
role="slider"
tabindex="0"
aria-label="Hue"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={hue.ariaValueNow.current}
{...attrs}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onkeydown={(e) => hue.handleKeydown(e)}
>
<!-- Thumb -->
<div
style="left: {hue.thumbStyle.current.left}"
aria-hidden="true"
></div>
</div>
<div style="left: {hue.thumbStyle.current.left}" aria-hidden="true"></div>
</div>

@ -1,37 +1,9 @@
<script lang="ts">
/**
* # Color Picker Input
*
* Campo de texto para ingresar/editar el valor del color.
*
* ## Uso
* ```svelte
* <ColorPickerInput format="hex" />
* <ColorPickerInput format="rgb" />
* <ColorPickerInput format="hsl" />
* ```
*
* ## Propiedades
* - `format`: "hex" | "hexa" | "rgb" | "rgba" | "hsl" | "hsla" - Formato del color
* - `class`: string - Clases CSS adicionales
*
* ## Selectores CSS
* ```
* No aplica selectores de datos específicos.
* Use el elemento input[type="text"] para aplicar estilos.
* ```
*
* ## Comportamiento
* - Actualiza el color al escribir y presionar Enter o perder foco
* - Muestra el color actual en el formato especificado
* - Valida el formato antes de aplicar cambios
*
* ## Estilos
* Renderiza un elemento `<input type="text">`.
*/
import { readableActive } from '$terra/utils';
import { ColorPickerInputState } from "../color-picker.svelte";
import type { ColorPickerInputProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerInputState } from '../color-picker.svelte';
import type { ColorPickerInputProps } from '../types';
let { format, class: className }: ColorPickerInputProps = $props();
@ -39,17 +11,28 @@
format: readableActive(() => format),
});
const ariaLabel = $derived(`Color value (${format})`);
const attrs = $derived.by(() => {
const prefix = input.root.translate('#?terra.colorPicker.inputValue|Color value', 'Color value');
const attrs = {
type: 'text' as const,
value: input.inputValue.current,
'aria-label': `${prefix} (${format})`,
spellcheck: 'false',
autocomplete: 'off',
[colorPickerAttrs.input]: '',
} as const;
assertDataContract('ColorPicker', 'input', attrs);
return attrs;
});
</script>
<input
type="text"
class={className}
value={input.inputValue.current}
aria-label={ariaLabel}
spellcheck="false"
autocomplete="off"
{...attrs}
oninput={(e) => input.handleInput(e.currentTarget.value)}
onblur={() => input.handleCommit()}
onkeydown={(e) => input.handleKeydown(e)}
/>

@ -1,34 +1,7 @@
<script lang="ts">
/**
* # Color Picker Swatch
*
* Muestra un color predefinido como opción seleccionable.
*
* ## Uso
* ```svelte
* <ColorPickerSwatch
* color="#ff0000"
* selected={selectedColor === '#ff0000'}
* onselect={() => selectColor('#ff0000')}
* />
* ```
*
* ## Propiedades
* - `color`: string - Color en formato hex
* - `selected`: boolean - Si está seleccionado
* - `onselect`: Callback al seleccionar
*
* ## Selectores CSS
* ```
* [data-color-picker-swatch] → Muestra un color predefinido como opción seleccionable
* [data-selected] → Estado cuando está seleccionado
* ```
*
* ## Estilos
* Renderiza un elemento `<button>` con role="radio".
* El color está disponible como `--cp-swatch-color`.
*/
import type { ColorPickerSwatchProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import type { ColorPickerSwatchProps } from '../types';
let {
color,
@ -38,15 +11,27 @@
selected?: boolean;
onselect?: () => void;
} = $props();
const attrs = $derived.by(() => {
const attrs = {
type: 'button' as const,
role: 'radio' as const,
'aria-checked': selected,
'aria-label': color,
'data-selected': selected ? '' : undefined,
style: `--cp-swatch-color: ${color}`,
[colorPickerAttrs.swatch]: '',
} as const;
assertDataContract('ColorPicker', 'swatch', attrs);
return attrs;
});
</script>
<button
type="button"
role="radio"
aria-checked={selected}
aria-label={color}
data-selected={selected ? "" : undefined}
style="--cp-swatch-color: {color}"
{...attrs}
onclick={onselect}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") onselect?.(); }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onselect?.(); }}
></button>

@ -1,35 +1,10 @@
<script lang="ts">
/**
* # Color Picker Swatches
*
* Contenedor de muestras de colores predefinidos.
*
* ## Uso
* ```svelte
* <ColorPickerSwatches
* colors={['#ff0000', '#00ff00', '#0000ff']}
* onSelect={(color) => setColor(color)}
* />
* ```
*
* ## Propiedades
* - `colors`: string[] - Array de colores en formato hex
* - `onSelect`: (color: string) => void - Callback al seleccionar
* - `class`: string - Clases CSS adicionales
*
* ## Selectores CSS
* ```
* No aplica selectores de datos específicos.
* Use el elemento con role="group" para aplicar estilos.
* ```
*
* ## Estilos
* Renderiza un elemento `<div>` con role="group".
*/
import { readableActive } from '$terra/utils';
import { ColorPickerSwatchesState } from "../color-picker.svelte";
import type { ColorPickerSwatchesProps } from "../types";
import ColorPickerSwatch from "./color-picker-swatch.svelte";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerSwatchesState } from '../color-picker.svelte';
import type { ColorPickerSwatchesProps } from '../types';
import ColorPickerSwatch from './color-picker-swatch.svelte';
let { colors, onSelect, class: className }: ColorPickerSwatchesProps = $props();
@ -37,13 +12,21 @@
colors: readableActive(() => colors),
onSelect: readableActive(() => onSelect),
});
const attrs = $derived.by(() => {
const attrs = {
role: 'group' as const,
'aria-label': swatches.root.translate('#?terra.colorPicker.swatches|Color swatches', 'Color swatches'),
[colorPickerAttrs.swatches]: '',
} as const;
assertDataContract('ColorPicker', 'swatches', attrs);
return attrs;
});
</script>
<div
class={className}
role="group"
aria-label="Color swatches"
>
<div class={className} {...attrs}>
{#each swatches.colors.current as color (color)}
<ColorPickerSwatch
{color}
@ -52,3 +35,4 @@
/>
{/each}
</div>

@ -1,57 +1,37 @@
<script lang="ts">
/**
* # Color Picker Trigger
*
* Botón que abre/cierra el panel del selector de color.
*
* ## Uso
* ```svelte
* <ColorPicker bind:value={color}>
* <ColorPickerTrigger showColor={true}>
* <div class="preview" />
* </ColorPickerTrigger>
* <ColorPickerContent>...</ColorPickerContent>
* </ColorPicker>
* ```
*
* ## Propiedades
* - `showColor`: boolean - Muestra el color como CSS variable `--cp-trigger-color`
*
* ## Selectores CSS
* ```
* [data-disabled] → Presente cuando disabled=true
* ```
*
* ## CSS Variables
* ```
* --cp-trigger-color → Color actual en formato HEX
* ```
*
* ## Estilos
* Renderiza un elemento `<button>`. Usa `data-disabled` cuando está deshabilitado.
*/
import { ColorPickerRootState } from "../color-picker.svelte";
import type { ColorPickerTriggerProps } from "../types";
import { assertDataContract } from '$terra/utils/contracts';
import { colorPickerAttrs } from '../color-picker-attrs';
import { ColorPickerRootState } from '../color-picker.svelte';
import type { ColorPickerTriggerProps } from '../types';
let {
showColor = true,
children,
...restProps
}: ColorPickerTriggerProps & {
children?: import("svelte").Snippet;
children?: import('svelte').Snippet;
onclick?: (e: MouseEvent) => void;
} = $props();
const root = ColorPickerRootState.get();
const attrs = $derived.by(() => {
const attrs = {
type: 'button' as const,
'aria-label': root.translate('#?terra.colorPicker.trigger|Pick a color', 'Pick a color'),
'aria-disabled': root.disabled.current,
'data-disabled': root.disabled.current ? '' : undefined,
style: showColor ? `--cp-trigger-color: ${root.hex.current}` : undefined,
[colorPickerAttrs.trigger]: '',
} as const;
assertDataContract('ColorPicker', 'trigger', attrs);
return attrs;
});
</script>
<button
type="button"
aria-label="Pick a color"
aria-disabled={root.disabled.current}
data-disabled={root.disabled.current ? "" : undefined}
style={showColor ? `--cp-trigger-color: ${root.hex.current}` : undefined}
{...restProps}
>
<button {...restProps} {...attrs}>
{@render children?.()}
</button>

@ -9,6 +9,8 @@ import {
} from '$terra/utils';
import { watch } from "runed";
import { createTerraContext } from '$terra/utils/context';
import { getTerraConfig } from '../config/terra-config';
import { resolveTerraTranslation } from '../config/translator.svelte';
import { findNextSibling, findPreviousSibling } from "./utils";
import type { CommandState } from "./types";
import type {
@ -21,7 +23,8 @@ import type {
import { kbd } from "../utils/keyboard/kbd";
import { createTerraAttrs, boolToStr, boolToEmptyStrOrUndef } from "../utils/attrs";
import { assertDataContract } from "$terra/utils/contracts";import { getFirstNonCommentChild } from "../dom/core/dom-internal";
import { assertDataContract } from '$terra/utils/contracts';
import { getFirstNonCommentChild } from '../dom/core/dom-internal';
import { computeCommandScore } from "./compute-command-score";
const COMMAND_VALUE_ATTR = "data-value";
@ -92,6 +95,7 @@ export class CommandRootState {
}
readonly opts: CommandRootStateOpts;
readonly attachment: RefAttachment;
readonly config = getTerraConfig();
updateScheduled = false;
isInitialMount = true;
sortAfterTick = false;
@ -144,6 +148,10 @@ export class CommandRootState {
this.commandState = defaults;
}
translate(value: unknown, fallback: string) {
return resolveTerraTranslation(value, this.config.translator.current, fallback);
}
score(value: string, keywords?: string[]) {
const filter = this.opts.filter.current ?? computeCommandScore;
return value ? filter(value, this._commandState.search, keywords) : 0;
@ -631,17 +639,19 @@ export class CommandRootState {
}
};
props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "application",
[commandAttrs.root]: "",
tabindex: -1,
onkeydown: this.onkeydown,
...this.attachment,
}) as const
);
props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'application',
[commandAttrs.root]: '',
tabindex: -1,
onkeydown: this.onkeydown,
...this.attachment,
} as const;
assertDataContract('Command', 'root', props);
return props;
});
}
function itemIsDisabled(item: HTMLElement) {
@ -674,9 +684,17 @@ export class CommandEmptyState {
$effect.pre(() => { this.isInitialRender = false; });
}
readonly props = $derived.by(
() => ({ id: this.opts.id.current, role: "presentation", [commandAttrs.empty]: "", ...this.attachment }) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'presentation',
[commandAttrs.empty]: '',
...this.attachment,
} as const;
assertDataContract('Command', 'empty', props);
return props;
});
}
// ── CommandGroupContainerState ────────────────────────────────────────────────
@ -730,16 +748,19 @@ export class CommandGroupContainerState {
});
}
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: "presentation",
role: 'presentation',
hidden: this.shouldRender ? undefined : true,
"data-value": this.trueValue,
[commandAttrs.group]: "",
'data-value': this.trueValue,
[commandAttrs.group]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'group', props);
return props;
});
}
// ── CommandGroupHeadingState ──────────────────────────────────────────────────
@ -760,9 +781,16 @@ export class CommandGroupHeadingState {
this.attachment = attachRef(this.opts.ref, (v) => (this.group.headingNode = v));
}
readonly props = $derived.by(
() => ({ id: this.opts.id.current, [commandAttrs["group-heading"]]: "", ...this.attachment }) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[commandAttrs['group-heading']]: '',
...this.attachment,
} as const;
assertDataContract('Command', 'group-heading', props);
return props;
});
}
// ── CommandGroupItemsState ────────────────────────────────────────────────────
@ -783,15 +811,18 @@ export class CommandGroupItemsState {
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: "group",
[commandAttrs["group-items"]]: "",
"aria-labelledby": this.group.headingNode?.id ?? undefined,
role: 'group',
[commandAttrs['group-items']]: '',
'aria-labelledby': this.group.headingNode?.id ?? undefined,
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'group-items', props);
return props;
});
}
// ── CommandInputState ─────────────────────────────────────────────────────────
@ -840,23 +871,26 @@ export class CommandInputState {
);
}
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: "text",
[commandAttrs.input]: "",
autocomplete: "off",
autocorrect: "off",
type: 'text',
[commandAttrs.input]: '',
autocomplete: 'off',
autocorrect: 'off',
spellcheck: false,
"aria-autocomplete": "list",
role: "combobox",
"aria-expanded": boolToStr(true),
"aria-controls": this.root.viewportNode?.id ?? undefined,
"aria-labelledby": this.root.labelNode?.id ?? undefined,
"aria-activedescendant": this.selectedItemId,
'aria-autocomplete': 'list',
role: 'combobox',
'aria-expanded': boolToStr(true),
'aria-controls': this.root.viewportNode?.id ?? undefined,
'aria-labelledby': this.root.labelNode?.id ?? undefined,
'aria-activedescendant': this.selectedItemId,
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'input', props);
return props;
});
}
// ── CommandItemState ──────────────────────────────────────────────────────────
@ -949,22 +983,25 @@ export class CommandItemState {
this.onSelect();
};
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
"aria-disabled": boolToStr(this.opts.disabled.current),
"aria-selected": boolToStr(this.isSelected),
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
"data-selected": boolToEmptyStrOrUndef(this.isSelected),
"data-value": this.trueValue,
"data-group": this.group?.trueValue,
[commandAttrs.item]: "",
role: "option",
'aria-disabled': boolToStr(this.opts.disabled.current),
'aria-selected': boolToStr(this.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-value': this.trueValue,
'data-group': this.group?.trueValue,
[commandAttrs.item]: '',
role: 'option',
onpointermove: this.onpointermove,
onclick: this.onclick,
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'item', props);
return props;
});
}
// ── CommandLoadingState ───────────────────────────────────────────────────────
@ -973,28 +1010,33 @@ interface CommandLoadingStateOpts extends WithRefOpts, ActiveProps<{ progress: n
export class CommandLoadingState {
static create(opts: CommandLoadingStateOpts) {
return new CommandLoadingState(opts);
return new CommandLoadingState(opts, CommandRootState.ctx.get());
}
readonly opts: CommandLoadingStateOpts;
readonly root: CommandRootState;
readonly attachment: RefAttachment;
private constructor(opts: CommandLoadingStateOpts) {
private constructor(opts: CommandLoadingStateOpts, root: CommandRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: "progressbar",
"aria-valuenow": this.opts.progress.current,
"aria-valuemin": 0,
"aria-valuemax": 100,
"aria-label": "Loading...",
[commandAttrs.loading]: "",
role: 'progressbar',
'aria-valuenow': this.opts.progress.current,
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-label': this.root.translate('#?terra.command.loading|Loading...', 'Loading...'),
[commandAttrs.loading]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'loading', props);
return props;
});
}
// ── CommandSeparatorState ─────────────────────────────────────────────────────
@ -1018,14 +1060,17 @@ export class CommandSeparatorState {
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
"aria-hidden": "true",
[commandAttrs.separator]: "",
'aria-hidden': 'true',
[commandAttrs.separator]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'separator', props);
return props;
});
}
// ── CommandListState ──────────────────────────────────────────────────────────
@ -1048,15 +1093,18 @@ export class CommandListState {
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: "listbox",
"aria-label": this.opts.ariaLabel.current,
[commandAttrs.list]: "",
role: 'listbox',
'aria-label': this.opts.ariaLabel.current,
[commandAttrs.list]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'list', props);
return props;
});
}
// ── CommandLabe$state ─────────────────────────────────────────────────────────
@ -1077,15 +1125,18 @@ export class CommandLabe$state {
this.attachment = attachRef(this.opts.ref, (v) => (this.root.labelNode = v));
}
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[commandAttrs["input-label"]]: "",
[commandAttrs['input-label']]: '',
for: this.opts.for?.current,
style: srOnlyStyles,
...this.attachment,
}) as const
);
} as const;
assertDataContract('Command', 'input-label', props);
return props;
});
}
// ── CommandViewportState ──────────────────────────────────────────────────────
@ -1122,8 +1173,15 @@ export class CommandViewportState {
);
}
readonly props = $derived.by(
() => ({ id: this.opts.id.current, [commandAttrs.viewport]: "", ...this.attachment }) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[commandAttrs.viewport]: '',
...this.attachment,
} as const;
assertDataContract('Command', 'viewport', props);
return props;
});
}

@ -1,37 +1,30 @@
<script lang="ts">
/**
* # TerraConfig
*
* Componente proveedor de configuración global para componentes Terra.
*
* ## Selectores CSS
* ```
* Este componente no renderiza elementos HTML ni agrega atributos data.
* Es un componente proveedor de contexto que solo pasa configuración
* a sus componentes descendientes.
* ```
*/
import type { TerraConfigProps } from '../types.ts';
import { useTerraConfig } from '../terra-config.ts';
import { readableActive } from '$terra/utils';
import { configureTerraLogger } from '$terra/utils/logger';
import { configureTerraLogger } from '../logger';
let {
children,
defaultPortalTo,
defaultLocale,
defaultDir,
defaultDateOrder,
defaultTimeFormat,
portalTo,
translator,
dir,
dateTimeFormatter,
numberFormatter,
currencyFormatter,
unitFormatter,
logger,
}: TerraConfigProps = $props();
useTerraConfig({
defaultPortalTo: readableActive(() => defaultPortalTo),
defaultLocale: readableActive(() => defaultLocale),
defaultDir: readableActive(() => defaultDir),
defaultDateOrder: readableActive(() => defaultDateOrder),
defaultTimeFormat: readableActive(() => defaultTimeFormat),
portalTo: readableActive(() => portalTo),
translator: readableActive(() => translator),
dir: readableActive(() => dir),
dateTimeFormatter: readableActive(() => dateTimeFormatter),
numberFormatter: readableActive(() => numberFormatter),
currencyFormatter: readableActive(() => currencyFormatter),
unitFormatter: readableActive(() => unitFormatter),
logger: readableActive(() => logger),
});
$effect(() => {

@ -0,0 +1,10 @@
import type { TerraCurrencyFormatter } from './types';
import { createTerraFormatter, type TerraFormatterSource } from './formatter.svelte.ts';
export type TerraCurrencyFormatterSource = TerraFormatterSource;
export function createTerraCurrencyFormatter(
source: TerraCurrencyFormatterSource
): TerraCurrencyFormatter {
return createTerraFormatter(source);
}

@ -0,0 +1,21 @@
import type { Getter } from '$terra/utils';
import type { DateOrder, HourCycle } from '$terra/external/dates';
import type { TerraDateTimeFormatter } from './types';
export type TerraDateTimeFormatterSource = {
dateOrder?: Getter<DateOrder | undefined>;
timeFormat?: Getter<HourCycle | undefined>;
};
export function createTerraDateTimeFormatter(
source: TerraDateTimeFormatterSource
): TerraDateTimeFormatter {
return {
getDateOrder() {
return source.dateOrder?.();
},
getTimeFormat() {
return source.timeFormat?.();
},
};
}

@ -1,4 +1,15 @@
export { default as TerraConfig } from './components/terra-config.svelte';
export { createTerraDateTimeFormatter } from './date-time-formatter.svelte.ts';
export {
createTerraTranslator,
extendTerraTranslationModule,
resolveTerraTranslation,
resolveTerraTranslationPath,
} from './translator.svelte.ts';
export { createTerraNumberFormatter } from './number-formatter.svelte.ts';
export { createTerraCurrencyFormatter } from './currency-formatter.svelte.ts';
export { createTerraUnitFormatter } from './unit-formatter.svelte.ts';
export { configureTerraLogger, getTerraLogger } from './logger';
export { getTerraConfig, TerraConfigState } from './terra-config.js';
export type {
TerraConfigProps,
@ -6,4 +17,12 @@ export type {
TerraPresentationProps,
TerraDatePresentationProps,
TerraDateTimePresentationProps,
TerraTranslator,
TerraTranslationModule,
TerraTranslationValue,
TerraDateTimeFormatter,
TerraNumberFormatter,
TerraCurrencyFormatter,
TerraUnitFormatter,
TerraLogger,
} from './types.ts';

@ -0,0 +1,17 @@
import type { Getter } from '$terra/utils';
export type TerraFormatter = {
format: (...args: unknown[]) => string;
};
export type TerraFormatterSource = {
format: Getter<(...args: unknown[]) => string>;
};
export function createTerraFormatter(source: TerraFormatterSource): TerraFormatter {
return {
format(...args: unknown[]) {
return source.format()(...args);
},
};
}

@ -1 +1 @@
export * from "./exports.ts";
export * from './exports.ts';

@ -0,0 +1,23 @@
export interface TerraLogger {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
const consoleLogger: TerraLogger = {
debug: (msg, ctx) => (ctx ? console.debug(msg, ctx) : console.debug(msg)),
info: (msg, ctx) => (ctx ? console.info(msg, ctx) : console.info(msg)),
warn: (msg, ctx) => (ctx ? console.warn(msg, ctx) : console.warn(msg)),
error: (msg, ctx) => (ctx ? console.error(msg, ctx) : console.error(msg)),
};
let logger: TerraLogger = consoleLogger;
export function configureTerraLogger(nextLogger: TerraLogger): void {
logger = nextLogger;
}
export function getTerraLogger(): TerraLogger {
return logger;
}

@ -0,0 +1,10 @@
import type { TerraNumberFormatter } from './types';
import { createTerraFormatter, type TerraFormatterSource } from './formatter.svelte.ts';
export type TerraNumberFormatterSource = TerraFormatterSource;
export function createTerraNumberFormatter(
source: TerraNumberFormatterSource
): TerraNumberFormatter {
return createTerraFormatter(source);
}

@ -4,17 +4,11 @@ import {
DEFAULT_TIME_FORMAT,
timeFormatToHourCycle,
type HourCycle,
} from '$terra/adapters/dates';
} from '$terra/external/dates';
import { type TerraConfigState, getTerraConfig } from './terra-config.ts';
/**
* Creates a generic prop resolver that follows a standard priority chain:
* 1. The getter's prop value (if defined)
* 2. The config default value (if no getter prop value is defined)
* 3. The fallback value (if no config value found)
*/
function createPropResolver<T>(
configOption: (config: TerraConfigState['opts']) => { current: T | undefined },
configOption: (config: TerraConfigState['opts']) => T | undefined,
fallback: T
) {
return (getProp: Getter<T | undefined>): Active<T> => {
@ -23,7 +17,7 @@ function createPropResolver<T>(
const propValue = getProp();
if (propValue !== undefined) return propValue;
const option = configOption(config).current;
const option = configOption(config);
if (option !== undefined) return option;
return fallback;
@ -31,41 +25,26 @@ function createPropResolver<T>(
};
}
/**
* Resolves a locale value using the prop, the config default, or a fallback.
*
* Default value: `"en"`
*/
export const resolveLocaleProp = createPropResolver((config) => config.defaultLocale, 'en');
export const resolveLocaleProp = createPropResolver(
(config) => config.translator.current?.getLocale(),
'en'
);
/**
* Resolves a direction value using the prop, the config default, or a fallback.
*
* Default value: `"ltr"`
*/
export const resolveDirectionProp = createPropResolver(
(config) => config.defaultDir,
(config) => config.dir.current,
'ltr' as Direction
);
/**
* Resolves a portal's `to` value using the prop, the config default, or a fallback.
*
* Default value: `"body"`
*/
export const resolvePortalToProp = createPropResolver((config) => config.defaultPortalTo, 'body');
export const resolvePortalToProp = createPropResolver(
(config) => config.portalTo.current,
'body'
);
/**
* Resolves a date order using the prop, the config default, or the library fallback.
*/
export const resolveDateOrderProp = createPropResolver(
(config) => config.defaultDateOrder,
(config) => config.dateTimeFormatter.current?.getDateOrder(),
DEFAULT_DATE_ORDER
);
/**
* Resolves an hour cycle using explicit time/date props, config defaults, or the library fallback.
*/
export function resolveHourCycleProp(
getTimeFormat: Getter<HourCycle | undefined>,
getHourCycle: Getter<HourCycle | undefined>
@ -78,7 +57,7 @@ export function resolveHourCycleProp(
const hourCycle = getHourCycle();
if (hourCycle !== undefined) return hourCycle;
const configTimeFormat = config.defaultTimeFormat.current;
const configTimeFormat = config.dateTimeFormatter.current?.getTimeFormat();
if (configTimeFormat !== undefined) return timeFormatToHourCycle(configTimeFormat);
return timeFormatToHourCycle(DEFAULT_TIME_FORMAT);

@ -1,93 +1,51 @@
import { createTerraContext } from '$terra/utils/context';
import { type Active, type ActiveProps, readableActive } from '$terra/utils';
import type { TerraConfigPropsWithoutChildren } from './types';
type TerraConfigStateProps = ActiveProps<TerraConfigPropsWithoutChildren>;
export const TerraConfigContext = createTerraContext<TerraConfigState>('TerraConfig');
export function getTerraConfig() {
const fallback = new TerraConfigState(null, {});
return TerraConfigContext.getOr(fallback).opts;
}
export function useTerraConfig(opts: TerraConfigStateProps) {
return TerraConfigContext.set(new TerraConfigState(TerraConfigContext.getOr(null), opts));
}
export class TerraConfigState {
readonly opts: Required<TerraConfigStateProps>;
constructor(parent: TerraConfigState | null, opts: TerraConfigStateProps) {
const resolveConfigOption = createConfigResolver(parent, opts);
this.opts = {
defaultPortalTo: resolveConfigOption((config) => config.defaultPortalTo),
defaultLocale: resolveConfigOption((config) => config.defaultLocale),
defaultDir: resolveConfigOption((config) => config.defaultDir),
defaultDateOrder: resolveConfigOption((config) => config.defaultDateOrder),
defaultTimeFormat: resolveConfigOption((config) => config.defaultTimeFormat),
portalTo: resolveConfigOption((config) => config.portalTo),
translator: resolveConfigOption((config) => config.translator),
dir: resolveConfigOption((config) => config.dir),
dateTimeFormatter: resolveConfigOption((config) => config.dateTimeFormatter),
numberFormatter: resolveConfigOption((config) => config.numberFormatter),
currencyFormatter: resolveConfigOption((config) => config.currencyFormatter),
unitFormatter: resolveConfigOption((config) => config.unitFormatter),
logger: resolveConfigOption((config) => config.logger),
};
}
}
type ConfigOptionGetter<T> = (config: TerraConfigStateProps) => Active<T> | undefined;
type ConfigOptionResolver = <T>(getter: ConfigOptionGetter<T>) => Active<T | undefined>;
function createConfigResolver(
parent: TerraConfigState | null,
currentOpts: TerraConfigStateProps
): ConfigOptionResolver {
return <T>(getter: ConfigOptionGetter<T>) => {
return readableActive(() => {
const value = getter(currentOpts)?.current;
if (value !== undefined) return value;
if (parent === null) return undefined;
return getter(parent.opts)?.current;
});
};
}

@ -0,0 +1,94 @@
import { onDestroyEffect, type Getter } from '$terra/utils';
import type { TerraTranslationModule, TerraTranslationValue, TerraTranslator } from './types';
const TRANSLATION_ID_PREFIX = '#?';
const TRANSLATION_ID_FALLBACK_SEPARATOR = '|';
type TerraTranslatorRuntime = {
extend?: (...args: any[]) => void;
onSchemaChange?: (callback: () => void) => (() => void) | void;
};
export type TerraTranslatorSource = {
locale: Getter<string>;
translate?: Getter<(value: any) => string>;
translatePath?: Getter<(path: string, ...args: unknown[]) => string>;
runtime?: Getter<TerraTranslatorRuntime | undefined>;
};
export function createTerraTranslator(source: TerraTranslatorSource): TerraTranslator {
let schemaVersion = $state(0);
const runtime = source.runtime?.();
const dispose = runtime?.onSchemaChange?.(() => {
schemaVersion += 1;
});
if (dispose) onDestroyEffect(dispose);
return {
getLocale() {
schemaVersion;
return source.locale();
},
translate(value: TerraTranslationValue) {
source.locale();
schemaVersion;
const translate = source.translate?.();
return translate ? translate(value) : String(value ?? '');
},
translatePath(path: string, ...args: unknown[]) {
source.locale();
schemaVersion;
const translatePath = source.translatePath?.();
return translatePath ? translatePath(path, ...args) : path;
},
extend(namespace: string, module: TerraTranslationModule) {
runtime?.extend?.(namespace, module);
},
};
}
export function extendTerraTranslationModule(
target: Pick<TerraTranslator, 'extend'> | Pick<TerraTranslatorRuntime, 'extend'>,
module: TerraTranslationModule,
namespace = 'terra'
) {
target.extend?.(namespace, module);
}
function getInlineTranslationFallback(value: TerraTranslationValue): string | undefined {
if (typeof value !== 'string') return undefined;
if (!value.startsWith(TRANSLATION_ID_PREFIX)) return undefined;
const raw = value.slice(TRANSLATION_ID_PREFIX.length);
const separatorIndex = raw.indexOf(TRANSLATION_ID_FALLBACK_SEPARATOR);
if (separatorIndex === -1) return undefined;
return raw.slice(separatorIndex + TRANSLATION_ID_FALLBACK_SEPARATOR.length);
}
export function resolveTerraTranslation(
value: TerraTranslationValue,
translator: TerraTranslator | undefined,
fallback = ''
): string {
if (translator) return translator.translate(value);
const inlineFallback = getInlineTranslationFallback(value);
if (inlineFallback !== undefined) return inlineFallback;
if (typeof value === 'string') return value;
return fallback;
}
export function resolveTerraTranslationPath(
path: string,
translator: TerraTranslator | undefined,
fallback = path,
...args: unknown[]
): string {
if (!translator) return fallback;
return translator.translatePath(path, ...args);
}

@ -1,84 +1,53 @@
import type { Direction, WithChildren } from '$terra/utils';
import type { TerraLogger } from '$terra/utils/logger';
import type { DateOrder, HourCycle } from '$terra/adapters/dates';
import type { DateOrder, HourCycle } from '$terra/external/dates';
import type { PortalTarget } from '../layers/portal/types.ts';
import type { TerraFormatter } from './formatter.svelte.ts';
export type TerraConfigPropsWithoutChildren = {
/**
* The default portal `to`/target to use for the `Portal` components throughout the app.
*/
defaultPortalTo?: PortalTarget;
/**
* The default locale to use for the components that support localization.
*/
defaultLocale?: string;
export type TerraLogger = import('./logger').TerraLogger;
export type TerraTranslationModule = unknown;
export type TerraTranslationValue = unknown;
/**
* The default direction to use for direction-aware components.
*
* @defaultValue 'ltr'
*/
defaultDir?: Direction;
export type TerraTranslator = {
getLocale: () => string;
translate: (value: TerraTranslationValue) => string;
translatePath: (path: string, ...args: unknown[]) => string;
extend?: (...args: any[]) => void;
};
/**
* The default date part order to use for date-aware components.
*/
defaultDateOrder?: DateOrder;
export type TerraDateTimeFormatter = {
getDateOrder: () => DateOrder | undefined;
getTimeFormat: () => HourCycle | undefined;
};
/**
* The default time format to use for time-aware components.
*/
defaultTimeFormat?: HourCycle;
export type TerraNumberFormatter = TerraFormatter;
export type TerraCurrencyFormatter = TerraFormatter;
export type TerraUnitFormatter = TerraFormatter;
/**
* Logger instance to use for terra internal warnings and errors.
* If not provided, terra falls back to console.*.
* Use `configureTerraLogger()` as an alternative for non-Svelte environments.
*/
export type TerraConfigPropsWithoutChildren = {
portalTo?: PortalTarget;
translator?: TerraTranslator;
dir?: Direction;
dateTimeFormatter?: TerraDateTimeFormatter;
numberFormatter?: TerraNumberFormatter;
currencyFormatter?: TerraCurrencyFormatter;
unitFormatter?: TerraUnitFormatter;
logger?: TerraLogger;
};
export type TerraConfigProps = WithChildren<TerraConfigPropsWithoutChildren>;
export type TerraPresentationProps = {
/**
* The locale to use for formatting localized components.
*
* @defaultValue 'en'
*/
locale?: string;
/**
* The direction to use for direction-aware components.
*
* @defaultValue 'ltr'
*/
dir?: Direction;
};
export type TerraDatePresentationProps = TerraPresentationProps & {
/**
* Preferred order for date segments in date-aware components.
*
* @defaultValue 'DMY'
*/
dateOrder?: DateOrder;
};
export type TerraDateTimePresentationProps = TerraDatePresentationProps & {
/**
* The format to use for displaying time segments.
*
* @defaultValue the locale's default time format
*/
hourCycle?: HourCycle;
/**
* Preferred time format for time-aware components.
* When provided, it overrides `hourCycle`.
*
* @defaultValue '24h'
*/
timeFormat?: HourCycle;
};

@ -0,0 +1,10 @@
import type { TerraUnitFormatter } from './types';
import { createTerraFormatter, type TerraFormatterSource } from './formatter.svelte.ts';
export type TerraUnitFormatterSource = TerraFormatterSource;
export function createTerraUnitFormatter(
source: TerraUnitFormatterSource
): TerraUnitFormatter {
return createTerraFormatter(source);
}

@ -23,7 +23,7 @@
import { resolveDateOrderProp, resolveHourCycleProp, resolveLocaleProp } from "../../config/prop-resolvers.ts";
import {
getDefaultDate
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import { noop } from '$lib/util/funcs';
let {

@ -16,12 +16,15 @@ import {
import { onMount, untrack } from 'svelte';
import { watch } from 'runed';
import { createTerraContext } from '$terra/utils/context';
import { getTerraConfig } from '../config/terra-config';
import { resolveTerraTranslation } from '../config/translator.svelte';
import type { DateRangeFieldRootState } from '../date-range-field/date-range-field.svelte.js';
import { boolToEmptyStrOrUndef, boolToStr, boolToStrTrueOrUndef, createTerraAttrs } from '../utils/attrs';
import { assertDataContract } from "$terra/utils/contracts";import { kbd } from '../utils/keyboard/kbd.ts';
import type { DateOnInvalid, DateOrder, DateValidator, DateValue, Granularity, HourCycle } from '$terra/adapters/dates';
import { dateValueToDate as toDate, getDaysInMonth, isBefore } from '$terra/adapters/dates';
import { assertDataContract } from '$terra/utils/contracts';
import { kbd } from '../utils/keyboard/kbd.ts';
import type { DateOnInvalid, DateOrder, DateValidator, DateValue, Granularity, HourCycle } from '$terra/external/dates';
import { dateValueToDate as toDate, getDaysInMonth, isBefore } from '$terra/external/dates';
import type {
DateAndTimeSegmentObj,
DateSegmentObj,
@ -186,6 +189,7 @@ export class DateFieldRootState {
errorMessageId: DateFieldRootStateOpts["errorMessageId"];
isInvalidProp: DateFieldRootStateOpts["isInvalidProp"];
descriptionId = useId();
readonly config = getTerraConfig();
formatter: Formatter;
initialSegments: SegmentValueObj;
segmentValues = $state() as SegmentValueObj;
@ -311,6 +315,26 @@ export class DateFieldRootState {
);
}
translate(value: string, fallback = '') {
return resolveTerraTranslation(value, this.config.translator.current, fallback);
}
getSegmentLabel(part: string) {
return this.translate(`#?terra.dateField.${part}|${part}`, part);
}
getEmptyLabel() {
return this.translate('#?terra.dateField.empty|Empty', 'Empty');
}
getDayPeriodLabel() {
return this.translate('#?terra.dateField.dayPeriod|AM/PM', 'AM/PM');
}
getTimeZoneLabel() {
return this.translate('#?terra.dateField.timeZone|timezone', 'timezone');
}
setName(name: string) {
this.name = name;
}
@ -745,9 +769,8 @@ export class DateFieldInputState {
return this.root.descriptionId;
});
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: "group",
"aria-labelledby": this.root.getLabelNode()?.id ?? undefined,
@ -757,8 +780,10 @@ export class DateFieldInputState {
"data-disabled": boolToEmptyStrOrUndef(this.root.disabled.current),
[dateFieldAttrs.input]: "",
...this.attachment,
}) as const
);
} as const;
assertDataContract('DateField', 'input', props);
return props;
});
}
export class DateFieldHiddenInputState {
static create() {
@ -776,11 +801,13 @@ export class DateFieldHiddenInputState {
}
readonly props = $derived.by(() => {
return {
const props = {
name: this.root.name,
value: this.isoValue,
required: this.root.required.current,
};
} as const;
assertDataContract('DateField', 'segment', props);
return props;
});
}
@ -808,17 +835,18 @@ export class DateFieldLabelState {
firstSegment.focus();
};
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
"data-invalid": boolToEmptyStrOrUndef(this.root.isInvalid),
"data-disabled": boolToEmptyStrOrUndef(this.root.disabled.current),
[dateFieldAttrs.label]: "",
onclick: this.onclick,
...this.attachment,
}) as const
);
} as const;
assertDataContract('DateField', 'label', props);
return props;
});
}
// Base class for numeric segments
@ -1103,15 +1131,15 @@ abstract class BaseNumericSegmentState {
const valueNow = date[this.part as keyof DateValue] as number;
const valueMin = this.getMin();
const valueMax = this.getMax();
let valueText = isEmpty ? "Empty" : `${valueNow}`;
let valueText = isEmpty ? this.root.getEmptyLabel() : `${valueNow}`;
// Special handling for hour segment with dayPeriod
if (this.part === "hour" && "dayPeriod" in segmentValues && segmentValues.dayPeriod) {
valueText = isEmpty ? "Empty" : `${valueNow} ${segmentValues.dayPeriod}`;
valueText = isEmpty ? this.root.getEmptyLabel() : `${valueNow} ${segmentValues.dayPeriod}`;
}
return {
"aria-label": `${this.part}, `,
"aria-label": `${this.root.getSegmentLabel(this.part)}, `,
"aria-valuemin": valueMin,
"aria-valuemax": valueMax,
"aria-valuenow": valueNow,
@ -1412,7 +1440,7 @@ export class DateFieldDayPeriodSegmentState {
...this.root.sharedSegmentAttrs,
id: this.opts.id.current,
inputmode: "text",
"aria-label": "AM/PM",
"aria-label": this.root.getDayPeriodLabel(),
"aria-valuemin": valueMin,
"aria-valuemax": valueMax,
"aria-valuenow": valueNow,
@ -1483,7 +1511,7 @@ export class DateFieldTimeZoneSegmentState {
({
role: "textbox",
id: this.opts.id.current,
"aria-label": "timezone, ",
"aria-label": `${this.root.getTimeZoneLabel()}, `,
style: {
caretColor: "transparent",
},

@ -3,7 +3,7 @@ import type {
DateOnInvalid,
DateValidator,
Granularity,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import type { EditableSegmentPart, SegmentPart } from "../utils/datetime/types";
import type { TerraDateTimePresentationProps } from '../config/types';
import type { WithChildren, OnChangeFn, WithChild, Without } from '$terra/utils';

@ -14,7 +14,7 @@
* ```
*/
import { watch } from 'runed';
import type { DateValue } from '$terra/adapters/dates';
import type { DateValue } from '$terra/external/dates';
import { readableActive, writableActive, mergeProps, useId } from '$terra/utils';
import { DatePickerRootState } from '../date-picker.svelte.ts';
@ -22,7 +22,7 @@
import { PopoverRootState } from '../../popover/popover.svelte.ts';
import { DateFieldRootState } from '../../date-field/date-field.svelte.ts';
import FloatingLayer from '../../layers/floating/components/floating-layer.svelte';
import { getDefaultDate } from '$terra/adapters/dates';
import { getDefaultDate } from '$terra/external/dates';
import { resolveDateOrderProp, resolveHourCycleProp, resolveLocaleProp } from '../../config/prop-resolvers.ts';
import { noop } from '$lib/util/funcs';
@ -50,7 +50,7 @@
timeFormat,
hideTimeZone = false,
required = false,
calendarLabel = 'Event',
calendarLabel = '#?terra.datePicker.calendarLabel|Calendar',
disableDaysOutsideMonth = true,
preventDeselect = false,
pagedNavigation = false,
@ -183,3 +183,4 @@
{/if}
</FloatingLayer>

@ -1,4 +1,4 @@
import type { DateValue } from '$terra/adapters/dates';
import type { DateValue } from '$terra/external/dates';
import { createTerraContext } from '$terra/utils/context';
import type { ActiveProps, StateProps } from '../utils';
import type {
@ -7,7 +7,7 @@ import type {
HourCycle,
WeekStartsOn,
DateOrder,
} from '$terra/adapters/dates';
} from '$terra/external/dates';
import type { SegmentPart } from '../utils/datetime/types';
interface DatePickerRootStateOpts
extends StateProps<{

@ -1,4 +1,4 @@
import type { DateValue } from "$terra/adapters/dates";
import type { DateValue } from "$terra/external/dates";
import type { OnChangeFn, WithChild, WithChildren, Without } from "../utils/types.ts";
import type { TerraPrimitiveDivAttributes } from '$terra/utils';
import type {
@ -7,7 +7,7 @@ import type {
DateValidator,
Granularity,
WeekStartsOn,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import type { EditableSegmentPart } from "../utils/datetime/types";
import type { TerraDateTimePresentationProps } from '../config/types';
import type { CalendarRootSnippetProps } from "../calendar/types.ts";

@ -15,15 +15,15 @@
*/
import { watch } from "runed";
import { readableActive, writableActive, mergeProps } from "$terra/utils";
import type { DateValue } from "$terra/adapters/dates";
import type { DateValue } from "$terra/external/dates";
import { DateRangeFieldRootState } from "../date-range-field.svelte.ts";
import type { DateRangeFieldRootProps } from "../types.js";
import { createId } from "$terra/utils";
import type { DateRange } from "$terra/adapters/dates";
import type { DateRange } from "$terra/external/dates";
import {
getDefaultDate,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import { resolveDateOrderProp, resolveHourCycleProp, resolveLocaleProp } from "../../config/prop-resolvers.ts";
import { noop } from '$lib/util/funcs';

@ -1,6 +1,6 @@
import { watch } from "runed";
import { createTerraContext } from '$terra/utils/context';
import type { DateValue, DateOrder } from "$terra/adapters/dates";
import type { DateValue, DateOrder } from "$terra/external/dates";
import type {
ActiveProps,
@ -27,8 +27,8 @@ import type {
DateRange,
DateRangeValidator,
Granularity,
} from "$terra/adapters/dates";
import { isBefore } from "$terra/adapters/dates";
} from "$terra/external/dates";
import { isBefore } from "$terra/external/dates";
import type { SegmentPart } from "../utils/datetime/types.ts";
import { type Formatter, createFormatter } from "../utils/datetime";
import { removeDescriptionElement } from "../utils/datetime/helpers.ts";
@ -218,16 +218,17 @@ export class DateRangeFieldRootState {
this.opts.value.current = newValue;
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: "group",
[dateRangeFieldAttrs.root]: "",
"data-invalid": boolToEmptyStrOrUndef(this.isInvalid),
...this.attachment,
}) as const
);
} as const;
assertDataContract('DateRangeField', 'root', props);
return props;
});
}
interface DateRangeFieldLabelStateOpts extends WithRefOpts {}
@ -254,17 +255,18 @@ export class DateRangeFieldLabelState {
firstSegment.focus();
};
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
"data-invalid": boolToEmptyStrOrUndef(this.root.isInvalid),
"data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current),
[dateRangeFieldAttrs.label]: "",
onclick: this.onclick,
...this.attachment,
}) as const
);
} as const;
assertDataContract('DateRangeField', 'label', props);
return props;
});
}
interface DateRangeFieldInputStateOpts

@ -1,4 +1,4 @@
import type { DateValue } from "$terra/adapters/dates";
import type { DateValue } from "$terra/external/dates";
import type { OnChangeFn, WithChild, Without } from "../utils";
import type {
TerraPrimitiveDivAttributes,
@ -9,7 +9,7 @@ import type {
DateRange,
DateRangeValidator,
Granularity,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import type { EditableSegmentPart, SegmentPart } from "../utils/datetime/types.ts";
import type { TerraDateTimePresentationProps } from '../config/types';
import type {

@ -14,7 +14,7 @@
* ```
*/
import { watch } from "runed";
import type { DateValue } from "$terra/adapters/dates";
import type { DateValue } from "$terra/external/dates";
import {
readableActive,
writableActive,
@ -33,7 +33,7 @@
import {
type DateRange,
getDefaultDate
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import { resolveDateOrderProp, resolveHourCycleProp, resolveLocaleProp } from "../../config/prop-resolvers.ts";
import { noop } from '$lib/util/funcs';

@ -1,4 +1,4 @@
import type { DateValue } from "$terra/adapters/dates";
import type { DateValue } from "$terra/external/dates";
import { createTerraContext } from '$terra/utils/context';
import { type ActiveProps, type StateProps } from "../utils";
import type {
@ -8,7 +8,7 @@ import type {
HourCycle,
WeekStartsOn,
DateOrder,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import type { SegmentPart } from "../utils/datetime/types";
interface DateRangePickerRootStateOpts
extends StateProps<{

@ -1,4 +1,4 @@
import type { DateValue } from "$terra/adapters/dates";
import type { DateValue } from "$terra/external/dates";
import type { OnChangeFn, WithChild, Without } from "../utils/types.ts";
import type { TerraPrimitiveDivAttributes } from '$terra/utils';
import type {
@ -8,7 +8,7 @@ import type {
DateRangeValidator,
Granularity,
WeekStartsOn,
} from "$terra/adapters/dates";
} from "$terra/external/dates";
import type { EditableSegmentPart } from "../utils/datetime/types";
import type { TerraDateTimePresentationProps } from '../config/types';
import type { CalendarRootSnippetProps } from "../calendar/types.ts";

@ -23,7 +23,7 @@
*
* ## Selectores CSS
* ```
* [data-terra-dialog-close] → Botón de cerrar
* [data-dialog-close] → Botón de cerrar
* ```
*
* ## Estilos

@ -30,7 +30,7 @@
*
* ## Selectores CSS
* ```
* [data-terra-dialog-content] → Contenedor del diálogo
* [data-dialog-content] → Contenedor del diálogo
* [data-state="open"] → Estado abierto
* [data-state="closed"] → Estado cerrado
* [data-nested] → Presente cuando el diálogo está anidado

@ -20,7 +20,7 @@
*
* ## Selectores CSS
* ```
* [data-terra-dialog-description] → Descripción del diálogo
* [data-dialog-description] → Descripción del diálogo
* ```
*
* ## Estilos

@ -15,7 +15,7 @@
*
* ## Selectores CSS
* ```
* [data-terra-dialog-overlay] → Fondo del diálogo
* [data-dialog-overlay] → Fondo del diálogo
* [data-state="open"] → Estado abierto
* [data-state="closed"] → Estado cerrado
* [data-nested] → Presente cuando el diálogo está anidado

@ -22,7 +22,7 @@
*
* ## Selectores CSS
* ```
* [data-terra-dialog-title] → Título del diálogo
* [data-dialog-title] → Título del diálogo
* ```
*
* ## Estilos

@ -20,7 +20,7 @@
*
* ## Selectores CSS
* ```
* [data-terra-dialog-trigger] → Botón que abre el diálogo
* [data-dialog-trigger] → Botón que abre el diálogo
* ```
*
* ## Estilos

@ -23,7 +23,7 @@
* - `onOpenChangeComplete`: Callback cuando la animación termina
*
* ## Selectores CSS
* El dialog usa atributos `data-terra-dialog-*` para sus partes.
* El dialog usa atributos `data-dialog-*` para sus partes.
*/
import { readableActive, writableActive } from '$terra/utils';
import { DialogRootState } from '../dialog.svelte';

@ -7,6 +7,8 @@ import {
} from '$terra/utils';
import { watch } from "runed";
import { createTerraContext } from '$terra/utils/context';
import { getTerraConfig } from '../config/terra-config';
import { resolveTerraTranslation } from '../config/translator.svelte';
import {
createTerraAttrs,
boolToStr,
@ -47,6 +49,7 @@ export class DialogRootState {
}
readonly opts: DialogRootStateOpts;
readonly config = getTerraConfig();
triggerNode = $state<HTMLElement | null>(null);
contentNode = $state<HTMLElement | null>(null);
overlayNode = $state<HTMLElement | null>(null);
@ -100,6 +103,20 @@ export class DialogRootState {
});
}
translate(value: string, fallback = '') {
return resolveTerraTranslation(value, this.config.translator.current, fallback);
}
getActionLabel(variant: 'action' | 'cancel' | 'close') {
if (variant === 'cancel') {
return this.translate('#?terra.alertDialog.cancel|Cancel', 'Cancel');
}
if (variant === 'action') {
return this.translate('#?terra.alertDialog.action|Action', 'Action');
}
return this.translate('#?terra.dialog.close|Close', 'Close');
}
handleOpen = () => {
if (this.opts.open.current) return;
this.opts.open.current = true;
@ -111,9 +128,14 @@ export class DialogRootState {
};
getTerraAttr: typeof dialogAttrs.getAttr = (part) => {
return dialogAttrs.getAttr(part, this.opts.variant.current);
return dialogAttrs.getAttr(part);
};
getContractName(part: 'content' | 'trigger' | 'overlay' | 'title' | 'description' | 'close' | 'cancel' | 'action') {
if (part === 'cancel' || part === 'action') return 'AlertDialog';
return this.opts.variant.current === 'alert-dialog' ? 'AlertDialog' : 'Dialog';
}
incrementNested() {
this.nestedOpenCount++;
this.parent?.incrementNested();
@ -165,8 +187,8 @@ export class DialogTriggerState {
};
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
"aria-haspopup": "dialog",
"aria-expanded": boolToStr(this.root.opts.open.current),
@ -177,7 +199,12 @@ export class DialogTriggerState {
disabled: boolToTrueOrUndef(this.opts.disabled.current),
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract(this.root.getContractName('trigger'), 'trigger', props);
return props;
}
);
}
@ -215,8 +242,8 @@ export class DialogCloseState {
};
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
[this.root.getTerraAttr(this.opts.variant.current)]: "",
onclick: this.onclick,
@ -225,7 +252,12 @@ export class DialogCloseState {
tabindex: 0,
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract(this.root.getContractName(this.opts.variant.current), this.opts.variant.current, props);
return props;
}
);
}
@ -247,13 +279,19 @@ export class DialogActionState {
}
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
'aria-label': this.root.getActionLabel('action'),
[this.root.getTerraAttr("action")]: "",
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract('AlertDialog', 'action', props);
return props;
}
);
}
@ -281,15 +319,20 @@ export class DialogTitleState {
}
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
role: "heading",
"aria-level": this.opts.level.current,
[this.root.getTerraAttr("title")]: "",
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract(this.root.getContractName('title'), 'title', props);
return props;
}
);
}
@ -317,13 +360,18 @@ export class DialogDescriptionState {
}
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
[this.root.getTerraAttr("description")]: "",
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract(this.root.getContractName('description'), 'description', props);
return props;
}
);
}
@ -350,8 +398,8 @@ export class DialogContentState {
readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
role: this.root.opts.variant.current === "alert-dialog" ? "alertdialog" : "dialog",
"aria-modal": "true",
@ -370,7 +418,12 @@ export class DialogContentState {
"data-nested": boolToEmptyStrOrUndef(this.root.parent !== null),
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract(this.root.getContractName('content'), 'content', props);
return props;
}
);
get shouldRender() {
@ -398,8 +451,8 @@ export class DialogOverlayState {
readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
[this.root.getTerraAttr("overlay")]: "",
style: {
@ -411,7 +464,12 @@ export class DialogOverlayState {
"data-nested": boolToEmptyStrOrUndef(this.root.parent !== null),
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract(this.root.getContractName('overlay'), 'overlay', props);
return props;
}
);
get shouldRender() {
@ -453,16 +511,22 @@ export class AlertDialogCance$state {
};
readonly props = $derived.by(
() =>
({
() => {
const props = {
id: this.opts.id.current,
'aria-label': this.root.getActionLabel('cancel'),
[this.root.getTerraAttr("cancel")]: "",
onclick: this.onclick,
onkeydown: this.onkeydown,
tabindex: 0,
...this.root.sharedProps,
...this.attachment,
}) as const
} as const;
assertDataContract('AlertDialog', 'cancel', props);
return props;
}
);
}

@ -100,14 +100,19 @@ export class EditableRootState {
// ── Props ─────────────────────────────────────────────────────────────────
readonly props = $derived.by(() => ({
id: this.opts.id.current,
'data-editing': boolToEmptyStrOrUndef(this.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
[editableAttrs.root]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-editing': boolToEmptyStrOrUndef(this.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
[editableAttrs.root]: '',
...this.attachment,
} as const;
assertDataContract('Editable', 'root', props);
return props;
});
}
// ─── EditableAreaState ────────────────────────────────────────────────────────
@ -129,11 +134,16 @@ export class EditableAreaState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
[editableAttrs.area]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[editableAttrs.area]: '',
...this.attachment,
} as const;
assertDataContract('Editable', 'area', props);
return props;
});
}
// ─── EditablePreviewState ─────────────────────────────────────────────────────
@ -167,17 +177,22 @@ export class EditablePreviewState {
this.root.startEdit();
};
readonly props = $derived.by(() => ({
id: this.opts.id.current,
hidden: boolToTrueOrUndef(this.root.isEditing),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.root.isReadonly),
[editableAttrs.preview]: '',
onclick: this.root.isDisabled || this.root.isReadonly ? undefined : this.onclick,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
hidden: boolToTrueOrUndef(this.root.isEditing),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.root.isReadonly),
[editableAttrs.preview]: '',
onclick: this.root.isDisabled || this.root.isReadonly ? undefined : this.onclick,
...this.attachment,
} as const;
assertDataContract('Editable', 'preview', props);
return props;
});
}
// ─── EditableInputState ───────────────────────────────────────────────────────
@ -230,21 +245,26 @@ export class EditableInputState {
}
};
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'text' as const,
hidden: boolToTrueOrUndef(!this.root.isEditing),
value: this.root.draft,
disabled: this.root.isDisabled || undefined,
readonly: this.root.isReadonly || undefined,
maxlength: this.root.opts.maxLength.current ?? undefined,
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
[editableAttrs.input]: '',
oninput: this.oninput,
onkeydown: this.onkeydown,
onblur: this.onblur,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: 'text' as const,
hidden: boolToTrueOrUndef(!this.root.isEditing),
value: this.root.draft,
disabled: this.root.isDisabled || undefined,
readonly: this.root.isReadonly || undefined,
maxlength: this.root.opts.maxLength.current ?? undefined,
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
[editableAttrs.input]: '',
oninput: this.oninput,
onkeydown: this.onkeydown,
onblur: this.onblur,
...this.attachment,
} as const;
assertDataContract('Editable', 'input', props);
return props;
});
}
// ─── EditableEditTriggerState ─────────────────────────────────────────────────
@ -272,16 +292,21 @@ export class EditableEditTriggerState {
onclick = (): void => { this.root.startEdit(); };
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'button' as const,
disabled: boolToTrueOrUndef(this.isDisabled),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[editableAttrs['edit-trigger']]: '',
onclick: this.onclick,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: 'button' as const,
disabled: boolToTrueOrUndef(this.isDisabled),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[editableAttrs['edit-trigger']]: '',
onclick: this.onclick,
...this.attachment,
} as const;
assertDataContract('Editable', 'edit-trigger', props);
return props;
});
}
// ─── EditableSubmitTriggerState ───────────────────────────────────────────────
@ -309,16 +334,21 @@ export class EditableSubmitTriggerState {
onclick = (): void => { this.root.submit(); };
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'button' as const,
disabled: boolToTrueOrUndef(this.isDisabled),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[editableAttrs['submit-trigger']]: '',
onclick: this.onclick,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: 'button' as const,
disabled: boolToTrueOrUndef(this.isDisabled),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[editableAttrs['submit-trigger']]: '',
onclick: this.onclick,
...this.attachment,
} as const;
assertDataContract('Editable', 'submit-trigger', props);
return props;
});
}
// ─── EditableCancelTriggerState ───────────────────────────────────────────────
@ -346,14 +376,19 @@ export class EditableCancelTriggerState {
onclick = (): void => { this.root.cancel(); };
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'button' as const,
disabled: boolToTrueOrUndef(this.isDisabled),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[editableAttrs['cancel-trigger']]: '',
onclick: this.onclick,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: 'button' as const,
disabled: boolToTrueOrUndef(this.isDisabled),
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[editableAttrs['cancel-trigger']]: '',
onclick: this.onclick,
...this.attachment,
} as const;
assertDataContract('Editable', 'cancel-trigger', props);
return props;
});
}

@ -4,6 +4,7 @@ import { attachRef } from '../utils';
import { boolToEmptyStrOrUndef, boolToStr, createTerraAttrs } from '$terra/utils/attrs';
import { FormFieldState } from '../form/form.svelte';
import type { FieldInputType, FieldState } from './types';
import { assertDataContract } from '$terra/utils/contracts';
const fieldAttrs = createTerraAttrs({
component: 'field',
@ -91,9 +92,8 @@ export class FieldRootState {
isRequired: this.isRequired,
}));
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-state': this.effectiveState,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
@ -103,8 +103,10 @@ export class FieldRootState {
'data-pending': boolToEmptyStrOrUndef(this.isPending),
[fieldAttrs.root]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'root', props);
return props;
});
}
interface FieldLabelStateOpts extends WithRefOpts {}
@ -124,9 +126,8 @@ export class FieldLabelState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.root.labelId,
for: this.root.inputId,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
@ -134,8 +135,10 @@ export class FieldLabelState {
'data-required': boolToEmptyStrOrUndef(this.root.isRequired),
[fieldAttrs.label]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'label', props);
return props;
});
}
interface FieldControlStateOpts extends WithRefOpts {}
@ -155,16 +158,17 @@ export class FieldControlState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-state': this.root.effectiveState,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
[fieldAttrs.control]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'control', props);
return props;
});
}
interface FieldInputStateOpts
@ -202,9 +206,8 @@ export class FieldInputState {
this.opts.onValueChange.current(value);
};
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.root.inputId,
name: this.fieldName,
type: this.opts.type.current,
@ -220,8 +223,10 @@ export class FieldInputState {
oninput: this.oninput,
[fieldAttrs.input]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'input', props);
return props;
});
}
interface FieldHelperTextStateOpts extends WithRefOpts {}
@ -241,15 +246,16 @@ export class FieldHelperTextState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.root.helperId,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
[fieldAttrs['helper-text']]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'helper-text', props);
return props;
});
}
interface FieldErrorStateOpts extends WithRefOpts {}
@ -269,17 +275,18 @@ export class FieldErrorState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.root.errorId,
role: 'alert' as const,
'aria-live': 'polite' as const,
'data-error': '',
[fieldAttrs.error]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'error', props);
return props;
});
readonly ghostProps = $derived.by(
() =>
@ -308,15 +315,16 @@ export class FieldPrefixState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'aria-hidden': 'true' as const,
[fieldAttrs.prefix]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'prefix', props);
return props;
});
}
interface FieldSuffixStateOpts extends WithRefOpts {}
@ -336,13 +344,14 @@ export class FieldSuffixState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'aria-hidden': 'true' as const,
[fieldAttrs.suffix]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Field', 'suffix', props);
return props;
});
}

@ -111,18 +111,20 @@ export class FieldLabelState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
for: this.root.inputId,
"data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled),
"data-error": boolToEmptyStrOrUndef(this.root.isError),
"data-required": boolToEmptyStrOrUndef(this.root.isRequired),
[fieldAttrs.label]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = ({
id: this.opts.id.current,
for: this.root.inputId,
"data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled),
"data-error": boolToEmptyStrOrUndef(this.root.isError),
"data-required": boolToEmptyStrOrUndef(this.root.isRequired),
[fieldAttrs.label]: "",
...this.attachment,
}) as const;
assertDataContract('Field', 'label', props);
return props;
});
}
// ─── Control ──────────────────────────────────────────────────────────────────
@ -144,16 +146,18 @@ export class FieldControlState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"data-state": this.root.opts.state.current,
"data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled),
[fieldAttrs.control]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = ({
id: this.opts.id.current,
"data-state": this.root.opts.state.current,
"data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled),
[fieldAttrs.control]: "",
...this.attachment,
}) as const;
assertDataContract('Field', 'control', props);
return props;
});
}
// ─── Input ────────────────────────────────────────────────────────────────────
@ -197,24 +201,26 @@ export class FieldInputState {
this.opts.onValueChange.current(value);
};
readonly props = $derived.by(
() =>
({
id: this.root.inputId,
type: this.opts.type.current,
value: this.opts.value.current,
disabled: this.root.isDisabled,
readonly: this.root.isReadonly,
required: this.root.isRequired,
"aria-invalid": boolToStr(this.root.isError),
"aria-describedby": this.describedBy || undefined,
"aria-busy": boolToStr(this.root.isLoading),
"data-state": this.root.opts.state.current,
oninput: this.oninput,
[fieldAttrs.input]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = ({
id: this.root.inputId,
type: this.opts.type.current,
value: this.opts.value.current,
disabled: this.root.isDisabled,
readonly: this.root.isReadonly,
required: this.root.isRequired,
"aria-invalid": boolToStr(this.root.isError),
"aria-describedby": this.describedBy || undefined,
"aria-busy": boolToStr(this.root.isLoading),
"data-state": this.root.opts.state.current,
oninput: this.oninput,
[fieldAttrs.input]: "",
...this.attachment,
}) as const;
assertDataContract('Field', 'input', props);
return props;
});
}
// ─── HelperText ───────────────────────────────────────────────────────────────
@ -236,15 +242,17 @@ export class FieldHelperTextState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(
() =>
({
id: this.root.helperId,
"data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled),
[fieldAttrs["helper-text"]]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = ({
id: this.root.helperId,
"data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled),
[fieldAttrs["helper-text"]]: "",
...this.attachment,
}) as const;
assertDataContract('Field', 'helper-text', props);
return props;
});
}
// ─── Error ────────────────────────────────────────────────────────────────────
@ -266,17 +274,19 @@ export class FieldErrorState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(
() =>
({
id: this.root.errorId,
role: "alert" as const,
"aria-live": "polite" as const,
"data-error": "",
[fieldAttrs.error]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = ({
id: this.root.errorId,
role: "alert" as const,
"aria-live": "polite" as const,
"data-error": "",
[fieldAttrs.error]: "",
...this.attachment,
}) as const;
assertDataContract('Field', 'error', props);
return props;
});
// El nodo fantasma mantiene el id en el DOM cuando no hay error
readonly ghostProps = $derived.by(
@ -308,15 +318,17 @@ export class FieldPrefixState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"aria-hidden": "true" as const,
[fieldAttrs.prefix]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = ({
id: this.opts.id.current,
"aria-hidden": "true" as const,
[fieldAttrs.prefix]: "",
...this.attachment,
}) as const;
assertDataContract('Field', 'prefix', props);
return props;
});
}
// ─── Suffix ───────────────────────────────────────────────────────────────────
@ -338,14 +350,16 @@ export class FieldSuffixState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
"aria-hidden": "true" as const,
[fieldAttrs.suffix]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = ({
id: this.opts.id.current,
"aria-hidden": "true" as const,
[fieldAttrs.suffix]: "",
...this.attachment,
}) as const;
assertDataContract('Field', 'suffix', props);
return props;
});
}

@ -1,9 +1,11 @@
import { createTerraContext } from '$terra/utils/context';
import { getTerraConfig } from '../config/terra-config';
import { resolveTerraTranslation } from '../config/translator.svelte';
import { attachRef } from '$terra/utils';
import type { Active, ActiveProps, StateProps, WithRefOpts, RefAttachment, OnChangeFn } from '$terra/utils';
import { boolToEmptyStrOrUndef, createTerraAttrs } from '../utils/attrs';
import { assertDataContract } from "$terra/utils/contracts";
import { assertDataContract } from '$terra/utils/contracts';
// ─── Domain Types ─────────────────────────────────────────────────────────────
export type FileRejectionReason = 'too-large' | 'wrong-type' | 'too-many';
@ -67,6 +69,7 @@ export class FileUploadRootState {
}
readonly opts: FileUploadRootStateOpts;
readonly config = getTerraConfig();
readonly attachment: RefAttachment;
private constructor(opts: FileUploadRootStateOpts) {
@ -74,6 +77,10 @@ export class FileUploadRootState {
this.attachment = attachRef(opts.ref);
}
translate(value: string, fallback = '') {
return resolveTerraTranslation(value, this.config.translator.current, fallback);
}
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly accept = $derived.by(() => this.opts.accept.current);
readonly maxSize = $derived.by(() => this.opts.maxSize.current);
@ -137,12 +144,18 @@ export class FileUploadRootState {
this.opts.onFilesChange.current?.([]);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[fileUploadAttrs.root]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[fileUploadAttrs.root]: '',
...this.attachment,
} as const;
assertDataContract('FileUpload', 'root', props);
return props;
});
}
// ─── FileUploadDropzoneState ──────────────────────────────────────────────────
@ -198,20 +211,27 @@ export class FileUploadDropzoneState {
this.root.addFiles(e.dataTransfer?.files ?? null);
};
readonly props = $derived.by(() => ({
id: this.opts.id.current,
role: 'button' as const,
tabindex: this.root.isDisabled ? -1 : 0,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.dragging),
[fileUploadAttrs.dropzone]: '',
onclick: this.onclick,
ondragenter: this.onDragEnter,
ondragover: this.onDragOver,
ondragleave: this.onDragLeave,
ondrop: this.onDrop,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'button' as const,
'aria-label': this.root.translate('#?terra.fileUpload.dropzone|Upload files', 'Upload files'),
tabindex: this.root.isDisabled ? -1 : 0,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.dragging),
[fileUploadAttrs.dropzone]: '',
onclick: this.onclick,
ondragenter: this.onDragEnter,
ondragover: this.onDragOver,
ondragleave: this.onDragLeave,
ondrop: this.onDrop,
...this.attachment,
} as const;
assertDataContract('FileUpload', 'dropzone', props);
return props;
});
}
// ─── FileUploadFileListState ──────────────────────────────────────────────────
@ -233,11 +253,17 @@ export class FileUploadFileListState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
[fileUploadAttrs['file-list']]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[fileUploadAttrs['file-list']]: '',
...this.attachment,
} as const;
assertDataContract('FileUpload', 'file-list', props);
return props;
});
}
// ─── FileUploadItemState ──────────────────────────────────────────────────────
@ -268,12 +294,18 @@ export class FileUploadItemState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
'data-file-name': this.opts.file.current.name,
[fileUploadAttrs.item]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-file-name': this.opts.file.current.name,
[fileUploadAttrs.item]: '',
...this.attachment,
} as const;
assertDataContract('FileUpload', 'item', props);
return props;
});
readonly snippetProps = $derived.by(() => ({
file: this.opts.file.current,
@ -300,11 +332,17 @@ export class FileUploadItemNameState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
[fileUploadAttrs['item-name']]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[fileUploadAttrs['item-name']]: '',
...this.attachment,
} as const;
assertDataContract('FileUpload', 'item-name', props);
return props;
});
}
// ─── FileUploadItemSizeState ──────────────────────────────────────────────────
@ -326,11 +364,17 @@ export class FileUploadItemSizeState {
this.attachment = attachRef(opts.ref);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
[fileUploadAttrs['item-size']]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[fileUploadAttrs['item-size']]: '',
...this.attachment,
} as const;
assertDataContract('FileUpload', 'item-size', props);
return props;
});
}
// ─── FileUploadItemRemoveState ────────────────────────────────────────────────
@ -358,17 +402,23 @@ export class FileUploadItemRemoveState {
this.item.root.removeFile(this.item.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.item.opts.file.current.name}`,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[fileUploadAttrs['item-remove']]: '',
onclick: this.onclick,
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: 'button' as const,
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label': `${this.item.root.translate('#?terra.fileUpload.removeFile|Remove file', 'Remove file')}: ${this.item.opts.file.current.name}`,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
[fileUploadAttrs['item-remove']]: '',
onclick: this.onclick,
...this.attachment,
} as const;
assertDataContract('FileUpload', 'item-remove', props);
return props;
});
}
// ─── FileUploadItemProgressState ─────────────────────────────────────────────
@ -396,13 +446,19 @@ export class FileUploadItemProgressState {
Math.min(100, Math.max(0, this.opts.value.current)),
);
readonly props = $derived.by(() => ({
id: this.opts.id.current,
role: 'progressbar' as const,
'aria-valuenow': this.percent,
'aria-valuemin': 0,
'aria-valuemax': 100,
[fileUploadAttrs['item-progress']]: '',
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'progressbar' as const,
'aria-valuenow': this.percent,
'aria-valuemin': 0,
'aria-valuemax': 100,
[fileUploadAttrs['item-progress']]: '',
...this.attachment,
} as const;
assertDataContract('FileUpload', 'item-progress', props);
return props;
});
}

@ -41,9 +41,8 @@ export class FormRootState {
isPending: this.isPending,
}));
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'aria-busy': boolToStr(this.isPending),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
@ -51,8 +50,10 @@ export class FormRootState {
'data-pending': boolToEmptyStrOrUndef(this.isPending),
[formAttrs.root]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Form', 'root', props);
return props;
});
}
interface FormFieldStateOpts
@ -110,9 +111,8 @@ export class FormFieldState {
labelId: this.labelId,
}));
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
@ -121,8 +121,10 @@ export class FormFieldState {
'data-pending': boolToEmptyStrOrUndef(this.isPending),
[formAttrs.field]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Form', 'field', props);
return props;
});
readonly controlProps = $derived.by(
() =>
@ -169,9 +171,8 @@ export class FormLabelState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.field.labelId,
for: this.field.controlId,
'data-invalid': boolToEmptyStrOrUndef(this.field.isInvalid),
@ -179,8 +180,10 @@ export class FormLabelState {
'data-required': boolToEmptyStrOrUndef(this.field.isRequired),
[formAttrs.label]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Form', 'label', props);
return props;
});
}
export class FormDescriptionState {
@ -198,15 +201,16 @@ export class FormDescriptionState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.field.descriptionId,
'data-disabled': boolToEmptyStrOrUndef(this.field.isDisabled),
[formAttrs.description]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Form', 'description', props);
return props;
});
}
export class FormErrorState {
@ -224,16 +228,17 @@ export class FormErrorState {
this.attachment = attachRef(opts.ref as any);
}
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.field.errorId,
'aria-live': 'polite',
'data-invalid': boolToEmptyStrOrUndef(this.field.isInvalid),
[formAttrs.error]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Form', 'error', props);
return props;
});
}
export class FormControlState {
@ -251,14 +256,15 @@ export class FormControlState {
this.attachment = attachRef(opts.ref as any);
}
readonly wrapperProps = $derived.by(
() =>
({
readonly wrapperProps = $derived.by(() => {
const props = {
id: this.opts.id.current,
...this.field.controlWrapperProps,
...this.attachment,
}) as const
);
} as const;
assertDataContract('Form', 'control', props);
return props;
});
readonly controlProps = $derived.by(() => this.field.controlProps);
}
@ -280,9 +286,8 @@ export class FormSubmitState {
readonly isDisabled = $derived.by(() => this.root.isDisabled || this.root.isPending);
readonly props = $derived.by(
() =>
({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
type: 'submit',
disabled: this.isDisabled || undefined,
@ -291,6 +296,8 @@ export class FormSubmitState {
'data-pending': boolToEmptyStrOrUndef(this.root.isPending),
[formAttrs.submit]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Form', 'submit', props);
return props;
});
}

@ -1,8 +1,8 @@
/**
* terra <EFBFBD> Active Elements
* terra
*
* Librer<EFBFBD>a de componentes UI con integraci<EFBFBD>n nativa a actx.
* Sin dependencias externas, refactorizada desde uisv.
* Primitives headless para UI, accesibilidad y composicion.
* Se integra con el ecosistema por adapters/config, sin acoplarse a actx.
*/
export * as AspectRatio from './aspect-ratio';
@ -28,6 +28,7 @@ export * as NumberField from './number-field';
export * as TagsInput from './tags-input';
export * as Editable from './editable';
export * as Stepper from './stepper';
export { default as Trans } from './trans';
export * as Splitter from './splitter';
export * as FileUpload from './file-upload';
export { formatBytes } from './file-upload';
@ -64,6 +65,14 @@ export type {
TerraPresentationProps,
TerraDatePresentationProps,
TerraDateTimePresentationProps,
TerraTranslator,
TerraTranslationModule,
TerraTranslationValue,
TerraDateTimeFormatter,
TerraNumberFormatter,
TerraCurrencyFormatter,
TerraUnitFormatter,
TerraLogger,
} from './config/exports';
export * as Calendar from './calendar';

@ -1,7 +1,8 @@
import { attachRef } from '$terra/utils';
import type { RefAttachment, WithRefOpts } from '$terra/utils';
import { createTerraAttrs } from "../utils/attrs";
import type { TerraMouseEvent } from '$terra/utils';
import { createTerraAttrs } from '../utils/attrs';
import type { TerraMouseEvent } from '$terra/utils';
import { assertDataContract } from '$terra/utils/contracts';
const labelAttrs = createTerraAttrs({
component: "label",
@ -26,11 +27,17 @@ export class LabelRootState {
if (e.detail > 1) e.preventDefault();
};
readonly props = $derived.by(() => ({
id: this.opts.id.current,
onmousedown: this.onmousedown,
[labelAttrs.root]: "",
...this.attachment,
}) as const);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
onmousedown: this.onmousedown,
[labelAttrs.root]: '',
...this.attachment,
} as const;
assertDataContract('Label', 'root', props);
return props;
});
}

@ -3,7 +3,7 @@ import { getTerraConfig } from '../../config/terra-config.ts';
export function getDirection(): Active<Direction> {
const config = getTerraConfig();
return readableActive(() => config.defaultDir.current ?? 'ltr');
return readableActive(() => config.dir.current ?? 'ltr');
}
export function useDirection(dir?: Active<Direction | undefined>): Active<Direction> {

@ -47,8 +47,6 @@ export type Boundary = Element | null;
export class FloatingRootState {
static readonly ctx = createTerraContext<FloatingRootState>('FloatingRootState');
static readonly ctx = createTerraContext<FloatingRootState>('FloatingRootState');
static create(tooltip = false) {
return tooltip
? FloatingRootState.ctx.set(new FloatingRootState())

@ -25,7 +25,7 @@ import { createTerraContext } from '$terra/utils/context';
import { on } from 'svelte/events';
import { boolToStr, createTerraAttrs, getDataOpenClosed } from '$terra/utils/attrs';
import { assertDataContract } from "$terra/utils/contracts";
import { assertDataContract } from '$terra/utils/contracts';
const linkPreviewAttrs = createTerraAttrs({
component: 'link-preview',
parts: ['content', 'trigger'] as const,
@ -188,23 +188,25 @@ export class LinkPreviewTriggerState {
this.root.handleClose();
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
'aria-haspopup': 'dialog',
'aria-expanded': boolToStr(this.root.opts.open.current),
'data-state': getDataOpenClosed(this.root.opts.open.current),
'aria-controls': this.root.contentNode?.id,
role: 'button',
[linkPreviewAttrs.trigger]: '',
onpointerenter: this.onpointerenter,
onfocus: this.onfocus,
onblur: this.onblur,
onpointerleave: this.onpointerleave,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
'aria-haspopup': 'dialog',
'aria-expanded': boolToStr(this.root.opts.open.current),
'data-state': getDataOpenClosed(this.root.opts.open.current),
'aria-controls': this.root.contentNode?.id,
role: 'button',
[linkPreviewAttrs.trigger]: '',
onpointerenter: this.onpointerenter,
onfocus: this.onfocus,
onblur: this.onblur,
onpointerleave: this.onpointerleave,
...this.attachment,
} as const;
assertDataContract('LinkPreview', 'trigger', props);
return props;
});
}
interface LinkPreviewContentStateOpts
@ -289,19 +291,21 @@ export class LinkPreviewContentState {
readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
tabindex: -1,
'data-state': getDataOpenClosed(this.root.opts.open.current),
[linkPreviewAttrs.content]: '',
onpointerdown: this.onpointerdown,
onpointerenter: this.onpointerenter,
onfocusout: this.onfocusout,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
tabindex: -1,
'data-state': getDataOpenClosed(this.root.opts.open.current),
[linkPreviewAttrs.content]: '',
onpointerdown: this.onpointerdown,
onpointerenter: this.onpointerenter,
onfocusout: this.onfocusout,
...this.attachment,
} as const;
assertDataContract('LinkPreview', 'content', props);
return props;
});
readonly popperProps = {
onInteractOutside: this.onInteractOutside,

@ -9,7 +9,8 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from "$terra/utils";
import type { MenuSubContentProps } from '../types';
import { MenuContentState } from '../menu.svelte';
import { MenuContentState, getMenuContractNameFromVariant } from '../menu.svelte';
import { assertDataContract } from '$terra/utils/contracts';
import { SUB_CLOSE_KEYS } from '../utils';
import { createId } from '$terra/utils';
import PopperLayer from '../../layers/popper/popper-layer.svelte';
@ -141,3 +142,4 @@
{/snippet}
</PopperLayer>

@ -43,7 +43,8 @@ import {
boolToEmptyStrOrUndef,
} from "../utils/attrs";
import { assertDataContract } from "$terra/utils/contracts";import type { Direction } from '$terra/utils';
import { assertDataContract } from '$terra/utils/contracts';
import type { Direction } from '$terra/utils';
import { IsUsingKeyboard } from "../utils/keyboard/is-using-keyboard.svelte";
import { getTabbableFrom } from "../dom/focus/tabbable";
import { isTabbable } from "tabbable";
@ -54,12 +55,22 @@ import { SafePolygon } from "../utils";
import { PresenceManager } from "../layers/presence/presence-manager.svelte";
import { arraysAreEqual } from "$lib/util/arrays";
export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger";
export const CONTEXT_MENU_CONTENT_ATTR = "data-context-menu-content";
const MenuGroupContext = createTerraContext<MenuGroupState | MenuRadioGroupState>(
"Menu.Group | Menu.RadioGroup"
);
type MenuVariant = "context-menu" | "dropdown-menu" | "menubar";
type MenuVariant = 'context-menu' | 'dropdown-menu' | 'menubar';
type MenuContractName = 'ContextMenu' | 'DropdownMenu' | 'Menubar';
export function getMenuContractNameFromVariant(variant: MenuVariant): MenuContractName {
switch (variant) {
case 'context-menu':
return 'ContextMenu';
case 'menubar':
return 'Menubar';
default:
return 'DropdownMenu';
}
}
export interface MenuRootStateOpts
extends ActiveProps<{
@ -93,6 +104,9 @@ export const menuAttrs = createTerraAttrs({
] as const,
});
export const CONTEXT_MENU_TRIGGER_ATTR = menuAttrs.getAttr("trigger", "context-menu");
export const CONTEXT_MENU_CONTENT_ATTR = menuAttrs.getAttr("content", "context-menu");
export class MenuRootState {
static readonly ctx = createTerraContext<MenuRootState>('MenuRootState');
@ -405,24 +419,31 @@ export class MenuContentState {
readonly snippetProps = $derived.by(() => ({ open: this.parentMenu.opts.open.current }));
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "menu",
"aria-orientation": "vertical" as const,
[this.parentMenu.root.getTerraAttr("content")]: "",
"data-state": getDataOpenClosed(this.parentMenu.opts.open.current),
onkeydown: this.onkeydown,
onblur: this.onblur,
onfocus: this.onfocus,
style: {
pointerEvents: "auto",
contain: "layout style",
},
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'menu',
'aria-orientation': 'vertical' as const,
[this.parentMenu.root.getTerraAttr('content')]: '',
'data-state': getDataOpenClosed(this.parentMenu.opts.open.current),
onkeydown: this.onkeydown,
onblur: this.onblur,
onfocus: this.onfocus,
style: {
pointerEvents: 'auto',
contain: 'layout style',
},
...this.attachment,
} as const;
if (!this.isSub) {
assertDataContract(
getMenuContractNameFromVariant(this.parentMenu.root.opts.variant.current),
'content',
props
);
}
return props;
});
readonly popperProps = {
onCloseAutoFocus: (e: Event) => this.onCloseAutoFocus(e),
@ -484,23 +505,28 @@ class MenuItemSharedState {
});
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
tabindex: -1,
role: "menuitem",
"aria-disabled": boolToStr(this.opts.disabled.current),
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
"data-highlighted": boolToEmptyStrOrUndef(this.isFocused),
[this.content.parentMenu.root.getTerraAttr("item")]: "",
onpointermove: this.onpointermove,
onpointerleave: this.onpointerleave,
onfocus: this.onfocus,
onblur: this.onblur,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
tabindex: -1,
role: 'menuitem',
'aria-disabled': boolToStr(this.opts.disabled.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-highlighted': boolToEmptyStrOrUndef(this.isFocused),
[this.content.parentMenu.root.getTerraAttr('item')]: '',
onpointermove: this.onpointermove,
onpointerleave: this.onpointerleave,
onfocus: this.onfocus,
onblur: this.onblur,
...this.attachment,
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.content.parentMenu.root.opts.variant.current),
'item',
props
);
return props;
});
}
type MenuItemCombinedProps = MenuItemSharedStateOpts & MenuItemStateOpts;
@ -763,19 +789,24 @@ export class MenuCheckboxItemState {
indeterminate: this.opts.indeterminate.current,
}));
readonly props = $derived.by(
() =>
({
...this.item.props,
role: "menuitemcheckbox",
"aria-checked": getAriaChecked(
this.opts.checked.current,
this.opts.indeterminate.current
),
"data-state": getCheckedState(this.opts.checked.current),
[this.item.root.getTerraAttr("checkbox-item")]: "",
}) as const
);
readonly props = $derived.by(() => {
const props = {
...this.item.props,
role: 'menuitemcheckbox',
'aria-checked': getAriaChecked(
this.opts.checked.current,
this.opts.indeterminate.current
),
'data-state': getCheckedState(this.opts.checked.current),
[this.item.root.getTerraAttr('checkbox-item')]: '',
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.item.root.opts.variant.current),
'checkbox-item',
props
);
return props;
});
}
// ─── Group ────────────────────────────────────────────────────────────────
@ -798,16 +829,21 @@ export class MenuGroupState {
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "group",
"aria-labelledby": this.groupHeadingId,
[this.root.getTerraAttr("group")]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'group',
'aria-labelledby': this.groupHeadingId,
[this.root.getTerraAttr('group')]: '',
...this.attachment,
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.root.opts.variant.current),
'group',
props
);
return props;
});
}
// ─── Group heading ────────────────────────────────────────────────────────
@ -836,15 +872,20 @@ export class MenuGroupHeadingState {
this.attachment = attachRef(this.opts.ref, (v) => (this.group.groupHeadingId = v?.id));
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "group",
[this.group.root.getTerraAttr("group-heading")]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'group',
[this.group.root.getTerraAttr('group-heading')]: '',
...this.attachment,
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.group.root.opts.variant.current),
'group-heading',
props
);
return props;
});
}
// ─── Separator ────────────────────────────────────────────────────────────
@ -866,15 +907,20 @@ export class MenuSeparatorState {
this.attachment = attachRef(this.opts.ref);
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
role: "separator",
[this.root.getTerraAttr("separator")]: "",
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: 'separator',
[this.root.getTerraAttr('separator')]: '',
...this.attachment,
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.root.opts.variant.current),
'separator',
props
);
return props;
});
}
// ─── Arrow ────────────────────────────────────────────────────────────────
@ -890,12 +936,17 @@ export class MenuArrowState {
this.root = root;
}
readonly props = $derived.by(
() =>
({
[this.root.getTerraAttr("arrow")]: "",
}) as const
);
readonly props = $derived.by(() => {
const props = {
[this.root.getTerraAttr('arrow')]: '',
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.root.opts.variant.current),
'arrow',
props
);
return props;
});
}
// ─── Radio group ──────────────────────────────────────────────────────────
@ -932,16 +983,21 @@ export class MenuRadioGroupState {
this.opts.value.current = v;
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
[this.root.getTerraAttr("radio-group")]: "",
role: "group",
"aria-labelledby": this.groupHeadingId,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[this.root.getTerraAttr('radio-group')]: '',
role: 'group',
'aria-labelledby': this.groupHeadingId,
...this.attachment,
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.root.opts.variant.current),
'radio-group',
props
);
return props;
});
}
// ─── Radio item ───────────────────────────────────────────────────────────
@ -980,17 +1036,22 @@ export class MenuRadioItemState {
this.group.setValue(this.opts.value.current);
}
readonly props = $derived.by(
() =>
({
[this.group.root.getTerraAttr("radio-item")]: "",
...this.item.props,
role: "menuitemradio",
"aria-checked": getAriaChecked(this.isChecked, false),
"data-state": getCheckedState(this.isChecked),
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
[this.group.root.getTerraAttr('radio-item')]: '',
...this.item.props,
role: 'menuitemradio',
'aria-checked': getAriaChecked(this.isChecked, false),
'data-state': getCheckedState(this.isChecked),
...this.attachment,
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.group.root.opts.variant.current),
'radio-item',
props
);
return props;
});
}
// ─── Dropdown menu trigger ────────────────────────────────────────────────
@ -1058,24 +1119,25 @@ export class DropdownMenuTriggerState {
return undefined;
});
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
disabled: this.opts.disabled.current,
"aria-haspopup": "menu",
"aria-expanded": boolToStr(this.parentMenu.opts.open.current),
"aria-controls": this.ariaControls,
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
"data-state": getDataOpenClosed(this.parentMenu.opts.open.current),
[this.parentMenu.root.getTerraAttr("trigger")]: "",
onclick: this.onclick,
onpointerdown: this.onpointerdown,
onpointerup: this.onpointerup,
onkeydown: this.onkeydown,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
disabled: this.opts.disabled.current,
'aria-haspopup': 'menu',
'aria-expanded': boolToStr(this.parentMenu.opts.open.current),
'aria-controls': this.ariaControls,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-state': getDataOpenClosed(this.parentMenu.opts.open.current),
[this.parentMenu.root.getTerraAttr('trigger')]: '',
onclick: this.onclick,
onpointerdown: this.onpointerdown,
onpointerup: this.onpointerup,
onkeydown: this.onkeydown,
...this.attachment,
} as const;
assertDataContract('DropdownMenu', 'trigger', props);
return props;
});
}
// ─── Context menu trigger ─────────────────────────────────────────────────
@ -1168,23 +1230,24 @@ export class ContextMenuTriggerState {
this.clearLongPressTimer();
};
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
disabled: this.opts.disabled.current,
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
"data-state": getDataOpenClosed(this.parentMenu.opts.open.current),
[CONTEXT_MENU_TRIGGER_ATTR]: "",
tabindex: -1,
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointercancel: this.onpointercancel,
onpointerup: this.onpointerup,
oncontextmenu: this.oncontextmenu,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
disabled: this.opts.disabled.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-state': getDataOpenClosed(this.parentMenu.opts.open.current),
[CONTEXT_MENU_TRIGGER_ATTR]: '',
tabindex: -1,
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointercancel: this.onpointercancel,
onpointerup: this.onpointerup,
oncontextmenu: this.oncontextmenu,
...this.attachment,
} as const;
assertDataContract('ContextMenu', 'trigger', props);
return props;
});
}
// ─── Checkbox group ───────────────────────────────────────────────────────
@ -1240,16 +1303,21 @@ export class MenuCheckboxGroupState {
this.opts.onValueChange.current(newValue);
}
readonly props = $derived.by(
() =>
({
id: this.opts.id.current,
[this.root.getTerraAttr("checkbox-group")]: "",
role: "group",
"aria-labelledby": this.groupHeadingId,
...this.attachment,
}) as const
);
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
[this.root.getTerraAttr('checkbox-group')]: '',
role: 'group',
'aria-labelledby': this.groupHeadingId,
...this.attachment,
} as const;
assertDataContract(
getMenuContractNameFromVariant(this.root.opts.variant.current),
'checkbox-group',
props
);
return props;
});
}
// ─── Submenu ──────────────────────────────────────────────────────────────
@ -1261,3 +1329,5 @@ export class MenuSubmenuState {
}
}

@ -11,7 +11,8 @@ import { createTerraContext } from '$terra/utils/context';
import type { Direction } from '$terra/utils';
import { createTerraAttrs, boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef, getDataOpenClosed } from "../utils/attrs";
import { assertDataContract } from "$terra/utils/contracts";import { kbd } from "../utils/keyboard/kbd";
import { assertDataContract } from '$terra/utils/contracts';
import { kbd } from '../utils/keyboard/kbd';
import { wrapArray } from "$lib/util/arrays";
import type { OnChangeFn, RefAttachment, WithRefOpts } from '$terra/utils';
import { onMount } from "svelte";
@ -69,7 +70,7 @@ export class MenubarRootState {
updateValue = (value: string) => {
const currValue = this.opts.value.current;
const currHandler = this.valueToChangeHandler.get(currValue)?.current;
const currHandler = currValue !== undefined ? this.valueToChangeHandler.get(currValue)?.current : undefined;
const nextHandler = this.valueToChangeHandler.get(value)?.current;
this.opts.value.current = value;
if (currHandler && currValue !== value) currHandler(false);
@ -96,14 +97,16 @@ export class MenubarRootState {
this.updateValue(isSameMenu ? "" : id);
};
readonly props = $derived.by(
() => ({
readonly props = $derived.by(() => {
const props = {
id: this.opts.id.current,
role: "menubar",
[menubarAttrs.root]: "",
role: 'menubar',
[menubarAttrs.root]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Menubar', 'root', props);
return props;
});
}
// ── MenubarMenuState ──────────────────────────────────────────────────────────
@ -212,29 +215,31 @@ export class MenubarTriggerState {
onfocus: FocusEventHandler<HTMLElement> = () => { this.isFocused = true; };
onblur: FocusEventHandler<HTMLElement> = () => { this.isFocused = false; };
readonly props = $derived.by(
() => ({
type: "button",
role: "menuitem",
readonly props = $derived.by(() => {
const props = {
type: 'button',
role: 'menuitem',
id: this.opts.id.current,
"aria-haspopup": "menu",
"aria-expanded": boolToStr(this.menu.open.current),
"aria-controls": this.menu.open.current ? this.menu.contentId : undefined,
"data-highlighted": boolToEmptyStrOrUndef(this.isFocused),
"data-state": getDataOpenClosed(this.menu.open.current),
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
"data-menu-value": this.menu.opts.value.current,
'aria-haspopup': 'menu',
'aria-expanded': boolToStr(this.menu.open.current),
'aria-controls': this.menu.open.current ? this.menu.contentId : undefined,
'data-highlighted': boolToEmptyStrOrUndef(this.isFocused),
'data-state': getDataOpenClosed(this.menu.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-menu-value': this.menu.opts.value.current,
disabled: boolToTrueOrUndef(this.opts.disabled.current),
tabindex: this.tabIndex,
[menubarAttrs.trigger]: "",
[menubarAttrs.trigger]: '',
onpointerdown: this.onpointerdown,
onpointerenter: this.onpointerenter,
onkeydown: this.onkeydown,
onfocus: this.onfocus,
onblur: this.onblur,
...this.attachment,
}) as const
);
} as const;
assertDataContract('Menubar', 'trigger', props);
return props;
});
}
// ── MenubarContentState ───────────────────────────────────────────────────────
@ -290,8 +295,8 @@ export class MenubarContentState {
if (e.key !== kbd.ARROW_LEFT && e.key !== kbd.ARROW_RIGHT) return;
const target = e.target as HTMLElement;
const targetIsSubTrigger = target.hasAttribute("data-menu-sub-trigger");
const isKeydownInsideSubMenu = target.closest("[data-menu-content]") !== e.currentTarget;
const targetIsSubTrigger = target.hasAttribute("data-menubar-sub-trigger");
const isKeydownInsideSubMenu = target.closest("[data-menubar-content]") !== e.currentTarget;
const prevMenuKey = this.root.opts.dir.current === "rtl" ? kbd.ARROW_RIGHT : kbd.ARROW_LEFT;
const isPrevKey = prevMenuKey === e.key;
@ -318,17 +323,18 @@ export class MenubarContentState {
if (nextValue) this.menu.root.onMenuOpen(nextValue.value, nextValue.triggerId);
};
props = $derived.by(
() => ({
props = $derived.by(() => {
const props = {
id: this.opts.id.current,
"aria-labelledby": this.menu.triggerId,
style: getFloatingContentCSSVars("menubar"),
'aria-labelledby': this.menu.triggerId,
style: getFloatingContentCSSVars('menubar'),
onkeydown: this.onkeydown,
"data-menu-content": "",
[menubarAttrs.content]: "",
[menubarAttrs.content]: '',
...this.attachment,
}) as const
);
} as const;
assertDataContract('Menubar', 'content', props);
return props;
});
popperProps = {
onCloseAutoFocus: this.onCloseAutoFocus,
@ -338,3 +344,4 @@ export class MenubarContentState {
};
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.