diff --git a/.claude/settings.json b/.claude/settings.json index 0b2e5da9b..0b324070a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,84 +1,91 @@ { - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(npx svelte-check:*)", - "Bash(npx tsc:*)", - "Bash(grep -v \"^$\")", - "Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)", - "Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)", - "Bash(wc -l \"g:/dev/svelte/vicen/src/uix/uisv/lib/bits/calendar/components/\"*.svelte)", - "Bash(grep -n \"from \"\"@internationalized\\\\|from ''.*shared/date\\\\|from \"\".*shared/date\\\\|getDocument\\\\|isBrowser\\\\|styleToString\\\\|afterTick\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/utils.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/formatter.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/announcer.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts\")", - "Bash(grep -r from.*[''].*internal/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)", - "Bash(grep -r from.*[''].*shared/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)", - "Bash(grep -rh \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)", - "Bash(grep -v \"^//\")", - "Bash(grep -r \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)", - "Bash(grep -v '^\\\\s*//')", - "Bash(git -C g:/dev/svelte/vicen status --short -- src/uix/acel/date-field/)", - "Bash(npx tsx:*)", - "Bash(for file:*)", - "Bash(sed -i 's/: \\\\\\([^.]*\\\\\\) ? \"\"\"\" : undefined,/: boolToEmptyStrOrUndef\\(\\\\1\\),/g' \"$file\")", - "Bash(node -e \":*)", - "Bash(while read:*)", - "Bash(do if:*)", - "Bash(! grep:*)", - "Bash(sed -i 's/DatePickerRootContext/DatePickerRootState.ctx/g' \"$file\")", - "Bash(sed -i 's/DateRangePickerRootContext/DateRangePickerRootState.ctx/g' \"$file\")", - "Bash(sed -i 's/MenuCheckboxGroupContext/MenuCheckboxGroupState.ctx/g' \"$file\")", - "Bash(sed -i 's/ScrollAreaRootContext/ScrollAreaRootState.ctx/g' \"$file\")", - "Bash(echo \"Fixed: $file\")", - "Read(//g/dev/svelte/vicen - copia/**)", - "Bash(bash /tmp/fix-imports.sh)", - "Bash(sed -i 's/CalendarRootContext/CalendarRootState.ctx/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)", - "Bash(sed -i 's/CalendarRootState\\\\.ctx\\\\.get/CalendarRootState.ctx.get/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)", - "Bash(sed -i \"s/data-switch-root/data-switch/g\" contracts.ts)", - "Bash(sed -i \"s/data-checkbox-root/data-checkbox/g\" contracts.ts)", - "Bash(sed -i \"s/data-toggle-root/data-toggle/g\" contracts.ts)", - "Bash(sed -i \"s/data-toggle-group-root/data-toggle-group/g\" contracts.ts)", - "Bash(sed -i \"s/data-slider-root/data-slider/g\" contracts.ts)", - "Bash(xxd \"G:/dev/svelte/vicen - copia/src/uix/terra/date-picker/components/date-picker-calendar.svelte\")", - "Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] \\\\u2192 Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el campo est\\\\u00e1 deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)", - "Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] \\\\u2192 Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] \\\\u2192 Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el tag est\\\\u00e1 deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)", - "Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] -> Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el campo esta deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)", - "Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] -> Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] -> Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el tag esta deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)", - "Bash(sed -i '43s/->/\\\\u2192/; 44s/->/\\\\u2192/' tags-input-root.svelte)", - "Bash(sed -i '28s/->/\\\\u2192/; 29s/->/\\\\u2192/; 30s/->/\\\\u2192/' tags-input-tag.svelte)", - "Bash(sed -i 's/data-terra-dialog-content/data-dialog-content/g' contracts.ts)", - "Bash(sed -i 's/data-terra-dialog-trigger/data-dialog-trigger/g' contracts.ts)", - "Bash(sed -i 's/data-terra-dialog-overlay/data-dialog-overlay/g' contracts.ts)", - "Bash(sed -i 's/data-terra-dialog-title/data-dialog-title/g' contracts.ts)", - "Bash(sed -i 's/data-terra-dialog-description/data-dialog-description/g' contracts.ts)", - "Bash(sed -i 's/data-alert-dialog-content/data-dialog-content/g' contracts.ts)", - "Bash(sed -i 's/data-alert-dialog-trigger/data-dialog-trigger/g' contracts.ts)", - "Bash(sed -i 's/data-alert-dialog-overlay/data-dialog-overlay/g' contracts.ts)", - "Bash(sed -i 's/data-alert-dialog-title/data-dialog-title/g' contracts.ts)", - "Bash(sed -i 's/data-alert-dialog-description/data-dialog-description/g' contracts.ts)", - "Bash(sed -i 's/AlertDialog usa el mismo contrato que Dialog con variant=\"alert-dialog\"/AlertDialog es una variante de Dialog, usa los mismos attrs data-dialog-*/g' contracts.ts)", - "Bash(sed -i '/Los attrs se generan con data-alert-dialog-/d' contracts.ts)", - "Bash(python3)", - "Bash(pnpm run:*)", - "Bash(sed -i 's/
/
diff --git a/src/routes/test/soma/announce/+page.svelte b/src/routes/test/soma/announce/+page.svelte new file mode 100644 index 000000000..fed331931 --- /dev/null +++ b/src/routes/test/soma/announce/+page.svelte @@ -0,0 +1,326 @@ + + + + Announce · Soma + + +
+

Announce

+

+ Primitivo de live region — role=status / role=alert + + aria-live para anuncios a lectores de pantalla. API imperativa + announce(msg, priority, timeout) y regiones declarativas. +

+ +
+

1. Imperative API

+

+ Provider monta 2 regiones internas ocultas (polite + assertive) y expone + announce() via snippet. Abre DevTools accessibility tree para ver las + regiones. +

+
+ + + +
+ + + {#snippet children({ announce, clear })} +
+ + + + +
+ {/snippet} +
+ +
+
history (last 8)
+
+ {#if log.length === 0} + — + {:else} +
    + {#each log as entry (entry + Math.random())} +
  • {entry}
  • + {/each} +
+ {/if} +
+
+
+ +
+

2. Declarative Region (bind:message)

+

+ Region visible — útil cuando el anuncio también es UI. Cambia el texto y escucha con un + lector de pantalla. +

+
+ +
+ + +
+ +
+

3. Global API (createAnnouncer())

+

+ Sin Provider — monta regiones en document.body la primera vez. Úsalo desde + módulos utilitarios o cuando no quieras cargar Provider en la raíz. +

+
+ + + +
+
+
global history
+
+ {#if globalLog.length === 0} + — + {:else} +
    + {#each globalLog as entry, i (entry + i)} +
  • {entry}
  • + {/each} +
+ {/if} +
+
+
+ +
+

4. Role variants

+

+ Cada role tiene semántica distinta. Abre el accessibility tree: +

+
+ + + + + +
+
+
+ + diff --git a/src/routes/test/soma/avatar/+page.svelte b/src/routes/test/soma/avatar/+page.svelte new file mode 100644 index 000000000..f73c303ef --- /dev/null +++ b/src/routes/test/soma/avatar/+page.svelte @@ -0,0 +1,240 @@ + + + + Avatar · Soma + + +
+

Avatar

+

+ Preload <img> en background; muestra fallback hasta que el estado sea + loaded. delayMs evita flash en cargas rápidas. +

+ +
+

1. Interactive testbed

+
+ + +
+
+ + + +
+ + { + status = s; + statusLog = [...statusLog, s].slice(-8); + }} + class="avatar" + > + + JP + + +
+
status
+
{status}
+
transitions
+
{statusLog.join(' → ') || '—'}
+
+
+ +
+

2. Grid (variations)

+
+ + + JP + + + + + MX + + + + + + + + + + + diff --git a/src/routes/test/soma/clipboard/+page.svelte b/src/routes/test/soma/clipboard/+page.svelte new file mode 100644 index 000000000..4d5d94db4 --- /dev/null +++ b/src/routes/test/soma/clipboard/+page.svelte @@ -0,0 +1,198 @@ + + + + Clipboard · Soma + + +
+

Clipboard

+

+ Wrapper alrededor de navigator.clipboard.writeText. Flag + copied se auto-resetea en timeout ms. data-copied lo + expone en root/trigger/indicator. +

+ +
+

1. Interactive testbed

+
+ + +
+ + (copyCount += 1)} + onError={(err) => (lastError = String(err))} + class="cb" + > + {#snippet children({ copied })} + + {copied ? '✓ Copied!' : 'Copy'} + + Copied to clipboard + {/snippet} + + +
+
copies fired
+
{copyCount}x
+
last error
+
{lastError ?? '—'}
+
+ +

+ Enter / Space sobre el botón copia. + data-copied persiste durante {timeout}ms. +

+
+ +
+

2. Minimal (children as snippet)

+

El mismo valor, pero sin Indicator separado — consumer lee copied del snippet.

+ + {#snippet children({ copied, copy })} + npm install soma + + {/snippet} + +
+
+ + diff --git a/src/routes/test/soma/drag-drop/+page.svelte b/src/routes/test/soma/drag-drop/+page.svelte new file mode 100644 index 000000000..49020f511 --- /dev/null +++ b/src/routes/test/soma/drag-drop/+page.svelte @@ -0,0 +1,300 @@ + + + + DragDrop · Soma + + +
+

DragDrop

+

+ Sistema headless de drag & drop — pointer + teclado + screen reader. Space / + Enter sobre un Draggable inicia el drag por teclado, Tab + / flechas navegan entre Droppables, Space/Enter suelta, + Esc cancela. Los anuncios de screen reader se emiten vía el live region global + de Announce. +

+ +
+

1. Kanban — drag tasks between columns

+

+ El "Trash" column rechaza todo (accept=() => false) — no se resalta al + hacer drag. La columna "Done" acepta task y doc; las demás solo + task. +

+ + + {#snippet children()} + {#each columns as col (col.id)} + col.accepts.includes((data as { kind: 'task' | 'doc' }).kind)} + textValue={col.title} + class="col" + > +
+ {col.title} + {col.tasks.length} +
+
+ {#each col.tasks as task (task.id)} + + {task.kind === 'doc' ? '📄' : '•'} + {task.title} + + {/each} + {#if col.tasks.length === 0} + — + {/if} +
+
+ {/each} + + + {#snippet children({ active })} +
{active.label}
+ {/snippet} +
+ {/snippet} +
+ +
+
recent drops
+
+ {#if dropLog.length === 0} + — + {:else} +
    + {#each dropLog as entry, i (entry + i)} +
  • {entry}
  • + {/each} +
+ {/if} +
+
+ +

+ Con el teclado: enfoca cualquier task (Tab), pulsa Space para + agarrarla, flechas/Tab para moverte entre columnas que aceptan, + Space/Enter para soltar. El Trash es saltado durante + navegación por teclado. +

+
+
+ + diff --git a/src/routes/test/soma/feed/+page.svelte b/src/routes/test/soma/feed/+page.svelte new file mode 100644 index 000000000..6e48aa730 --- /dev/null +++ b/src/routes/test/soma/feed/+page.svelte @@ -0,0 +1,294 @@ + + + + Feed · Soma + + +
+

Feed

+

+ Stream infinito con semántica role="feed". PageDown avanza al siguiente + artículo, PageUp retrocede. En el último artículo, PageDown dispara + onLoadMore. Cada Article tiene aria-posinset / aria-setsize. +

+ +
+

1. Interactive testbed

+
+ + + + +
+ + + {#each items as post (post.id)} + + {#snippet children({ index, total })} + + {post.title} + +

+ by {post.author} · {index + 1} / {total ?? 'unknown'} +

+ + {post.body} + + + {#if post.replies && post.replies.length > 0} + + {#each post.replies as reply (reply.id)} + + + Reply from {reply.author} + + + {reply.body} + + + {/each} + + {/if} + {/snippet} +
+ {/each} + {#if busy} + + {/if} + +
+ +
+
articles
+
{items.length}
+
load count
+
{loadCount}
+
sentinel hits
+
{sentinelHits}
+
busy
+
{busy}
+
+ +

+ Enfoca el primer artículo (Tab) y usa PageUp/PageDown + para navegar. Ctrl+Home/Ctrl+End saltan al primero/último. El + primer post tiene un Feed.Thread anidado — sus artículos reciben + aria-level={'{'}h+1{'}'} automáticamente. El Feed.Sentinel + dispara onLoadMore cuando aparece en el viewport (activa + auto-load para ver). +

+
+
+ + diff --git a/src/routes/test/soma/grid-list/+page.svelte b/src/routes/test/soma/grid-list/+page.svelte new file mode 100644 index 000000000..4477e0487 --- /dev/null +++ b/src/routes/test/soma/grid-list/+page.svelte @@ -0,0 +1,332 @@ + + + + GridList · Soma + + +
+

GridList

+

+ Lista seleccionable con role="grid". Arriba/Abajo navega filas, + Space selecciona, Shift+Click rango, + Ctrl+Click toggle. Typeahead activo. +

+ +
+

1. Interactive testbed

+
+ + + + + + + + +
+ + + + {#each rows as row (row.value)} + + + + + {row.name} + {row.role} + {row.email} + + + { + e.stopPropagation(); + lastAction = `open ${row.value}`; + }}>Open + + + {/each} + + +
+
value
+
{JSON.stringify(value)}
+
count
+
{value.length}
+
last cell action
+
{lastAction ?? '—'}
+
+ +

+ Cell 2D nav: enfoca una fila, pulsa ArrowRight para entrar a los controles de + la fila, navega con Arrow entre Edit/Open/checkbox, ArrowLeft + desde el primer control vuelve a la fila. +

+
+ +
+

2. Field integration

+ + Team members + + + {#each rows.slice(0, 3) as row (row.value)} + + + + + {row.name} + {row.email} + + {/each} + + + Selecciona uno o más miembros. + +
+ + +
+

Field value: {JSON.stringify(fieldValue)}

+
+
+ + diff --git a/src/routes/test/soma/meter/+page.svelte b/src/routes/test/soma/meter/+page.svelte new file mode 100644 index 000000000..c77aab011 --- /dev/null +++ b/src/routes/test/soma/meter/+page.svelte @@ -0,0 +1,200 @@ + + + + Meter · Soma + + +
+

Meter

+

+ role="meter" — valor estático dentro de un rango conocido (disco, batería, score). + low / high / optimum definen zonas; el provider emite + data-state (below | optimum | above) y + --soma-meter-value-pct. +

+ +
+

1. Interactive testbed

+
+ + + + + + + +
+ + + + + +
+
state
+
{value < low ? 'below' : value > high ? 'above' : 'optimum'}
+
aria-valuetext
+
{valueText || `${value} / ${max}`}
+
+
+ +
+

2. Examples

+
+
+ Battery — 15% + + + +
+
+ Score — 62/100 + + + +
+
+ Disk usage — 95% + + + +
+
+
+
+ + diff --git a/src/routes/test/soma/progress/+page.svelte b/src/routes/test/soma/progress/+page.svelte new file mode 100644 index 000000000..e732f96cb --- /dev/null +++ b/src/routes/test/soma/progress/+page.svelte @@ -0,0 +1,167 @@ + + + + Progress · Soma + + +
+

Progress

+

+ role="progressbar" con aria-valuenow / min / max. El Indicator se escala vía + --soma-progress-value-pct. value=null → estado indeterminado. +

+ +
+

1. Interactive testbed

+
+ + + + + + +
+ + + + + +
+
value
+
{value === null ? 'null' : value}
+
aria-valuetext
+
{valueText || (value === null ? '—' : `${value} / ${max}`)}
+
+
+
+ + diff --git a/src/routes/test/soma/search-field/+page.svelte b/src/routes/test/soma/search-field/+page.svelte new file mode 100644 index 000000000..f593baed5 --- /dev/null +++ b/src/routes/test/soma/search-field/+page.svelte @@ -0,0 +1,222 @@ + + + + SearchField · Soma + + +
+

SearchField

+

+ <input type="search" role="searchbox"> con clear button integrado, + Escape-to-clear, Enter-to-submit, y OR-merge con Field.Provider. +

+ +
+

1. Interactive testbed

+
+ + + + + +
+ + (lastSubmit = v)} + onClear={() => (lastClear += 1)} + class="sf" + > + + + + +
+
value
+
{JSON.stringify(value)}
+
last submit
+
{lastSubmit ?? '—'}
+
clear fired
+
{lastClear}x
+
+ +

Presiona Enter para submit. Escape borra el valor.

+
+ +
+

2. Field integration

+

+ disabled / invalid OR-merge con Field. Field.Label + toma precedencia sobre aria-label del SearchField. +

+
+ + +
+ + + Find a product + + + + + + + Enter para buscar, Escape para limpiar. + {#if fieldInvalid} + El término de búsqueda no es válido. + {/if} + + +

Field value: {fieldValue || '—'}

+
+
+ + diff --git a/src/routes/test/soma/table/+page.svelte b/src/routes/test/soma/table/+page.svelte index a2917aa3d..f7c9d147c 100644 --- a/src/routes/test/soma/table/+page.svelte +++ b/src/routes/test/soma/table/+page.svelte @@ -8,7 +8,7 @@ const _locale = $derived(soma?.langs.getLocale()); function tt(path: string, fallback: string): string { void _locale; - return soma?.langs.t(`soma.table.${path}`) || fallback; + return soma?.langs.t(`components.table.${path}`) || fallback; } interface Person { @@ -125,97 +125,12 @@ window.addEventListener('mouseup', onUp); } - // ── Table 3: Expandable rows ───────────────────────────────────────────── - - interface Employee { - name: string; - age: number; - email: string; - role: string; - directReports?: Employee[]; - } - - const orgData: Employee[] = [ - { - name: 'Frank Miller', - age: 52, - email: 'frank@example.com', - role: 'Director', - directReports: [ - { - name: 'Bob Smith', - age: 45, - email: 'bob@example.com', - role: 'Manager', - directReports: [ - { - name: 'Alice Johnson', - age: 32, - email: 'alice@example.com', - role: 'Engineer' - }, - { name: 'Grace Lee', age: 35, email: 'grace@example.com', role: 'Engineer' } - ] - }, - { - name: 'Henry Wilson', - age: 41, - email: 'henry@example.com', - role: 'Manager', - directReports: [ - { - name: 'Jack Taylor', - age: 33, - email: 'jack@example.com', - role: 'Engineer' - }, - { name: 'Leo Garcia', age: 30, email: 'leo@example.com', role: 'Engineer' } - ] - } - ] - }, - { - name: 'Kate Moore', - age: 47, - email: 'kate@example.com', - role: 'Director', - directReports: [ - { - name: 'Carol White', - age: 28, - email: 'carol@example.com', - role: 'Designer', - directReports: [ - { name: 'Eve Davis', age: 29, email: 'eve@example.com', role: 'Designer' }, - { name: 'Ivy Chen', age: 26, email: 'ivy@example.com', role: 'Designer' } - ] - }, - { name: 'David Brown', age: 38, email: 'david@example.com', role: 'Engineer' } - ] - } - ]; - - const empColumns: ColumnDef[] = [ - { accessorKey: 'name', header: 'Name', enableSorting: true }, - { accessorKey: 'age', header: 'Age' }, - { accessorKey: 'email', header: 'Email' }, - { accessorKey: 'role', header: 'Role' } - ]; - - const table3 = createTable({ - data: () => orgData, - columns: empColumns, - getRowSubRows: (row) => row.directReports ?? [], - sorting: { enabled: true } - }); - // ── Table 4: Detail panels ────────────────────────────────────────────── const table4 = createTable({ data: () => data, columns, - sorting: { enabled: true }, - getRowCanExpand: () => true + sorting: { enabled: true } }); // ── Helpers ────────────────────────────────────────────────────────────── @@ -664,125 +579,25 @@
- +
-

Expandable Rows

-
- {table3.rows.length} rows visible -
-
- -
- +

Hierarchy → TreeGrid

- -
- - - - - {#each table3.headers as header} - - - {header.label} - {#if table3.getCanSort(header.id)} - - {/if} - - - {/each} - - - - {#each table3.rows as row (row.id)} - - - {#if row.getCanExpand} - - {/if} - - {#each row.cells as cell, colIdx} - - {#if colIdx === 0} - - {#if row.depth > 0} - - {/if} - - {initials(String(cell.value))} - {cell.value} - - - {:else if cell.column.id === 'role'} - {cell.value} - {:else} - {cell.value} - {/if} - - {/each} - - {/each} - - -
- -
- State -
{JSON.stringify({ expanded: table3.expanded }, null, 2)}
-
+

+ Para filas jerárquicas (padre → hijos con el mismo esquema) soma + expone TreeGrid, el pattern WAI-ARIA + role="treegrid" con keyboard APG completo (ArrowRight expande, + ArrowLeft colapsa o va al padre, aria-level automático). Table se + mantiene como tabla plana; no emite role="treegrid" y por eso no es el + sitio correcto para jerarquía. +

+

+ Para detalle/metadata por fila, Table sí lo cubre — ver sección + siguiente. +

@@ -838,17 +653,11 @@ {#each table4.rows as row (row.id)} - + {#each row.cells as cell, colIdx} @@ -875,54 +684,49 @@ {/each} - {#if row.getIsExpanded} - - - -
-
+
+
+ {initials(row.original.name)} +
+
+
{row.original.name}
+
+ {row.original.role} - {initials(row.original.name)} -
-
-
{row.original.name}
-
- {row.original.role} - {row.original.age} years -
- -

- {row.original.role === 'Engineer' - ? 'Builds and maintains core platform infrastructure. Focused on performance, reliability, and developer experience.' - : row.original.role === 'Designer' - ? 'Shapes product vision through research, prototyping, and systematic design. Advocates for user clarity.' - : row.original.role === 'Manager' - ? 'Leads cross-functional teams, aligns priorities, and removes blockers. Bridges strategy and execution.' - : 'Sets organizational direction, owns P&L outcomes, and drives long-term strategic initiatives.'} -

-
+ {row.original.age} years +
+ - - - {/if} +

+ {row.original.role === 'Engineer' + ? 'Builds and maintains core platform infrastructure. Focused on performance, reliability, and developer experience.' + : row.original.role === 'Designer' + ? 'Shapes product vision through research, prototyping, and systematic design. Advocates for user clarity.' + : row.original.role === 'Manager' + ? 'Leads cross-functional teams, aligns priorities, and removes blockers. Bridges strategy and execution.' + : 'Sets organizational direction, owns P&L outcomes, and drives long-term strategic initiatives.'} +

+
+
+ {/each} @@ -1476,7 +1280,7 @@ width: 2.25rem; text-align: center; } - .expand-trigger { + :global(.expand-trigger) { display: inline-flex; align-items: center; justify-content: center; @@ -1490,12 +1294,12 @@ padding: 0; transition: all 0.2s; } - .expand-trigger:hover { + :global(.expand-trigger:hover) { background: var(--c-hover); border-color: var(--c-text-dim); color: var(--c-text); } - .expand-trigger.open { + :global(.expand-trigger[data-state='open']) { background: var(--c-accent-soft); border-color: var(--c-accent); color: var(--c-accent); @@ -1505,32 +1309,10 @@ height: 8px; transition: transform 0.2s; } - .expand-trigger.open .chevron-icon { + :global(.expand-trigger[data-state='open'] .chevron-icon) { transform: rotate(90deg); } - /* ── Tree ────────────────────────────────────────────────────────────── */ - .tree-indent { - display: inline-flex; - align-items: center; - position: relative; - } - .tree-line { - position: absolute; - left: -0.4rem; - top: -0.55rem; - width: 0.7rem; - height: calc(100% + 0.1rem); - border-left: 1px solid var(--c-border); - border-bottom: 1px solid var(--c-border); - border-bottom-left-radius: 4px; - } - .tree-content { - display: inline-flex; - align-items: center; - gap: 0.5rem; - } - /* ── Avatar ──────────────────────────────────────────────────────────── */ .avatar-sm { display: inline-flex; @@ -1554,7 +1336,7 @@ } /* ── Detail panel ────────────────────────────────────────────────────── */ - .detail-row td { + :global(.detail-row td) { padding: 0 !important; border-bottom: 1px solid var(--c-border-subtle); background: var(--c-bg) !important; diff --git a/src/routes/test/soma/tag-group/+page.svelte b/src/routes/test/soma/tag-group/+page.svelte new file mode 100644 index 000000000..78ccad5f2 --- /dev/null +++ b/src/routes/test/soma/tag-group/+page.svelte @@ -0,0 +1,288 @@ + + + + TagGroup · Soma + + +
+

TagGroup

+

+ Tags de solo lectura agrupados (role="grid" + role="row"). + Arrow navega, Space selecciona, Backspace / Delete + elimina. +

+ +
+

1. Interactive testbed

+
+ + + +
+ + + Categories +
+ {#each items as item (item)} + + {item} + × + + {/each} + {#if items.length === 0} + No tags. + {/if} +
+
+ +
+
items
+
{JSON.stringify(items)}
+
selected
+
{JSON.stringify(value)}
+
last removed
+
{lastRemoved ?? '—'}
+
+
+ +
+

2. Link tags (navigable)

+

+ TagGroup.Link renderiza <a href> en vez de + <div>. Navegación con flechas, Enter activa el link. +

+ + Browse by tag +
+ {#each ['svelte', 'typescript', 'accessibility', 'aria', 'soma'] as tag (tag)} + + #{tag} + + {/each} +
+
+
+ +
+

3. Filter chips (read-only display)

+

selectionMode="none" — tags informativos solamente.

+ + Filters applied +
+ {#each ['typescript', 'svelte', 'accessibility', 'aria', '2026'] as item (item)} + + {item} + + {/each} +
+
+
+
+ + diff --git a/src/routes/test/soma/tree-grid/+page.svelte b/src/routes/test/soma/tree-grid/+page.svelte new file mode 100644 index 000000000..a900b86a0 --- /dev/null +++ b/src/routes/test/soma/tree-grid/+page.svelte @@ -0,0 +1,285 @@ + + + + TreeGrid · Soma + + +
+

TreeGrid

+

+ role="treegrid" — tabla con filas jerárquicas. ArrowDown/ArrowUp + entre filas, ArrowRight expande o entra al hijo, ArrowLeft colapsa o va al + padre, Space selecciona, typeahead por nombre. +

+ +
+

1. Interactive testbed

+
+ + + +
+ + {#snippet renderNode(nodes: Node[])} + {#each nodes as node (node.value)} + 0} + class="tg-row" + > + {#snippet children({ expanded: isExpanded, hasChildren, level })} + + {#if hasChildren} + + {isExpanded ? '▾' : '▸'} + + {:else} + + {/if} + {node.name} + + {node.size} + {node.modified} + {#if hasChildren} + + {@render renderNode(node.children ?? [])} + + {/if} + {/snippet} + + {/each} + {/snippet} + + + + Name + Size + Modified + + + {@render renderNode(tree)} + + +
+
expanded
+
{JSON.stringify(expanded)}
+
selected
+
{JSON.stringify(value)}
+
+
+
+ + diff --git a/src/uix/air/components/layout/banner/banner.css b/src/uix/air/components/layout/banner/banner.css new file mode 100644 index 000000000..924e095bb --- /dev/null +++ b/src/uix/air/components/layout/banner/banner.css @@ -0,0 +1,11 @@ +.air-banner { + display: flex; + align-items: center; + gap: var(--air-banner-gap, 1rem); + padding: var(--air-banner-padding, 0.75rem 1rem); + background: var(--air-banner-bg, transparent); + color: var(--air-banner-color, inherit); + border-bottom: var(--air-banner-border, none); + width: 100%; + box-sizing: border-box; +} diff --git a/src/uix/air/components/layout/banner/banner.svelte b/src/uix/air/components/layout/banner/banner.svelte new file mode 100644 index 000000000..e5b10ad6e --- /dev/null +++ b/src/uix/air/components/layout/banner/banner.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/src/uix/air/components/layout/banner/index.ts b/src/uix/air/components/layout/banner/index.ts new file mode 100644 index 000000000..47e287683 --- /dev/null +++ b/src/uix/air/components/layout/banner/index.ts @@ -0,0 +1,2 @@ +export { default } from './banner.svelte'; +export type { BannerProps } from './types'; diff --git a/src/uix/air/components/layout/banner/types.ts b/src/uix/air/components/layout/banner/types.ts new file mode 100644 index 000000000..6f126a232 --- /dev/null +++ b/src/uix/air/components/layout/banner/types.ts @@ -0,0 +1,17 @@ +import type { HTMLAttributes } from 'svelte/elements'; +import type { Snippet } from 'svelte'; + +/** + * Landmark element that renders as `
`. Use once per + * page for the site header. For internal section headers use a plain + * `
` (which gets `role="banner"` only as a top-level child of + * ``, per HTML spec). Forcing `role="banner"` manually via this + * component expresses the intent explicitly and works even when nested. + */ +export type BannerProps = Omit, 'children'> & { + /** Accessible name. */ + 'aria-label'?: string; + /** External label id. */ + 'aria-labelledby'?: string; + children?: Snippet; +}; diff --git a/src/uix/air/components/typography/link/index.ts b/src/uix/air/components/typography/link/index.ts new file mode 100644 index 000000000..414b09308 --- /dev/null +++ b/src/uix/air/components/typography/link/index.ts @@ -0,0 +1,2 @@ +export { default } from './link.svelte'; +export type { LinkProps, LinkVariant, LinkSize, LinkUnderline } from './types'; diff --git a/src/uix/air/components/typography/link/link.css b/src/uix/air/components/typography/link/link.css new file mode 100644 index 000000000..ebe8677a4 --- /dev/null +++ b/src/uix/air/components/typography/link/link.css @@ -0,0 +1,88 @@ +.air-link { + display: inline-flex; + align-items: center; + gap: var(--air-link-gap, 0.25em); + color: var(--air-link-color); + text-decoration: none; + border-radius: var(--air-link-radius, 2px); + outline: none; + cursor: pointer; + font: inherit; + transition: color 150ms, text-decoration-color 150ms; +} + +.air-link[data-size='sm'] { + font-size: var(--air-link-size-sm, 0.85em); +} +.air-link[data-size='md'] { + font-size: var(--air-link-size-md, 1em); +} +.air-link[data-size='lg'] { + font-size: var(--air-link-size-lg, 1.15em); +} + +.air-link[data-variant='default'] { + color: var(--air-link-color, currentColor); +} +.air-link[data-variant='subtle'] { + color: var(--air-link-subtle-color, inherit); +} +.air-link[data-variant='quiet'] { + color: inherit; +} +.air-link[data-variant='emphasized'] { + color: var(--air-link-emphasized-color); + font-weight: var(--air-link-emphasized-weight, 600); +} + +.air-link[data-underline='always'] { + text-decoration: underline; + text-decoration-thickness: var(--air-link-underline-thickness, 1px); + text-underline-offset: var(--air-link-underline-offset, 2px); +} +.air-link[data-underline='hover']:hover, +.air-link[data-underline='hover']:focus-visible { + text-decoration: underline; + text-decoration-thickness: var(--air-link-underline-thickness, 1px); + text-underline-offset: var(--air-link-underline-offset, 2px); +} +.air-link[data-underline='none'] { + text-decoration: none; +} + +.air-link:hover { + color: var(--air-link-hover-color, var(--air-link-color)); +} +.air-link:active { + color: var(--air-link-active-color, var(--air-link-color)); +} + +.air-link:focus-visible { + outline: 2px solid var(--air-link-focus-ring, currentColor); + outline-offset: 2px; +} + +.air-link[aria-disabled='true'] { + cursor: not-allowed; + opacity: 0.55; + pointer-events: none; +} + +.air-link-external-icon { + width: 0.85em; + height: 0.85em; + fill: currentColor; + flex-shrink: 0; +} + +.air-link-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/src/uix/air/components/typography/link/link.svelte b/src/uix/air/components/typography/link/link.svelte new file mode 100644 index 000000000..e15b3e048 --- /dev/null +++ b/src/uix/air/components/typography/link/link.svelte @@ -0,0 +1,64 @@ + + +{#if disabled} + + {@render children?.()} + +{:else} + + {@render children?.()} + {#if external} + + (opens in new tab) + {/if} + +{/if} diff --git a/src/uix/air/components/typography/link/types.ts b/src/uix/air/components/typography/link/types.ts new file mode 100644 index 000000000..e3e1cb8b0 --- /dev/null +++ b/src/uix/air/components/typography/link/types.ts @@ -0,0 +1,31 @@ +import type { HTMLAnchorAttributes } from 'svelte/elements'; +import type { Snippet } from 'svelte'; +import type { ResponsiveProp } from '$uix/air/lib'; + +/** Visual variant. */ +export type LinkVariant = 'default' | 'subtle' | 'quiet' | 'emphasized'; +/** Size scale. */ +export type LinkSize = 'sm' | 'md' | 'lg'; +/** Underline behavior. */ +export type LinkUnderline = 'none' | 'hover' | 'always'; + +export type LinkProps = Omit & { + /** Target URL. Omit for a plain rendering without href (useful with SvelteKit `` via `child`). */ + href?: string; + /** + * When `external` is `true`, adds `target="_blank"` + `rel="noopener noreferrer"` + * and a visible "opens in new tab" affordance (screen-reader-only text + optional icon). + * @default false + */ + external?: boolean; + /** @default 'default' */ + variant?: ResponsiveProp; + /** @default 'md' */ + size?: ResponsiveProp; + /** @default 'hover' */ + underline?: ResponsiveProp; + /** When `true`, disables the link (renders as ``, no href, aria-disabled). @default false */ + disabled?: boolean; + children?: Snippet; +}; + diff --git a/src/uix/morfo/DESIGN.md b/src/uix/morfo/DESIGN.md new file mode 100644 index 000000000..99ea400d0 --- /dev/null +++ b/src/uix/morfo/DESIGN.md @@ -0,0 +1,435 @@ +# Morfo — propuesta de diseño + +> Documento de diseño para revisión externa. Describe la motivación, el shape propuesto y las decisiones abiertas de `morfo`, un artefacto compartido entre las capas de un framework de componentes Svelte 5. +> +> Se busca crítica técnica: puntos ciegos, precedentes pasados por alto, consecuencias no previstas, y alternativas razonables al shape propuesto. + +--- + +## 1. Contexto del framework + +El framework en cuestión (llamémoslo **Vicen**) organiza componentes en capas independientes que colaboran: + +| Capa | Rol | Estado | +|------|-----|--------| +| **soma** | Headless. Comportamiento, estado, ARIA, keyboard, A11Y. ~66 componentes hoy. | Estable | +| **sema** | Semántica de comportamiento (qué semántica aplica cada componente). | No definido todavía | +| **eidos** | Capa visual. Tokens, variants, recipes, estilos. | Planificado | +| **air** | Primitives visuales sin behavior (Separator, Link, Banner, layout). | Convive con eidos | + +Regla del framework: **soma nunca importa de eidos**. eidos y sema consumen soma, no al revés. + +Componentes como `Dialog`, `Calendar`, `Table` existirán en **varias capas a la vez**: un `soma/Dialog` (headless) y un `eidos/Dialog` (estilado), ambos refiriéndose a la misma idea de componente. + +## 2. El problema + +Hoy, la información formal de un componente vive repartida en varios sitios dentro de `soma`: + +| Información | Dónde vive hoy | +|-------------|----------------| +| Nombres de parts (`root`, `trigger`, …) | `createAttrs({ parts: [...] })` en el provider | +| Data-attrs + enums válidos | `registerContract({ parts: { trigger: [{ attr: 'data-state', values: ['open','closed'] }]}})` | +| Qué ARIA emite cada part | hardcoded en `props = $derived.by(...)` del provider | +| Keyboard contract | en los handlers + en el README | +| Props | TypeScript en `types.ts` con JSDoc | +| Traducciones | `langs.ts` con idlangref constants | +| Prosa (summary, when-to-use) | README.md | +| Comparativa vs otras libs | README.md | + +Problemas derivados: + +1. **Drift dentro de soma.** Si renombro un part `content` → `panel`, tengo que tocar `createAttrs`, `registerContract`, la emisión en el provider, los selectores del demo, las tablas del README, y los tests. Fácil olvidar alguno. + +2. **Drift futuro entre capas.** Cuando llegue `eidos/Dialog`, su CSS estilará `[data-dialog-content]`. Si `soma/Dialog` emite `[data-dialog-panel]` después de un refactor, los estilos silenciosamente no aplican. Esto es **un clásico** de los design systems con layering físico. + +3. **Sin fuente única para tooling.** Docs autogeneradas, low-code builders, AI assistants que generan código consumer, form designers — todos necesitan un descriptor máquina-legible del componente. Hoy lo único cercano es `registerContract`, que solo cubre data-attrs. + +4. **Versionado impreciso.** Un cambio en nombres de parts debería ser breaking. Hoy se notifica con una entrada en un CHANGELOG manual; no hay chequeo automático. + +## 3. Propuesta: `morfo` + +Un **artefacto declarativo estructural, cross-layer, máquina-legible, por componente**. Define la *forma* que cualquier capa que implemente el componente debe respetar. + +Se llama `morfo` (de μορφή, "forma") y **no** "ADN" ni "anatomy" porque: + +- **Anatomy** (Zag.js tiene `@zag-js/anatomy`) cubre solo la estructura de parts + selectores. Si ampliamos el alcance, el nombre queda estrecho. +- **ADN** sugiere "blueprint completo por célula/capa". Implicaría un ADN por capa (soma ADN, eidos ADN). Lo que queremos es **lo compartido entre capas**, no lo exhaustivo de cada una. +- **Morfo** es la forma que convergentemente implementan las distintas capas. Análogo a cómo un pez y un delfín comparten morfología pero su ADN es distinto. + +### 3.1 Alcance + +Morfo contiene **solo contrato máquina-legible**: + +- Nombres de parts (árbol recursivo con posible nesting) +- Elemento HTML que renderiza cada part +- Data-attrs que emite cada part (con valores válidos enumerables) +- ARIA contract (qué atributo emite, qué estado semántico expone, condición) +- Keyboard contract (teclas por part con acción semántica) +- URL del pattern WAI-ARIA APG si aplica (pointer, no prose) +- Qué capas implementan el componente (`scope: ('soma' | 'sema' | 'eidos')[]`) + +Morfo **NO** contiene (y por qué): + +| Fuera de morfo | Dónde vive | Por qué | +|----------------|------------|---------| +| Summary, overview | README.md | Prose editorial, no contrato | +| Comparativa vs Radix/BaseUI/Bits | README.md | Drift externo; no ensuciar el contrato | +| Ejemplos de uso | README.md | Narrativa | +| `whenToUse X vs Y` | README.md | Guía de decisión, prose | +| Props (nombre, tipo, default) | `types.ts` con JSDoc | Ya es canónico. Docs parsean JSDoc | +| Event handlers | `{name}-provider.svelte.ts` | Código, no datos | +| State machine | `{name}-provider.svelte.ts` | Código, no datos | +| Traducciones | `langs.ts` (idlangref) | Registry aparte, consumido por el provider | +| Recipes (variants visuales) | eidos (cuando exista) | Capa-específico, fuera del contrato compartido | + +### 3.2 Shape propuesto + +```ts +// src/uix/morfo/types.ts + +export type Layer = 'soma' | 'sema' | 'eidos'; + +export interface Morfo { + /** Component display name. */ + name: string; // "Dialog" + /** kebab-case name. Matches createAttrs({component}). */ + kebab: string; // "dialog" + /** Layers that implement this component. */ + scope: Layer[]; // ['soma'] | ['soma', 'eidos'] + /** WAI-ARIA APG pattern URL, if the component implements a formal pattern. */ + apg?: string; + /** Top-level parts. Recursive. */ + parts: MorfoPart[]; +} + +export interface MorfoPart { + /** PascalCase name exposed as a component. */ + name: string; // "Trigger" + /** kebab-case matching createAttrs({parts}). Used for data-attr suffix. */ + kebab: string; // "trigger" + /** HTML element this part renders. "none" for context-only parts. */ + element: string; // " +``` + +Or a purpose-specific visible region: + +```svelte + +``` + +## Parts + +| Part | Element | Description | +| ---------- | ------- | --------------------------------------------------------------------------- | +| `Provider` | `
` | Root context. Renders internal dual live regions. Exposes `announce()`. | +| `Region` | `
` | Declarative live region for persistent, purpose-specific announcements. | + +## Props + +### `Provider` + +| Prop | Type | Default | Description | +| ---------------- | --------- | ------- | -------------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `defaultTimeout` | `number` | `1000` | Ms before a posted message is cleared. Clearing is what allows the same text to fire twice. | + +Snippet props: `{ announce, clear }`. + +### `Region` + +| Prop | Type | Default | Description | +| ----------------- | ---------------------------------------- | ------------------- | ---------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `role` | `'status' \| 'alert' \| 'log' \| 'timer'` | `'status'` | ARIA role. Each implies a default `aria-live`. | +| `aria-live` | `'polite' \| 'assertive'` | derived from `role` | Override the implicit urgency. | +| `aria-atomic` | `boolean` | `true` | Whether AT reads the full region or the diff. | +| `aria-relevant` | `string` | `'additions text'` | What mutations trigger the announcement. | +| `message` | `string` | — | Bound reactive text. Changes are announced. | +| `visuallyHidden` | `boolean` | `true` | Whether to apply the SR-only CSS clip. Set `false` for visible regions. | + +## ARIA + +| Part | Attribute | Value | +| ----------------- | --------------------- | ------------------------------------------------------- | +| Provider (inner) | `role` | `status` (polite) + `alert` (assertive) | +| Provider (inner) | `aria-live` | `polite` / `assertive` | +| Provider (inner) | `aria-atomic` | `true` | +| Region | `role` | `status` \| `alert` \| `log` \| `timer` | +| Region | `aria-live` | Resolved from `role` unless overridden | +| Region | `aria-atomic` | As prop | +| Region | `aria-relevant` | As prop | + +### Role semantics + +- **`status`** (polite) — non-critical state changes. Default. Use for "Saved", "Copied". +- **`alert`** (assertive) — urgent. Interrupts current speech. Use sparingly — for errors, session expiry. +- **`log`** (polite) — running history (chat, activity). New content at the end. +- **`timer`** (polite) — time-sensitive. AT decides update cadence (don't ticker every second). + +## Data Attributes + +| Part | Attribute | Values | +| ------- | ----------------- | --------------------------- | +| Provider | `data-announce` | Always present | +| Region | `data-announce-region` | Always present | +| Region | `data-role` | `status \| alert \| log \| timer` | +| Region | `data-live` | `polite \| assertive` | + +## Dual-region A/B trick + +Screen readers dedupe consecutive identical strings — writing "Saved" twice in a row is silent. The Provider alternates between two internal regions (A/B) on each `announce()` call; the AT sees a change in the "other" region and re-reads the message. + +## Global API (`createAnnouncer()`) + +For utility modules that can't assume a `Provider` ancestor (toast libraries, form validation helpers, worker-response handlers), soma ships a singleton-style global announcer: + +```ts +import { createAnnouncer } from '$soma/components/announce'; + +const { announce, clear } = createAnnouncer(); + +// From any handler, any module: +announce('Saved!', 'polite'); +announce('Connection lost', 'assertive'); +``` + +Behavior: +- Regions are created lazily on the **first** `announce()` call and attached to `document.body`. +- Regions are singletons per document — multiple `createAnnouncer()` calls share the same DOM nodes. No double-announces. +- Server-side rendered environments: the function is safe to call; it's a no-op when `document` is undefined. +- Independent from `Announce.Provider`. The two do not cross-post — mount the Provider if you want consistent cleanup tied to a subtree; use the global API for cross-cutting utilities. + +A pre-built shared handle is exported as `announcer`: + +```ts +import { announcer } from '$soma/components/announce'; +announcer.announce('Hello', 'polite'); +``` + +## Comparison + +| Feature | Soma | Radix | Ark UI | react-aria | APG | +| ------------------------------------- | :--: | :---: | :----: | :--------: | :-: | +| Standalone component / API | ✅ | ❌ | ⚠️¹ | ✅ | N/A | +| `role=status` / `role=alert` regions | ✅ | — | ✅ | ✅ | ✅ | +| A/B toggle (repeat-announce) | ✅ | — | ✅ | ✅ | ✅ | +| Priority routing (polite/assertive) | ✅ | — | ✅ | ✅ | ✅ | +| Auto-clear timeout | ✅ | — | ✅ | ✅ | — | +| Imperative `announce()` via context | ✅ | — | ✅ | ✅ | — | +| Singleton-style global announcer | ✅ | — | ❌ | ✅ | — | +| Declarative `Region` variant | ✅ | — | ❌ | ❌ | — | +| `role=log` / `role=timer` | ✅ | — | ❌ | ❌ | ✅ | +| `aria-atomic` / `aria-relevant` opt | ✅ | — | ❌ | ❌ | ✅ | +| Translated default `aria-label` | ✅ | — | ❌ | ❌ | — | + +¹ Ark uses an announcer built into ProgressCircle/DragDrop; no standalone API. + +## Usage + +### Save confirmation + +```svelte + + + +``` + +### Form error summary + +```svelte + +``` + +When `errorSummary` changes (e.g. after form validation), the new text is assertively announced. + +### Chat log + +```svelte + + {#each messages as m (m.id)} +

{m.author}: {m.text}

+ {/each} +
+``` + +New messages are read politely in the order they appear. diff --git a/src/uix/soma/components/announce/announce-provider.svelte.ts b/src/uix/soma/components/announce/announce-provider.svelte.ts new file mode 100644 index 000000000..7f439bdf5 --- /dev/null +++ b/src/uix/soma/components/announce/announce-provider.svelte.ts @@ -0,0 +1,211 @@ +import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider'; +import { createAttrs, registerContract, boolToStr } from '../../attrs'; +import { type ActiveProps } from '../../reactive'; +import { Soma } from '../../core/soma.svelte'; +import { ANNOUNCE_LANGS } from './langs'; +import type { AnnouncePriority, AnnounceRole } from './types'; + +const attrs = createAttrs({ + component: 'announce', + parts: ['root', 'region'] as const +}); + +registerContract({ + name: 'announce', + version: 1, + parts: { + root: [], + region: [ + { + attr: 'data-role', + values: ['status', 'alert', 'log', 'timer'], + description: 'The ARIA live-region role' + }, + { + attr: 'data-live', + values: ['polite', 'assertive'], + description: 'Resolved aria-live value (may differ from role default)' + } + ] + } +}); + +/** Visually-hidden style kept in-sync with the WAI "sr-only" recipe. */ +const VISUALLY_HIDDEN_STYLE = { + position: 'absolute', + width: '1px', + height: '1px', + padding: '0', + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + 'white-space': 'nowrap', + border: '0' +} as const; + +/** Default `aria-live` value for each role. */ +const ROLE_LIVE_DEFAULTS: Record = { + status: 'polite', + alert: 'assertive', + log: 'polite', + timer: 'polite' +}; + +// ── Root provider ────────────────────────────────────────────────────────── + +interface AnnounceOpts extends ProviderOpts, ActiveProps<{ defaultTimeout: number }> {} + +export class AnnounceProvider extends Provider { + static readonly ctx = context('Announce'); + static get(): AnnounceProvider | undefined { + return this.ctx.getOr(undefined) as AnnounceProvider | undefined; + } + static require(): AnnounceProvider { + return this.ctx.get(); + } + + static create(opts: AnnounceOpts) { + return new AnnounceProvider(opts); + } + + readonly soma = Soma.get(); + + /** Current text for each internal region. Swap alternates so repeated announcements re-fire. */ + politeA = $state(''); + politeB = $state(''); + assertiveA = $state(''); + assertiveB = $state(''); + + private politeToggle = false; + private assertiveToggle = false; + private politeTimer: ReturnType | null = null; + private assertiveTimer: ReturnType | null = null; + + private constructor(opts: AnnounceOpts) { + super(opts, 'Announce', 'root', attrs.root, AnnounceProvider.ctx); + + $effect(() => { + return () => { + if (this.politeTimer) clearTimeout(this.politeTimer); + if (this.assertiveTimer) clearTimeout(this.assertiveTimer); + }; + }); + } + + announce = ( + message: string, + priority: AnnouncePriority = 'polite', + timeout?: number + ): void => { + if (typeof window === 'undefined') return; + const clearAfter = timeout ?? this.opts.defaultTimeout.current; + + if (priority === 'assertive') { + // Alternate between A/B so that identical consecutive messages + // still fire the screen reader (writing the same string into the + // same region is a no-op for most ATs). + this.assertiveToggle = !this.assertiveToggle; + if (this.assertiveToggle) { + this.assertiveA = message; + this.assertiveB = ''; + } else { + this.assertiveA = ''; + this.assertiveB = message; + } + if (this.assertiveTimer) clearTimeout(this.assertiveTimer); + this.assertiveTimer = setTimeout(() => { + this.assertiveA = ''; + this.assertiveB = ''; + this.assertiveTimer = null; + }, clearAfter); + } else { + this.politeToggle = !this.politeToggle; + if (this.politeToggle) { + this.politeA = message; + this.politeB = ''; + } else { + this.politeA = ''; + this.politeB = message; + } + if (this.politeTimer) clearTimeout(this.politeTimer); + this.politeTimer = setTimeout(() => { + this.politeA = ''; + this.politeB = ''; + this.politeTimer = null; + }, clearAfter); + } + }; + + clear = (): void => { + this.politeA = ''; + this.politeB = ''; + this.assertiveA = ''; + this.assertiveB = ''; + if (this.politeTimer) clearTimeout(this.politeTimer); + if (this.assertiveTimer) clearTimeout(this.assertiveTimer); + this.politeTimer = null; + this.assertiveTimer = null; + }; + + readonly snippetProps = $derived.by(() => ({ + announce: this.announce, + clear: this.clear + })); + + readonly internalRegionStyle = VISUALLY_HIDDEN_STYLE; + + readonly politeLabel = $derived.by(() => + this.soma?.langs.ts(ANNOUNCE_LANGS.LABEL) ?? 'Announcements' + ); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps + } as const) + ); +} + +// ── Region ───────────────────────────────────────────────────────────────── + +interface AnnounceRegionOpts + extends WithRefOpts, + ActiveProps<{ + role: AnnounceRole; + ariaLive: AnnouncePriority | undefined; + ariaAtomic: boolean; + ariaRelevant: string; + visuallyHidden: boolean; + }> {} + +export class AnnounceRegionProvider extends Provider { + static create(opts: AnnounceRegionOpts) { + return new AnnounceRegionProvider(opts); + } + + /** Optional — a Region can live outside a Provider (pure declarative use). */ + readonly provider: AnnounceProvider | undefined; + + private constructor(opts: AnnounceRegionOpts) { + super(opts, 'Announce', 'region', attrs.region); + this.provider = AnnounceProvider.get(); + } + + readonly resolvedLive = $derived.by( + () => this.opts.ariaLive.current ?? ROLE_LIVE_DEFAULTS[this.opts.role.current] + ); + + readonly props = $derived.by(() => { + const hidden = this.opts.visuallyHidden.current; + return this.assertProps({ + ...this.baseProps, + role: this.opts.role.current, + 'aria-live': this.resolvedLive, + 'aria-atomic': boolToStr(this.opts.ariaAtomic.current), + 'aria-relevant': this.opts.ariaRelevant.current, + 'data-role': this.opts.role.current, + 'data-live': this.resolvedLive, + style: hidden ? VISUALLY_HIDDEN_STYLE : undefined + } as const); + }); +} + diff --git a/src/uix/soma/components/announce/components/announce-region.svelte b/src/uix/soma/components/announce/components/announce-region.svelte new file mode 100644 index 000000000..42430c791 --- /dev/null +++ b/src/uix/soma/components/announce/components/announce-region.svelte @@ -0,0 +1,47 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {#if message !== undefined}{message}{/if} + {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/announce/components/announce.svelte b/src/uix/soma/components/announce/components/announce.svelte new file mode 100644 index 000000000..0df4290c3 --- /dev/null +++ b/src/uix/soma/components/announce/components/announce.svelte @@ -0,0 +1,68 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+ {provider.politeA} +
+
+ {provider.politeB} +
+
+ {provider.assertiveA} +
+
+ {provider.assertiveB} +
+
+{/if} diff --git a/src/uix/soma/components/announce/exports.ts b/src/uix/soma/components/announce/exports.ts new file mode 100644 index 000000000..a0bbbd358 --- /dev/null +++ b/src/uix/soma/components/announce/exports.ts @@ -0,0 +1,13 @@ +export { default as Provider } from './components/announce.svelte'; +export { default as Region } from './components/announce-region.svelte'; + +export { createAnnouncer, announcer } from './global.svelte'; + +export type { + AnnounceProps as ProviderProps, + AnnounceRegionProps as RegionProps, + AnnounceProviderSnippetProps as ProviderSnippetProps, + AnnouncePriority, + AnnounceRole, + AnnounceApi +} from './types'; diff --git a/src/uix/soma/components/announce/global.svelte.ts b/src/uix/soma/components/announce/global.svelte.ts new file mode 100644 index 000000000..3f1240d75 --- /dev/null +++ b/src/uix/soma/components/announce/global.svelte.ts @@ -0,0 +1,140 @@ +import type { AnnouncePriority, AnnounceApi } from './types'; + +/** + * A global screen-reader announcer independent of `Announce.Provider`. + * + * Creates two hidden live regions (polite + assertive) attached to + * `document.body` lazily on the first `announce()` call. The regions are + * singletons per document, so multiple calls to `createAnnouncer()` share + * the same DOM nodes. + * + * Use this when: + * - A utility module needs to announce from outside the component tree. + * - You cannot (or prefer not to) mount `` at the app root. + * + * When both a `createAnnouncer()`-backed call AND a mounted + * `Announce.Provider` exist, they are independent — messages do not + * cross-post. Prefer the Provider when available; the global API is a + * fallback. + * + * Server-side: `announce()` is a no-op (no `document`). + */ + +const POLITE_ID = 'soma-announce-global-polite'; +const ASSERTIVE_ID = 'soma-announce-global-assertive'; + +const VISUALLY_HIDDEN_STYLE = + 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'; + +type RegionPair = { + a: HTMLDivElement; + b: HTMLDivElement; + toggle: boolean; + timer: ReturnType | null; +}; + +function ensurePair( + politeOrAssertive: AnnouncePriority, + baseId: string +): RegionPair | null { + if (typeof document === 'undefined') return null; + const cache = (globalThis as typeof globalThis & { + __somaAnnouncer?: Map; + }); + if (!cache.__somaAnnouncer) cache.__somaAnnouncer = new Map(); + let pair = cache.__somaAnnouncer.get(baseId); + if (pair) return pair; + + const host = document.createElement('div'); + host.id = baseId; + host.style.cssText = VISUALLY_HIDDEN_STYLE; + + const role = politeOrAssertive === 'assertive' ? 'alert' : 'status'; + + const make = (suffix: string) => { + const el = document.createElement('div'); + el.id = `${baseId}-${suffix}`; + el.setAttribute('role', role); + el.setAttribute('aria-live', politeOrAssertive); + el.setAttribute('aria-atomic', 'true'); + el.setAttribute('aria-relevant', 'additions text'); + return el; + }; + + const a = make('a'); + const b = make('b'); + host.appendChild(a); + host.appendChild(b); + document.body.appendChild(host); + + pair = { a, b, toggle: false, timer: null }; + cache.__somaAnnouncer.set(baseId, pair); + return pair; +} + +function write(pair: RegionPair, text: string, timeout: number) { + pair.toggle = !pair.toggle; + if (pair.toggle) { + pair.a.textContent = text; + pair.b.textContent = ''; + } else { + pair.a.textContent = ''; + pair.b.textContent = text; + } + if (pair.timer) clearTimeout(pair.timer); + pair.timer = setTimeout(() => { + pair.a.textContent = ''; + pair.b.textContent = ''; + pair.timer = null; + }, timeout); +} + +/** + * Returns the global announce/clear pair. Safe to call from any module — does + * not require a mounted ``. Regions are created lazily on + * first call and persist for the lifetime of the document. + * + * ```ts + * import { createAnnouncer } from '$soma/components/announce'; + * + * const { announce } = createAnnouncer(); + * // From any handler: + * announce('Saved!', 'polite'); + * ``` + * + * @param defaultTimeout Ms before messages are cleared. @default 1000 + */ +export function createAnnouncer(defaultTimeout = 1000): AnnounceApi { + const announce: AnnounceApi['announce'] = (message, priority = 'polite', timeout) => { + const baseId = priority === 'assertive' ? ASSERTIVE_ID : POLITE_ID; + const pair = ensurePair(priority, baseId); + if (!pair) return; + write(pair, message, timeout ?? defaultTimeout); + }; + + const clear: AnnounceApi['clear'] = () => { + if (typeof document === 'undefined') return; + const cache = (globalThis as typeof globalThis & { + __somaAnnouncer?: Map; + }).__somaAnnouncer; + if (!cache) return; + for (const pair of cache.values()) { + pair.a.textContent = ''; + pair.b.textContent = ''; + if (pair.timer) clearTimeout(pair.timer); + pair.timer = null; + } + }; + + return { announce, clear }; +} + +/** + * Convenience: the shared singleton announcer. Equivalent to calling + * `createAnnouncer()` once and reusing the result. Lazy: does not touch the + * DOM until first `.announce()` call. + */ +export const announcer: AnnounceApi = { + announce: (msg, priority, timeout) => createAnnouncer().announce(msg, priority, timeout), + clear: () => createAnnouncer().clear() +}; diff --git a/src/uix/soma/components/announce/index.ts b/src/uix/soma/components/announce/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/announce/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/announce/langs.ts b/src/uix/soma/components/announce/langs.ts new file mode 100644 index 000000000..7c0f45a05 --- /dev/null +++ b/src/uix/soma/components/announce/langs.ts @@ -0,0 +1,10 @@ +/** + * Idlangref constants for the Announce component. + * + * Announce is a headless primitive for screen reader live-region messages. + * Most strings are supplied by the consumer at announce time; only the + * optional default `aria-label` on declarative regions uses a translation. + */ +export const ANNOUNCE_LANGS = { + LABEL: '#?components.announce.label|Announcements' +} as const; diff --git a/src/uix/soma/components/announce/types.ts b/src/uix/soma/components/announce/types.ts new file mode 100644 index 000000000..64d3e6d57 --- /dev/null +++ b/src/uix/soma/components/announce/types.ts @@ -0,0 +1,115 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without } from '../../types'; +import type { PrimitiveDivAttributes } from '../../types'; + +/** How urgently the message interrupts the user's current activity. */ +export type AnnouncePriority = 'polite' | 'assertive'; + +/** ARIA live-region role. Distinct semantics in ATs: */ +/** `status` = non-critical updates, `alert` = urgent, `log` = running history, `timer` = time-sensitive. */ +export type AnnounceRole = 'status' | 'alert' | 'log' | 'timer'; + +/** Imperative API exposed by the Provider. */ +export type AnnounceApi = { + /** + * Post a message to the appropriate internal live region. + * The message is cleared after `timeout` ms so repeat announcements + * of the same text still re-trigger the screen reader. + * + * @param message Text to announce. + * @param priority `'polite'` (default) queues behind current speech, `'assertive'` interrupts. + * @param timeout Milliseconds before the region is cleared. @default 1000 + */ + announce: (message: string, priority?: AnnouncePriority, timeout?: number) => void; + /** Clear all internal live regions immediately. */ + clear: () => void; +}; + +/** Snippet props for `Announce.Provider`. */ +export type AnnounceProviderSnippetProps = AnnounceApi; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for `Announce.Provider`. + * + * Mounts two internal hidden live regions (polite + assertive) and exposes + * an imperative `announce()` API via snippet props and via context + * (descendants can call `AnnounceProvider.require().announce(...)`). + * + * Mount this ONCE near the app root — typically in the root layout. Nested + * providers work but create duplicate regions; screen readers may read the + * same message twice. + * + * For purpose-specific regions (chat log, form errors, timer), compose with + * `Announce.Region` inside the Provider. + */ +export type AnnounceProps = WithChild< + { + /** DOM id. Auto-generated if omitted. */ + id?: string; + /** + * Default timeout in ms before a posted message is cleared from the + * region. Clearing is what allows the same text to be announced again + * — screen readers dedupe identical consecutive strings. @default 1000 + */ + defaultTimeout?: number; + children?: Snippet<[AnnounceProviderSnippetProps]>; + }, + AnnounceProviderSnippetProps +> & + Without; + +// ── Region ───────────────────────────────────────────────────────────────── + +/** + * Props for `Announce.Region` — a declarative live region for persistent, + * purpose-specific announcements (chat log, running timer, form error summary). + * + * Unlike the imperative `announce()` path which uses the Provider's shared + * polite/assertive regions, a `Region` is an addressable element in the DOM + * — consumers can write directly to it via `Region.write(text)` or let it + * reflect a bound `message` prop. + */ +export type AnnounceRegionProps = WithChild<{ + /** DOM id. Auto-generated if omitted. */ + id?: string; + /** + * ARIA role. Each role implies different AT handling: + * - `status` (polite) — non-critical status updates. + * - `alert` (assertive) — interrupts speech. Use for errors. + * - `log` (polite) — running history (chat, activity). Newer content at the end. + * - `timer` (polite) — time-sensitive info. Do not announce every tick. + * @default 'status' + */ + role?: AnnounceRole; + /** + * Override the implicit `aria-live` value from `role`. Only use when you + * deliberately want a non-default urgency (e.g. an `alert` region that + * should be polite, or a `status` that should be assertive). + */ + 'aria-live'?: AnnouncePriority; + /** + * Whether the AT reads the entire region on change (`true`) or just the + * diff (`false`). @default true + */ + 'aria-atomic'?: boolean; + /** + * What types of changes trigger an announcement: `'additions'`, `'removals'`, + * `'text'`, `'all'`, or a space-separated combination. @default 'additions text' + */ + 'aria-relevant'?: string; + /** + * Bound reactive message. When the value changes, the region's content + * updates and the AT announces the new text. Alternative to writing + * children directly. + */ + message?: string; + /** + * Whether the region is visually hidden via the standard SR-only technique. + * Set `false` only when the region doubles as visible UI (e.g. a status + * banner that's both visible and announced). @default true + */ + visuallyHidden?: boolean; +}> & + Without; diff --git a/src/uix/soma/components/avatar/README.md b/src/uix/soma/components/avatar/README.md new file mode 100644 index 000000000..6e4c984ed --- /dev/null +++ b/src/uix/soma/components/avatar/README.md @@ -0,0 +1,163 @@ +# Avatar + +An image avatar with a fallback that shows until the image loads (or fails). The Provider preloads the URL out-of-band; `Image` only becomes visible when `status='loaded'`. A configurable `delayMs` prevents the fallback from flashing for fast-loading images. + +## Anatomy + +```svelte + + + JP + +``` + +## Parts + +| Part | Element | Description | +| ----------- | --------- | ---------------------------------------------------------------------------- | +| `Provider` | `` | Root context. Holds `status`. Emits `data-status`. | +| `Image` | `` | `` with `display: none` until the Provider resolves to `loaded`. | +| `Fallback` | `` | Visible while `status !== 'loaded'`. Consumer content (initials, icon, …). | + +## Props + +### `Provider` + +| Prop | Type | Default | Description | +| ---------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `delayMs` | `number` | `0` | Minimum ms before `status='loaded'` is applied (debounce against flicker). | +| `onStatusChange` | `(status: AvatarLoadingStatus) => void` | — | Fires on every status transition. | + +Snippet props: `{ status }`. + +### `Image` + +| Prop | Type | Default | Description | +| ---------------- | ----------------------------------------- | ------- | --------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `src` | `string \| null \| undefined` | — | Image URL. Empty/null/undefined → error. | +| `crossorigin` | `'anonymous' \| 'use-credentials' \| ''` | — | Passed through to the img + preloader. | +| `referrerpolicy` | `ReferrerPolicy` | — | Passed through to the img + preloader. | + +### `Fallback` + +| Prop | Type | Default | Description | +| ---- | -------- | ------- | ----------- | +| `id` | `string` | auto | DOM id. | + +## ARIA + +No explicit ARIA roles. `Avatar.Image` is a plain ``; the Fallback is a decorative ``. Consumers must provide `alt` on `Image` for the image to carry its semantic name. + +## Data Attributes + +| Part | Attribute | Values | +| --------- | --------------------- | ----------------------------------------------- | +| Provider | `data-avatar` | Always present | +| Provider | `data-status` | `idle` \| `loading` \| `loaded` \| `error` | +| Image | `data-avatar-image` | Always present | +| Image | `data-status` | Inherited from provider | +| Fallback | `data-avatar-fallback`| Always present | +| Fallback | `data-status` | Inherited from provider | + +## Status machine + +``` +idle → loading → loaded + ↓ + error +``` + +- `idle`: no `src` set yet, or component just mounted. +- `loading`: `src` is non-empty; preloader in flight. +- `loaded`: image decoded; the `` becomes visible, fallback hides. +- `error`: preload failed, `src` is empty/null, or the URL is unreachable. + +Preloading uses a detached `new Image()` so the browser decodes the bitmap before the rendered `` becomes visible — no flash of unstyled image. + +## Comparison + +| Feature | Soma | Radix | Ark UI | bits-ui | react-aria | +| ---------------------------------- | :--: | :---: | :----: | :-----: | :--------: | +| Dedicated component | ✅ | ✅ | ✅ | ✅ | ❌¹ | +| Image preload via `new Image()` | ✅ | ✅ | ✅ | ✅ | — | +| `delayMs` debounce | ✅ | ✅ | ✅ | ✅ | — | +| `onStatusChange` callback | ✅ | ✅ | ✅ | ✅ | — | +| `data-status` on root | ✅ | ❌² | ✅ | ✅ | — | +| `Group` primitive | ❌³ | ❌ | ✅ | ❌ | — | +| Built-in initials helper | ❌⁴ | ❌ | ❌ | ❌ | — | +| `crossorigin` / `referrerpolicy` | ✅ | ❌ | ❌ | ❌ | — | + +¹ react-aria leaves Avatar to the styled layer. +² Radix only propagates status via snippet / data-attrs on child parts. +³ Avatar stacks are purely visual; the air layer owns `Avatar.Group` with spacing tokens and overlap. +⁴ `getInitials(name)` is a one-liner for the consumer; not part of the headless contract. + +## Out of scope + +- **Group stack**: overlap and spacing are visual concerns. Air implements `Avatar.Group` with `--air-avatar-overlap` tokens. +- **Initials derivation**: consumers pass their own text. Different locales / naming conventions would make a built-in helper wrong for someone. +- **Skeleton state**: use the `idle` / `loading` status directly in CSS — `Avatar.Fallback[data-status='loading']` can be styled as a pulse. + +## Usage + +### Basic + +```svelte + + + JP + +``` + +### With delay (no flash on fast loads) + +```svelte + + + {user.initials} + +``` + +### Reacting to status (loader ring, skeleton) + +```svelte + + {#snippet children({ status })} + + + {#if status === 'loading'} + + {:else} + {user.initials} + {/if} + + {/snippet} + +``` + +### Styling with `data-status` + +```css +[data-avatar] { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; + background: #e2e8f0; +} +[data-avatar-image] { + width: 100%; + height: 100%; + object-fit: cover; +} +[data-avatar][data-status='error'] { + background: #fee2e2; + color: #991b1b; +} +``` diff --git a/src/uix/soma/components/avatar/avatar-provider.svelte.ts b/src/uix/soma/components/avatar/avatar-provider.svelte.ts new file mode 100644 index 000000000..f88f3f9b2 --- /dev/null +++ b/src/uix/soma/components/avatar/avatar-provider.svelte.ts @@ -0,0 +1,221 @@ +import { watch } from 'runed'; +import { Provider, context, type WithRefOpts } from '../../provider'; +import { createAttrs, registerContract } from '../../attrs'; +import { state, type ActiveProps, type StateProps } from '../../reactive'; +import type { OnChangeFn } from '../../types'; +import type { HTMLImgAttributes } from 'svelte/elements'; +import type { AvatarLoadingStatus } from './types'; + +const attrs = createAttrs({ + component: 'avatar', + parts: ['root', 'image', 'fallback'] as const +}); + +registerContract({ + name: 'avatar', + version: 1, + parts: { + root: [ + { + attr: 'data-status', + values: ['idle', 'loading', 'loaded', 'error'], + description: 'Image loading status' + } + ], + image: [ + { + attr: 'data-status', + values: ['idle', 'loading', 'loaded', 'error'], + description: 'Image loading status (inherited)' + } + ], + fallback: [ + { + attr: 'data-status', + values: ['idle', 'loading', 'loaded', 'error'], + description: 'Image loading status (inherited)' + } + ] + } +}); + +type CrossOrigin = HTMLImgAttributes['crossorigin']; +type ReferrerPolicy = HTMLImgAttributes['referrerpolicy']; + +// ── Root provider ────────────────────────────────────────────────────────── + +interface AvatarOpts + extends WithRefOpts, + StateProps<{ status: AvatarLoadingStatus }>, + ActiveProps<{ + delayMs: number; + onStatusChange: OnChangeFn | undefined; + }> {} + +export class AvatarProvider extends Provider { + static readonly ctx = context('Avatar'); + static get(): AvatarProvider | undefined { + return this.ctx.getOr(undefined) as AvatarProvider | undefined; + } + static require(): AvatarProvider { + return this.ctx.get(); + } + + static create(opts: AvatarOpts) { + return new AvatarProvider(opts); + } + + private constructor(opts: AvatarOpts) { + super(opts, 'Avatar', 'root', attrs.root, AvatarProvider.ctx); + + // Fire onStatusChange whenever status transitions. + let previous = opts.status.current; + $effect(() => { + const current = opts.status.current; + if (current !== previous) { + opts.onStatusChange.current?.(current); + previous = current; + } + }); + } + + setStatus(next: AvatarLoadingStatus) { + if (this.opts.status.current === next) return; + this.opts.status.current = next; + } + + /** + * Preload the image in the background. Updates `status` through + * `loading → loaded` (with optional `delayMs` debounce) or `→ error`. + * Returns a cleanup function that cancels the pending delay timer + the + * image listeners if `src` changes before load resolves. + */ + loadImage( + src: string, + crossorigin?: CrossOrigin, + referrerPolicy?: ReferrerPolicy + ): (() => void) | void { + if (typeof window === 'undefined') return; + + const image = new Image(); + let timerId: ReturnType | undefined; + + image.src = src; + if (crossorigin !== undefined) image.crossOrigin = crossorigin as string; + if (referrerPolicy) image.referrerPolicy = referrerPolicy; + + this.setStatus('loading'); + + image.onload = () => { + const delay = this.opts.delayMs.current; + if (delay > 0) { + timerId = setTimeout(() => this.setStatus('loaded'), delay); + } else { + this.setStatus('loaded'); + } + }; + image.onerror = () => { + this.setStatus('error'); + }; + + return () => { + image.onload = null; + image.onerror = null; + if (timerId !== undefined) clearTimeout(timerId); + }; + } + + readonly snippetProps = $derived.by(() => ({ + status: this.opts.status.current + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'data-status': this.opts.status.current + } as const) + ); +} + +// ── Image ────────────────────────────────────────────────────────────────── + +interface AvatarImageOpts + extends WithRefOpts, + ActiveProps<{ + src: string | null | undefined; + crossOrigin: CrossOrigin; + referrerPolicy: ReferrerPolicy; + }> {} + +export class AvatarImageProvider extends Provider { + static create(opts: AvatarImageOpts) { + return new AvatarImageProvider(opts); + } + + readonly provider: AvatarProvider; + + private constructor(opts: AvatarImageOpts) { + super(opts, 'Avatar', 'image', attrs.image); + this.provider = AvatarProvider.require(); + + // Preload on src change. `watch.pre` runs synchronously so the + // status flips to 'loading' before the first paint. + watch.pre( + [ + () => this.opts.src.current, + () => this.opts.crossOrigin.current, + () => this.opts.referrerPolicy.current + ], + ([src, crossOrigin, referrerPolicy]) => { + if (!src) { + this.provider.setStatus('error'); + return; + } + return this.provider.loadImage(src, crossOrigin, referrerPolicy); + } + ); + } + + readonly props = $derived.by(() => { + const status = this.provider.opts.status.current; + return this.assertProps({ + ...this.baseProps, + // The img element exists in DOM for all states, but is hidden + // until `loaded` so preloading doesn't flicker. + style: { + display: status === 'loaded' ? 'block' : 'none' + }, + src: this.opts.src.current || undefined, + crossorigin: this.opts.crossOrigin.current, + referrerpolicy: this.opts.referrerPolicy.current, + 'data-status': status + } as const); + }); +} + +// ── Fallback ─────────────────────────────────────────────────────────────── + +interface AvatarFallbackOpts extends WithRefOpts {} + +export class AvatarFallbackProvider extends Provider { + static create(opts: AvatarFallbackOpts) { + return new AvatarFallbackProvider(opts); + } + + readonly provider: AvatarProvider; + + private constructor(opts: AvatarFallbackOpts) { + super(opts, 'Avatar', 'fallback', attrs.fallback); + this.provider = AvatarProvider.require(); + } + + readonly props = $derived.by(() => { + const status = this.provider.opts.status.current; + // Visible while idle / loading / error. Hidden once image loaded. + return this.assertProps({ + ...this.baseProps, + style: status === 'loaded' ? { display: 'none' } : undefined, + 'data-status': status + } as const); + }); +} diff --git a/src/uix/soma/components/avatar/components/avatar-fallback.svelte b/src/uix/soma/components/avatar/components/avatar-fallback.svelte new file mode 100644 index 000000000..5bf9abb3e --- /dev/null +++ b/src/uix/soma/components/avatar/components/avatar-fallback.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/soma/components/avatar/components/avatar-image.svelte b/src/uix/soma/components/avatar/components/avatar-image.svelte new file mode 100644 index 000000000..8c4550cac --- /dev/null +++ b/src/uix/soma/components/avatar/components/avatar-image.svelte @@ -0,0 +1,33 @@ + + +{restProps.alt diff --git a/src/uix/soma/components/avatar/components/avatar.svelte b/src/uix/soma/components/avatar/components/avatar.svelte new file mode 100644 index 000000000..ebc0bd29a --- /dev/null +++ b/src/uix/soma/components/avatar/components/avatar.svelte @@ -0,0 +1,45 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} + + {@render children?.(provider.snippetProps)} + +{/if} diff --git a/src/uix/soma/components/avatar/exports.ts b/src/uix/soma/components/avatar/exports.ts new file mode 100644 index 000000000..7bbb4a014 --- /dev/null +++ b/src/uix/soma/components/avatar/exports.ts @@ -0,0 +1,11 @@ +export { default as Provider } from './components/avatar.svelte'; +export { default as Image } from './components/avatar-image.svelte'; +export { default as Fallback } from './components/avatar-fallback.svelte'; + +export type { + AvatarProps as ProviderProps, + AvatarImageProps as ImageProps, + AvatarFallbackProps as FallbackProps, + AvatarProviderSnippetProps as ProviderSnippetProps, + AvatarLoadingStatus +} from './types'; diff --git a/src/uix/soma/components/avatar/index.ts b/src/uix/soma/components/avatar/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/avatar/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/avatar/types.ts b/src/uix/soma/components/avatar/types.ts new file mode 100644 index 000000000..feb0196ce --- /dev/null +++ b/src/uix/soma/components/avatar/types.ts @@ -0,0 +1,78 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without, OnChangeFn } from '../../types'; +import type { + PrimitiveDivAttributes, + PrimitiveSpanAttributes +} from '../../types'; +import type { HTMLImgAttributes } from 'svelte/elements'; + +/** Image loading status tracked internally. */ +export type AvatarLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +/** Snippet props exposed by `Avatar.Provider`. */ +export type AvatarProviderSnippetProps = { + status: AvatarLoadingStatus; +}; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for the root `Avatar.Provider`. + * + * Composes `Image` (the actual ``) + `Fallback` (initials, icon, or + * placeholder). The Provider preloads the image out-of-band; only when + * `status='loaded'` does `Image` show and `Fallback` hide. A configurable + * `delayMs` prevents fallback flash for fast-loading images. + */ +export type AvatarProps = WithChild< + { + /** DOM id. Auto-generated if omitted. */ + id?: string; + /** + * Minimum delay in ms before the Fallback shows. Prevents the + * initials flashing for images that load in <100ms. @default 0 + */ + delayMs?: number; + /** Fires on every status transition. */ + onStatusChange?: OnChangeFn; + children?: Snippet<[AvatarProviderSnippetProps]>; + }, + AvatarProviderSnippetProps +> & + Without; + +// ── Image ────────────────────────────────────────────────────────────────── + +/** + * Props for `Avatar.Image`. Wraps ``; the Provider preloads the URL + * and only reveals the element (`display: block`) when `status='loaded'`. + * + * When `src` becomes `undefined` / `null`, the Provider flips to + * `status='error'` so the Fallback shows. + */ +export type AvatarImageProps = WithChild<{ + id?: string; + /** Image source. Required. */ + src: string | null | undefined; + /** `crossorigin` attribute passed through to the img + preloader. */ + crossorigin?: HTMLImgAttributes['crossorigin']; + /** `referrerpolicy` attribute passed through. */ + referrerpolicy?: HTMLImgAttributes['referrerpolicy']; +}> & + Without< + HTMLImgAttributes, + { src?: unknown; crossorigin?: unknown; referrerpolicy?: unknown } + >; + +// ── Fallback ─────────────────────────────────────────────────────────────── + +/** + * Props for `Avatar.Fallback` — shown while `status` is `idle`, `loading`, + * or `error`. Consumer chooses the content (initials `{JP}`, + * icon, placeholder image). The Provider hides it via inline `display: none` + * once `status='loaded'`. + */ +export type AvatarFallbackProps = WithChild<{ + id?: string; +}> & + Without; diff --git a/src/uix/soma/components/clipboard/README.md b/src/uix/soma/components/clipboard/README.md new file mode 100644 index 000000000..4ab056846 --- /dev/null +++ b/src/uix/soma/components/clipboard/README.md @@ -0,0 +1,174 @@ +# Clipboard + +A `navigator.clipboard.writeText` wrapper with a `copied` state that auto-resets after a configurable timeout. Consumers compose a `Trigger` button (copy on click) and an optional `Indicator` that renders only while the copy is fresh. + +`navigator.clipboard` is required — there is no `execCommand('copy')` fallback. 98%+ of browsers in 2026 support the async API, and the legacy API's synchronous-gesture requirement is incompatible with Svelte 5's microtask-based event handling. + +## Anatomy + +```svelte + + Copy + Copied! + +``` + +## Parts + +| Part | Element | Description | +| ----------- | ---------- | --------------------------------------------------------------------------- | +| `Provider` | `
` | Root context. Holds `value` + `copied` + imperative `copy()`. | +| `Trigger` | ` + {#if copied}Done!{/if} + {/snippet} + +``` + +### Styling with `data-copied` + +```css +[data-clipboard-trigger] { + transition: background 150ms; +} +[data-clipboard-trigger][data-copied] { + background: #dcfce7; + color: #166534; +} +``` + +### Error handling + +```svelte + + + { + error = err instanceof Error ? err.message : 'Could not copy'; + }} +> + Copy + +{#if error}

{error}

{/if} +``` + +Permission denial (e.g. the browser blocks the API in insecure contexts) reaches `onError`; the Provider doesn't swallow it. diff --git a/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts b/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts new file mode 100644 index 000000000..b0f555ba8 --- /dev/null +++ b/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts @@ -0,0 +1,177 @@ +import { Provider, context, type WithRefOpts } from '../../provider'; +import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; +import { readableActive, type Active, type ActiveProps } from '../../reactive'; +import type { OnChangeFn, SomaMouseEvent } from '../../types'; +import { Soma } from '../../core/soma.svelte'; +import { CLIPBOARD_LANGS } from './langs'; + +const attrs = createAttrs({ + component: 'clipboard', + parts: ['root', 'trigger', 'indicator'] as const +}); + +registerContract({ + name: 'clipboard', + version: 1, + parts: { + root: [{ attr: 'data-copied', description: 'Fresh copy within timeout window' }], + trigger: [{ attr: 'data-copied', description: 'Fresh copy within timeout window' }], + indicator: [{ attr: 'data-copied', description: 'Always present when rendered' }] + } +}); + +// ── Root provider ────────────────────────────────────────────────────────── + +interface ClipboardOpts + extends WithRefOpts, + ActiveProps<{ + value: string; + timeout: number; + onCopy: OnChangeFn | undefined; + onError: ((err: unknown) => void) | undefined; + }> {} + +export class ClipboardProvider extends Provider { + static readonly ctx = context('Clipboard'); + static get(): ClipboardProvider | undefined { + return this.ctx.getOr(undefined) as ClipboardProvider | undefined; + } + static require(): ClipboardProvider { + return this.ctx.get(); + } + + static create(opts: ClipboardOpts) { + return new ClipboardProvider(opts); + } + + readonly soma = Soma.get(); + + copied = $state(false); + private resetTimer: ReturnType | null = null; + + private constructor(opts: ClipboardOpts) { + super(opts, 'Clipboard', 'root', attrs.root, ClipboardProvider.ctx); + + $effect(() => { + return () => { + if (this.resetTimer) clearTimeout(this.resetTimer); + }; + }); + } + + /** + * Copy the current value. Returns a promise that resolves when the + * clipboard write finishes (or rejects if the API throws / is missing). + * Also schedules a `copied=false` reset after `timeout` ms. + */ + copy = async (): Promise => { + const value = this.opts.value.current; + if (typeof navigator === 'undefined' || !navigator.clipboard) { + const err = new Error( + 'navigator.clipboard is not available in this environment.' + ); + this.opts.onError.current?.(err); + throw err; + } + try { + await navigator.clipboard.writeText(value); + this.copied = true; + this.opts.onCopy.current?.(value); + + if (this.resetTimer) clearTimeout(this.resetTimer); + this.resetTimer = setTimeout(() => { + this.copied = false; + this.resetTimer = null; + }, this.opts.timeout.current); + } catch (err) { + this.opts.onError.current?.(err); + throw err; + } + }; + + readonly snippetProps = $derived.by(() => ({ + value: this.opts.value.current, + copied: this.copied, + copy: this.copy + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'data-copied': boolToEmptyStrOrUndef(this.copied) + } as const) + ); +} + +// ── Trigger ──────────────────────────────────────────────────────────────── + +interface ClipboardTriggerOpts + extends WithRefOpts, + ActiveProps<{ ariaLabel: string | undefined }> {} + +export class ClipboardTriggerProvider extends Provider { + static create(opts: ClipboardTriggerOpts) { + return new ClipboardTriggerProvider(opts); + } + + readonly provider: ClipboardProvider; + + private constructor(opts: ClipboardTriggerOpts) { + super(opts, 'Clipboard', 'trigger', attrs.trigger); + this.provider = ClipboardProvider.require(); + } + + readonly resolvedAriaLabel: Active = readableActive(() => { + const override = this.opts.ariaLabel.current; + if (override) return override; + // When copied, announce the copied state; otherwise the copy action. + return this.provider.copied + ? this.provider.soma?.langs.ts(CLIPBOARD_LANGS.COPIED) + : this.provider.soma?.langs.ts(CLIPBOARD_LANGS.COPY); + }); + + readonly onclick = (_e: SomaMouseEvent) => { + this.provider.copy().catch(() => { + // onError already fired from the provider; swallow here to avoid + // unhandled-rejection noise on button click. + }); + }; + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + type: 'button' as const, + 'aria-label': this.resolvedAriaLabel.current, + 'aria-live': 'polite' as const, + 'data-copied': boolToEmptyStrOrUndef(this.provider.copied), + onclick: this.onclick + } as const) + ); +} + +// ── Indicator ────────────────────────────────────────────────────────────── + +interface ClipboardIndicatorOpts extends WithRefOpts {} + +export class ClipboardIndicatorProvider extends Provider { + static create(opts: ClipboardIndicatorOpts) { + return new ClipboardIndicatorProvider(opts); + } + + readonly provider: ClipboardProvider; + + private constructor(opts: ClipboardIndicatorOpts) { + super(opts, 'Clipboard', 'indicator', attrs.indicator); + this.provider = ClipboardProvider.require(); + } + + readonly isPresent = $derived.by(() => this.provider.copied); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'aria-hidden': true as const, + 'data-copied': '' + } as const) + ); +} diff --git a/src/uix/soma/components/clipboard/components/clipboard-indicator.svelte b/src/uix/soma/components/clipboard/components/clipboard-indicator.svelte new file mode 100644 index 000000000..c94c30746 --- /dev/null +++ b/src/uix/soma/components/clipboard/components/clipboard-indicator.svelte @@ -0,0 +1,38 @@ + + +{#if state.isPresent || forceMount} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {@render children?.()} + + {/if} +{/if} diff --git a/src/uix/soma/components/clipboard/components/clipboard-trigger.svelte b/src/uix/soma/components/clipboard/components/clipboard-trigger.svelte new file mode 100644 index 000000000..c84fe8952 --- /dev/null +++ b/src/uix/soma/components/clipboard/components/clipboard-trigger.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/clipboard/components/clipboard.svelte b/src/uix/soma/components/clipboard/components/clipboard.svelte new file mode 100644 index 000000000..95d967d9f --- /dev/null +++ b/src/uix/soma/components/clipboard/components/clipboard.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(state.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/clipboard/exports.ts b/src/uix/soma/components/clipboard/exports.ts new file mode 100644 index 000000000..af57f7b1a --- /dev/null +++ b/src/uix/soma/components/clipboard/exports.ts @@ -0,0 +1,10 @@ +export { default as Provider } from './components/clipboard.svelte'; +export { default as Trigger } from './components/clipboard-trigger.svelte'; +export { default as Indicator } from './components/clipboard-indicator.svelte'; + +export type { + ClipboardProps as ProviderProps, + ClipboardTriggerProps as TriggerProps, + ClipboardIndicatorProps as IndicatorProps, + ClipboardProviderSnippetProps as ProviderSnippetProps +} from './types'; diff --git a/src/uix/soma/components/clipboard/index.ts b/src/uix/soma/components/clipboard/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/clipboard/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/clipboard/langs.ts b/src/uix/soma/components/clipboard/langs.ts new file mode 100644 index 000000000..f6ce604be --- /dev/null +++ b/src/uix/soma/components/clipboard/langs.ts @@ -0,0 +1,5 @@ +/** Idlangref constants for the Clipboard component. */ +export const CLIPBOARD_LANGS = { + COPY: '#?components.clipboard.copy|Copy to clipboard', + COPIED: '#?components.clipboard.copied|Copied', +} as const; diff --git a/src/uix/soma/components/clipboard/types.ts b/src/uix/soma/components/clipboard/types.ts new file mode 100644 index 000000000..b26da4660 --- /dev/null +++ b/src/uix/soma/components/clipboard/types.ts @@ -0,0 +1,81 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without, OnChangeFn } from '../../types'; +import type { + PrimitiveDivAttributes, + PrimitiveButtonAttributes, + PrimitiveSpanAttributes +} from '../../types'; + +/** Snippet props exposed by `Clipboard.Provider`. */ +export type ClipboardProviderSnippetProps = { + /** Current value that gets copied on trigger. */ + value: string; + /** `true` for `timeout` ms after a successful copy. */ + copied: boolean; + /** Imperative: call to copy immediately (bypasses Trigger click). */ + copy: () => Promise; +}; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for the root `Clipboard.Provider`. + * + * Wraps `navigator.clipboard.writeText` with a `copied` state that auto- + * resets after `timeout` ms. Exposes `Trigger` (the button that triggers + * the copy) and `Indicator` (only visible while `copied=true`). + * + * `navigator.clipboard` is required — no `execCommand('copy')` fallback. + * 98% of browsers in 2026 support the async API; the fallback's security + * model (requires direct user gesture + sync execution) is incompatible + * with Svelte 5's microtask-based event handling. + */ +export type ClipboardProps = WithChild< + { + /** DOM id. Auto-generated if omitted. */ + id?: string; + + /** String to copy. Required. */ + value: string; + /** Alias for value if preferred ergonomically. */ + /** ms to keep `copied=true` after a successful copy. @default 2000 */ + timeout?: number; + + /** Fires on successful copy. */ + onCopy?: OnChangeFn; + /** Fires when clipboard API throws (permission denied, blur, etc.). */ + onError?: (err: unknown) => void; + + children?: Snippet<[ClipboardProviderSnippetProps]>; + }, + ClipboardProviderSnippetProps +> & + Without; + +// ── Trigger ──────────────────────────────────────────────────────────────── + +/** + * Props for `Clipboard.Trigger` — button that copies `Provider.value` on + * click. Emits `data-copied` while the copy is fresh so CSS can style + * feedback (color change, icon swap via `:not(.copied)` / `.copied`). + */ +export type ClipboardTriggerProps = WithChild<{ + id?: string; + /** Accessible name. @default translated `'Copy to clipboard'` */ + 'aria-label'?: string; +}> & + Without; + +// ── Indicator ────────────────────────────────────────────────────────────── + +/** + * Props for `Clipboard.Indicator` — decorative marker that renders only + * while `copied=true` (unless `forceMount`). Consumer puts a "Copied!" + * label or a check icon inside. + */ +export type ClipboardIndicatorProps = WithChild<{ + id?: string; + /** Keep in DOM while not copied, for CSS exit transitions. @default false */ + forceMount?: boolean; +}> & + Without; diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts index 2ea191faa..acd977d8c 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts @@ -23,39 +23,16 @@ import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte import { ScrollLock } from '../../layers/scroll-lock.svelte'; import { TextSelection } from '../../layers/text-selection.svelte'; -const attrs = createAttrs({ - component: 'dialog', - parts: ['root', 'trigger', 'content', 'overlay', 'title', 'description', 'close'] as const -}); - -registerContract({ - name: 'dialog', - version: 2, - parts: { - root: [ - { attr: 'data-state', values: ['open', 'closed'], description: 'Open state' }, - { attr: 'data-disabled', description: 'Disabled flag' } - ], - trigger: [{ attr: 'data-state', values: ['open', 'closed'], description: 'Open state' }], - content: [ - { attr: 'data-state', values: ['open', 'closed'], description: 'Open state' }, - { attr: 'data-nested', description: 'Present when nested inside another dialog' }, - { attr: 'data-nested-open', description: 'Present when a nested dialog is open' }, - { attr: 'data-starting-style', description: 'Present during open animation' }, - { attr: 'data-ending-style', description: 'Present during close animation' } - ], - overlay: [ - { attr: 'data-state', values: ['open', 'closed'], description: 'Open state' }, - { attr: 'data-nested', description: 'Present when nested' }, - { attr: 'data-nested-open', description: 'Present when nested dialog open' }, - { attr: 'data-starting-style', description: 'Present during open animation' }, - { attr: 'data-ending-style', description: 'Present during close animation' } - ], - title: [], - description: [], - close: [] - } -}); +import { dialogMorfo } from '../../../morfo/components/dialog'; + +// Parts, data-attrs, and their valid enums are sourced from `dialogMorfo` — +// the single cross-layer contract. Changing a part name or a data-attr +// value here requires editing the morfo, never this file. +const attrs = createAttrs(dialogMorfo); + +// Data-attr contract comes from the morfo. Any change in valid values +// or attr names is done in `morfo/components/dialog.ts`. +registerContract(dialogMorfo); // ── Provider (root) ───────────────────────────────────────────────────────── diff --git a/src/uix/soma/components/drag-drop/README.md b/src/uix/soma/components/drag-drop/README.md new file mode 100644 index 000000000..27e499882 --- /dev/null +++ b/src/uix/soma/components/drag-drop/README.md @@ -0,0 +1,212 @@ +# DragDrop + +A headless drag-and-drop system — pointer + keyboard + screen reader. Coordinates draggable sources and drop targets within a Provider boundary, fires a single `onDrop` event with a typed payload, and announces each drag step through the global `Announce` live region. + +Soma chooses this system over HTML5 native `draggable` because HTML5 DnD has poor mobile support, incompatible cross-browser dataTransfer semantics, and no built-in keyboard model. This primitive works everywhere pointer events work (mouse, touch, pen) plus a full WAI-ARIA-aligned keyboard contract. + +## Anatomy + +```svelte + moveItem(e.value, e.target)}> + + Drag me + + + data.kind === 'task'} textValue="Done"> + Drop here + + + + {#snippet children({ active })} +
{active.label}
+ {/snippet} +
+
+``` + +## Parts + +| Part | Element | Description | +| ----------- | ---------- | -------------------------------------------------------------------- | +| `Provider` | `
` | Coordinates the drag gesture across descendants. Announces ARIA. | +| `Draggable` | `
` | An item that can be picked up. Pointer + keyboard gesture host. | +| `Droppable` | `
` | A target that accepts drops. Filters via `accept(data, value)`. | +| `Preview` | `
` | Optional floating ghost element that follows the pointer during drag.| + +## Props + +### `Provider` + +| Prop | Type | Default | Description | +| ------------------ | --------------------------------------------------------------------- | ------- | ------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `onDragStart` | `(e: { value, data, preventDefault }) => void` | — | Fires before drag starts. Call `preventDefault` to cancel. | +| `onDragEnd` | `(e: DragEndEvent) => void` | — | Fires on drop or cancel. | +| `onDrop` | `(e: DropEvent) => void` | — | Fires on successful drop. | +| `announceEnabled` | `boolean` | `true` | Emit live-region announcements via `Announce` global API. | + +Snippet props: `{ active, cancel }`. + +### `Draggable` + +| Prop | Type | Default | Description | +| ------------ | -------------------- | ------- | --------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `value` | `string` | — | **Required.** Unique identifier; returned in `onDrop.value`. | +| `data` | `Record`| `{}` | Payload; returned in `onDrop.data`. | +| `disabled` | `boolean` | `false` | Disables dragging. | +| `moveBuffer` | `number` | `5` | Pixels the pointer must move before a drag is recognised. | +| `textValue` | `string` | — | Label for announcements. Falls back to aria-label → textContent. | + +Snippet props: `{ dragging, handleProps }`. + +### `Droppable` + +| Prop | Type | Default | Description | +| ----------- | ----------------------------------------- | --------------- | ----------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `accept` | `(data, value) => boolean` | `() => true` | Filter — returning `false` removes this target from keyboard nav. | +| `disabled` | `boolean` | `false` | Disables all drops on this element. | +| `textValue` | `string` | — | Label for announcements. | + +Snippet props: `{ over, accepting }`. + +## ARIA + +| Part | Attribute | Value | +| --------- | ------------------------ | ------------------------------------------------------------ | +| Draggable | `role` | `button` | +| Draggable | `aria-roledescription` | Translated `'draggable'` | +| Draggable | `aria-grabbed` | `true` during active drag (legacy but widely supported) | +| Draggable | `tabindex` | `0` (disabled: `-1`) | +| Droppable | `role` | `region` | +| Droppable | `aria-roledescription` | Translated `'drop zone'` | +| Droppable | `aria-dropeffect` | `'move'` while accepting, `'none'` otherwise | +| Droppable | `tabindex` | `0` during drag if accepting; `-1` otherwise | + +Each drag step is announced via the `Announce` global API (see the component's [langs.ts](./langs.ts)): drag-started, drag-over-target, dropped, cancelled, no-targets-available. Disable with `announceEnabled={false}` on the Provider when wiring your own. + +## Data Attributes + +| Part | Attribute | Values | +| --------- | --------------------------------- | -------------------------------- | +| Provider | `data-drag-drop` | Always present | +| Provider | `data-dragging` | Present during active drag | +| Draggable | `data-drag-drop-draggable` | Always present | +| Draggable | `data-dragging` | This draggable is the source | +| Draggable | `data-disabled` / `data-value` / `data-text-value` | — | +| Droppable | `data-drag-drop-droppable` | Always present | +| Droppable | `data-dragover` | Pointer / focus over this target | +| Droppable | `data-accepting` | Would accept the current drag | +| Droppable | `data-disabled` | Drops disabled | +| Preview | `data-drag-drop-preview` | Always present | +| Preview | `data-active` | Present during active drag | + +## Keyboard + +| Focus on | Key | Action | +| ----------- | ---------------------------- | -------------------------------------------------------------------- | +| Draggable | `Space` / `Enter` | Begin drag. Focus jumps to the first accepting Droppable. | +| Droppable | `ArrowDown` / `ArrowRight` / `Tab` | Move to next accepting Droppable (wraps). | +| Droppable | `ArrowUp` / `ArrowLeft` / `Shift+Tab` | Move to previous accepting Droppable. | +| Droppable | `Space` / `Enter` | Drop. | +| any | `Escape` | Cancel the drag. Focus returns to the source Draggable. | + +Droppables are explicitly added/removed from the tab order based on the accept filter — a disabled or non-accepting target is silently skipped during keyboard navigation. + +## Comparison + +| Feature | Soma | Radix | Ark UI | react-aria | dnd-kit | +| ------------------------------------ | :--: | :---: | :----: | :--------: | :-----: | +| Pointer (mouse + touch + pen) | ✅ | ❌ | ❌ | ✅ | ✅ | +| Keyboard drag (Space/Enter/Escape) | ✅ | — | — | ✅ | ✅ | +| Arrow / Tab to navigate drop targets | ✅ | — | — | ✅ | ⚠️¹ | +| Accept filter per target | ✅ | — | — | ✅ | ✅ | +| Typed `data` payload | ✅ | — | — | ✅ | ✅ | +| Cancel (Escape → source focus) | ✅ | — | — | ✅ | ✅ | +| Live-region announcements | ✅ | — | — | ✅ | ⚠️² | +| Translated messages | ✅ | — | — | ❌ | ❌ | +| DragPreview (ghost element) | ✅ | — | — | ✅ | ✅ | +| Integration with Announce global API | ✅ | — | — | — | — | +| Sortable (within-list reorder) | ⚠️³ | — | — | ✅ | ✅ | +| Auto-scroll near viewport edges | ❌⁴ | — | — | ✅ | ✅ | + +¹ dnd-kit uses keyboard sensors that move items directionally; no explicit Tab between targets. +² dnd-kit provides announcer hooks; consumers must wire the message strings themselves. +³ Consumers implement sort by rendering N Droppables between items (one before, one after each row) and collapsing empty slots. Works but more verbose than a `` wrapper. First-class Sortable primitive can land when a consumer hits the verbosity wall. +⁴ Use native CSS overflow + scroll behavior of the container. Auto-scroll on viewport edges is a plausible future addition — the API surface would be a `autoScroll={true}` prop on the Provider. + +## Usage + +### Kanban board + +```svelte + + {#each columns as col (col.id)} + col.accepts.includes(data.kind)} + textValue={col.title} + > +

{col.title}

+ {#each col.tasks as t (t.id)} + + {t.title} + + {/each} +
+ {/each} + + + {#snippet children({ active })} + {active.label} + {/snippet} + +
+``` + +### File explorer drop zone + +```svelte + + + {#each files as f (f.id)} + + + + {f.name} + + + + {/each} + + + {#each folders as folder (folder.id)} + data.kind === 'file' && !folder.contains(value)} + textValue={folder.name} + > + 📁 {folder.name} + + {/each} + +``` + +### Sortable list (reorder) + +Render a Droppable "gap" between every pair of items. Each gap's `accept` returns true when the dragged item isn't adjacent to that gap (the move would be a no-op). + +```svelte + reorder(e.value, parseInt(e.target.dataset.gapIndex ?? '0'))}> + {#each items as item, i (item.id)} + v !== item.id && v !== items[i - 1]?.id} + textValue="Gap {i}" + data-gap-index={i} + /> + + {item.label} + + {/each} + + +``` diff --git a/src/uix/soma/components/drag-drop/components/drag-drop-draggable.svelte b/src/uix/soma/components/drag-drop/components/drag-drop-draggable.svelte new file mode 100644 index 000000000..b581279f3 --- /dev/null +++ b/src/uix/soma/components/drag-drop/components/drag-drop-draggable.svelte @@ -0,0 +1,45 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/drag-drop/components/drag-drop-droppable.svelte b/src/uix/soma/components/drag-drop/components/drag-drop-droppable.svelte new file mode 100644 index 000000000..f5a198cd5 --- /dev/null +++ b/src/uix/soma/components/drag-drop/components/drag-drop-droppable.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/drag-drop/components/drag-drop-preview.svelte b/src/uix/soma/components/drag-drop/components/drag-drop-preview.svelte new file mode 100644 index 000000000..8fea5b7de --- /dev/null +++ b/src/uix/soma/components/drag-drop/components/drag-drop-preview.svelte @@ -0,0 +1,32 @@ + + +{#if provider.provider.active} + {#if child} + {@render child({ active: provider.provider.active, props: mergedProps })} + {:else} +
+ {@render children?.({ active: provider.provider.active })} +
+ {/if} +{/if} diff --git a/src/uix/soma/components/drag-drop/components/drag-drop.svelte b/src/uix/soma/components/drag-drop/components/drag-drop.svelte new file mode 100644 index 000000000..05bc59df9 --- /dev/null +++ b/src/uix/soma/components/drag-drop/components/drag-drop.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts new file mode 100644 index 000000000..461fcf0c6 --- /dev/null +++ b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts @@ -0,0 +1,634 @@ +import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider'; +import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; +import { readableActive, type Active, type ActiveProps } from '../../reactive'; +import type { SomaKeyboardEvent } from '../../types'; +import { KEYS } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { createAnnouncer } from '../announce/global.svelte'; +import { DRAG_DROP_LANGS } from './langs'; +import type { ActiveDrag, DragData, DragEndEvent, DropEvent } from './types'; + +const attrs = createAttrs({ + component: 'drag-drop', + parts: ['root', 'draggable', 'droppable', 'preview'] as const +}); + +registerContract({ + name: 'drag-drop', + version: 1, + parts: { + root: [ + { attr: 'data-dragging', description: 'A drag is currently active' } + ], + draggable: [ + { attr: 'data-dragging', description: 'This draggable is the active source' }, + { attr: 'data-disabled', description: 'Dragging disabled' } + ], + droppable: [ + { attr: 'data-dragover', description: 'The active drag is currently hovering' }, + { attr: 'data-accepting', description: 'Would accept the current drag' }, + { attr: 'data-disabled', description: 'Drops disabled' } + ], + preview: [ + { attr: 'data-active', description: 'Rendered during an active drag' } + ] + } +}); + +const globalAnnounce = /* @__PURE__ */ createAnnouncer(1500); + +interface DragDropOpts + extends WithRefOpts, + ActiveProps<{ + onDragStart: + | ((e: { value: string; data: DragData; preventDefault: () => void }) => void) + | undefined; + onDragEnd: ((e: DragEndEvent) => void) | undefined; + onDrop: ((e: DropEvent) => void) | undefined; + announceEnabled: boolean; + }> {} + +// ── Root provider ────────────────────────────────────────────────────────── + +export class DragDropProvider extends Provider { + static readonly ctx = context('DragDrop'); + static get(): DragDropProvider | undefined { + return this.ctx.getOr(undefined) as DragDropProvider | undefined; + } + static require(): DragDropProvider { + return this.ctx.get(); + } + + static create(opts: DragDropOpts) { + return new DragDropProvider(opts); + } + + readonly soma = Soma.get(); + + /** Currently active drag (pointer or keyboard). */ + active = $state(null); + /** Droppable element currently under the pointer / keyboard focus. */ + overTarget = $state(null); + /** `true` when the active drag originated from keyboard Space/Enter. */ + keyboard = $state(false); + + private pointerMoveListener: ((e: PointerEvent) => void) | null = null; + private pointerUpListener: ((e: PointerEvent) => void) | null = null; + private keyListener: ((e: KeyboardEvent) => void) | null = null; + + private constructor(opts: DragDropOpts) { + super(opts, 'DragDrop', 'root', attrs.root, DragDropProvider.ctx); + $effect(() => { + return () => this.teardown(); + }); + } + + // ── Announcements ─────────────────────────────────────────────────────── + + private announce(key: string, priority: 'polite' | 'assertive' = 'polite') { + if (!this.opts.announceEnabled.current) return; + const ling = this.soma?.langs; + const text = ling ? ling.ts(key) : key; + globalAnnounce.announce(text, priority); + } + + private announceTemplate( + key: string, + vars: Record, + priority: 'polite' | 'assertive' = 'polite' + ) { + if (!this.opts.announceEnabled.current) return; + const ling = this.soma?.langs; + const text = ling ? ling.t(key, vars) : key; + globalAnnounce.announce(text, priority); + } + + // ── DOM queries ──────────────────────────────────────────────────────── + + /** All enabled Droppable elements inside this Provider. */ + getDroppables(): HTMLElement[] { + const root = this.opts.ref.current; + if (!root) return []; + return Array.from( + root.querySelectorAll(`[${attrs.droppable}]:not([data-disabled])`) + ).filter((el) => el.closest(`[${attrs.root}]`) === root); + } + + /** + * Droppables that accept the current drag, in DOM order. + */ + getAcceptingDroppables(): HTMLElement[] { + const drag = this.active; + if (!drag) return []; + return this.getDroppables().filter((el) => this.isAccepting(el, drag)); + } + + private isAccepting(el: HTMLElement, drag: ActiveDrag): boolean { + const accept = (el as HTMLElement & { __somaAccept?: (data: DragData, value: string) => boolean }) + .__somaAccept; + if (!accept) return true; + try { + return accept(drag.data, drag.value) !== false; + } catch { + return false; + } + } + + private dropTargetFromPoint(x: number, y: number): HTMLElement | null { + if (typeof document === 'undefined') return null; + const el = document.elementFromPoint(x, y); + if (!(el instanceof HTMLElement)) return null; + return el.closest(`[${attrs.droppable}]:not([data-disabled])`); + } + + // ── Start / commit / cancel ──────────────────────────────────────────── + + /** Begin a drag. Returns `false` if the `onDragStart` callback prevented it. */ + startDrag(params: { + value: string; + data: DragData; + label: string; + source: HTMLElement; + pointer: { x: number; y: number } | null; + keyboard: boolean; + }): boolean { + if (this.active) return false; + let prevented = false; + this.opts.onDragStart.current?.({ + value: params.value, + data: params.data, + preventDefault: () => { + prevented = true; + } + }); + if (prevented) return false; + + this.active = { + value: params.value, + data: params.data, + label: params.label, + pointer: params.pointer, + source: params.source + }; + this.keyboard = params.keyboard; + this.announceTemplate(DRAG_DROP_LANGS.DRAG_STARTED, { item: params.label }); + + if (params.keyboard) { + this.setupKeyboardNav(); + const firstTarget = this.getAcceptingDroppables()[0]; + if (firstTarget) { + this.setOverTarget(firstTarget, { focus: true }); + } else { + this.announce(DRAG_DROP_LANGS.DRAG_NO_TARGETS); + } + } else { + this.setupPointerDrag(); + } + return true; + } + + private setOverTarget(el: HTMLElement | null, opts?: { focus?: boolean }) { + if (this.overTarget === el) return; + this.overTarget = el; + if (!el) return; + if (opts?.focus) el.focus(); + const label = el.getAttribute('data-text-value') || el.getAttribute('aria-label') || el.textContent || ''; + this.announceTemplate(DRAG_DROP_LANGS.DRAG_OVER_TARGET, { target: label.trim() }); + } + + commitDrop(target: HTMLElement) { + const drag = this.active; + if (!drag) return; + if (!this.isAccepting(target, drag)) { + this.cancelDrag(); + return; + } + const targetLabel = + target.getAttribute('data-text-value') || + target.getAttribute('aria-label') || + target.textContent || + ''; + const event: DropEvent = { + value: drag.value, + data: drag.data, + target, + targetLabel: targetLabel.trim() + }; + this.opts.onDrop.current?.(event); + this.opts.onDragEnd.current?.({ + value: drag.value, + data: drag.data, + outcome: 'drop', + target + }); + this.announceTemplate(DRAG_DROP_LANGS.DRAG_DROPPED, { + item: drag.label, + target: targetLabel.trim() + }); + this.finish(); + } + + cancelDrag() { + const drag = this.active; + if (!drag) return; + this.opts.onDragEnd.current?.({ + value: drag.value, + data: drag.data, + outcome: 'cancel' + }); + this.announce(DRAG_DROP_LANGS.DRAG_CANCELLED); + // Return focus to the source for keyboard drags. + const source = drag.source; + this.finish(); + if (this.keyboard && source && typeof source.focus === 'function') { + source.focus(); + } + } + + private finish() { + this.active = null; + this.overTarget = null; + this.keyboard = false; + this.teardown(); + } + + private teardown() { + if (this.pointerMoveListener) { + window.removeEventListener('pointermove', this.pointerMoveListener); + this.pointerMoveListener = null; + } + if (this.pointerUpListener) { + window.removeEventListener('pointerup', this.pointerUpListener); + window.removeEventListener('pointercancel', this.pointerUpListener); + this.pointerUpListener = null; + } + if (this.keyListener) { + window.removeEventListener('keydown', this.keyListener); + this.keyListener = null; + } + } + + // ── Pointer drag ─────────────────────────────────────────────────────── + + private setupPointerDrag() { + this.pointerMoveListener = (e: PointerEvent) => { + if (!this.active) return; + this.active = { ...this.active, pointer: { x: e.clientX, y: e.clientY } }; + const target = this.dropTargetFromPoint(e.clientX, e.clientY); + if (target && this.active && this.isAccepting(target, this.active)) { + if (this.overTarget !== target) this.setOverTarget(target); + } else { + this.overTarget = null; + } + }; + this.pointerUpListener = (e: PointerEvent) => { + if (!this.active) return; + if (e.type === 'pointercancel') { + this.cancelDrag(); + return; + } + const target = this.dropTargetFromPoint(e.clientX, e.clientY); + if (target) { + this.commitDrop(target); + } else { + this.cancelDrag(); + } + }; + window.addEventListener('pointermove', this.pointerMoveListener); + window.addEventListener('pointerup', this.pointerUpListener); + window.addEventListener('pointercancel', this.pointerUpListener); + } + + // ── Keyboard drag ────────────────────────────────────────────────────── + + private setupKeyboardNav() { + this.keyListener = (e: KeyboardEvent) => { + if (!this.active || !this.keyboard) return; + if (e.key === KEYS.ESCAPE) { + e.preventDefault(); + this.cancelDrag(); + return; + } + if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { + if (this.overTarget) { + e.preventDefault(); + this.commitDrop(this.overTarget); + } + return; + } + if ( + e.key === KEYS.ARROW_DOWN || + e.key === KEYS.ARROW_RIGHT || + e.key === KEYS.TAB + ) { + e.preventDefault(); + const list = this.getAcceptingDroppables(); + if (list.length === 0) return; + const idx = this.overTarget ? list.indexOf(this.overTarget) : -1; + const next = list[(idx + 1) % list.length]; + if (next) this.setOverTarget(next, { focus: true }); + return; + } + if ( + e.key === KEYS.ARROW_UP || + e.key === KEYS.ARROW_LEFT || + (e.shiftKey && e.key === KEYS.TAB) + ) { + e.preventDefault(); + const list = this.getAcceptingDroppables(); + if (list.length === 0) return; + const idx = this.overTarget ? list.indexOf(this.overTarget) : 0; + const prev = list[(idx - 1 + list.length) % list.length]; + if (prev) this.setOverTarget(prev, { focus: true }); + return; + } + }; + window.addEventListener('keydown', this.keyListener); + } + + // ── Root props ───────────────────────────────────────────────────────── + + readonly snippetProps = $derived.by(() => ({ + active: this.active, + cancel: () => this.cancelDrag() + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'data-dragging': boolToEmptyStrOrUndef(this.active !== null) + } as const) + ); +} + +// ── Draggable ────────────────────────────────────────────────────────────── + +interface DraggableOpts + extends WithRefOpts, + ActiveProps<{ + value: string; + data: DragData; + disabled: boolean; + moveBuffer: number; + textValue: string | undefined; + }> {} + +export class DraggableProvider extends Provider { + static create(opts: DraggableOpts) { + return new DraggableProvider(opts); + } + + readonly provider: DragDropProvider; + + /** Pending pointer-down state; promoted to active drag after moveBuffer exceeded. */ + private pending: { + id: number; + startX: number; + startY: number; + } | null = null; + + private constructor(opts: DraggableOpts) { + super(opts, 'DragDrop', 'draggable', attrs.draggable); + this.provider = DragDropProvider.require(); + } + + readonly isActive = $derived.by( + () => this.provider.active?.value === this.opts.value.current + ); + + private getLabel(): string { + return ( + this.opts.textValue.current || + this.opts.ref.current?.getAttribute('aria-label') || + this.opts.ref.current?.textContent?.trim() || + this.opts.value.current + ); + } + + readonly onpointerdown = (e: PointerEvent) => { + if (this.opts.disabled.current || e.button !== 0) return; + const el = this.opts.ref.current; + if (!el) return; + this.pending = { id: e.pointerId, startX: e.clientX, startY: e.clientY }; + + const onMove = (me: PointerEvent) => { + if (!this.pending || me.pointerId !== this.pending.id) return; + const dx = me.clientX - this.pending.startX; + const dy = me.clientY - this.pending.startY; + if (Math.hypot(dx, dy) < this.opts.moveBuffer.current) return; + // Promote to active drag. + const pending = this.pending; + this.pending = null; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onCancel); + window.removeEventListener('pointercancel', onCancel); + this.provider.startDrag({ + value: this.opts.value.current, + data: this.opts.data.current, + label: this.getLabel(), + source: el, + pointer: { x: me.clientX, y: me.clientY }, + keyboard: false + }); + }; + const onCancel = () => { + this.pending = null; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onCancel); + window.removeEventListener('pointercancel', onCancel); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onCancel); + window.addEventListener('pointercancel', onCancel); + }; + + readonly onkeydown = (e: KeyboardEvent) => { + if (this.opts.disabled.current) return; + if (this.provider.active) return; // drag already in progress + if (e.key !== KEYS.SPACE && e.key !== KEYS.ENTER) return; + const el = this.opts.ref.current; + if (!el) return; + e.preventDefault(); + this.provider.startDrag({ + value: this.opts.value.current, + data: this.opts.data.current, + label: this.getLabel(), + source: el, + pointer: null, + keyboard: true + }); + }; + + readonly handleProps = $derived.by( + () => + ({ + onpointerdown: this.onpointerdown, + onkeydown: this.onkeydown, + tabindex: 0, + 'data-drag-handle': '' + }) as Record + ); + + readonly snippetProps = $derived.by(() => ({ + dragging: this.isActive, + handleProps: this.handleProps + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'button' as const, + tabindex: this.opts.disabled.current ? -1 : 0, + 'aria-disabled': this.opts.disabled.current ? true : undefined, + 'aria-roledescription': this.provider.soma?.langs.ts( + DRAG_DROP_LANGS.DRAGGABLE_ROLE + ), + 'aria-grabbed': this.isActive ? true : undefined, + 'data-dragging': boolToEmptyStrOrUndef(this.isActive), + 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), + 'data-text-value': this.opts.textValue.current, + 'data-value': this.opts.value.current, + onpointerdown: this.onpointerdown, + onkeydown: this.onkeydown, + 'style:touch-action': 'none' + } as const) + ); +} + +// ── Droppable ────────────────────────────────────────────────────────────── + +interface DroppableOpts + extends WithRefOpts, + ActiveProps<{ + accept: ((data: DragData, value: string) => boolean) | undefined; + disabled: boolean; + textValue: string | undefined; + }> {} + +export class DroppableProvider extends Provider { + static create(opts: DroppableOpts) { + return new DroppableProvider(opts); + } + + readonly provider: DragDropProvider; + + private constructor(opts: DroppableOpts) { + super(opts, 'DragDrop', 'droppable', attrs.droppable); + this.provider = DragDropProvider.require(); + + // Attach the accept predicate directly to the DOM element so the + // Provider can read it without a registry (which would require reactive + // bookkeeping + cleanup for every Droppable mount/unmount). + $effect(() => { + const el = opts.ref.current; + const accept = opts.accept.current; + if (!el) return; + (el as HTMLElement & { __somaAccept?: typeof accept }).__somaAccept = accept; + return () => { + delete (el as HTMLElement & { __somaAccept?: unknown }).__somaAccept; + }; + }); + } + + readonly isOver = $derived.by(() => this.provider.overTarget === this.opts.ref.current); + readonly isAccepting = $derived.by(() => { + const drag = this.provider.active; + if (!drag || this.opts.disabled.current) return false; + const accept = this.opts.accept.current; + if (!accept) return true; + try { + return accept(drag.data, drag.value) !== false; + } catch { + return false; + } + }); + + readonly onpointerup = (e: PointerEvent) => { + // Safety: the Provider's window listener already handles commit via + // elementFromPoint, but in case setPointerCapture steals events we + // also catch the release here. + if (!this.provider.active || this.opts.disabled.current) return; + if (!this.isAccepting) return; + const el = this.opts.ref.current; + if (!el) return; + e.stopPropagation(); + this.provider.commitDrop(el); + }; + + readonly snippetProps = $derived.by(() => ({ + over: this.isOver, + accepting: this.isAccepting + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'region' as const, + tabindex: this.provider.active && this.isAccepting ? 0 : -1, + 'aria-roledescription': this.provider.soma?.langs.ts( + DRAG_DROP_LANGS.DROPPABLE_ROLE + ), + 'aria-dropeffect': + this.provider.active && this.isAccepting ? ('move' as const) : ('none' as const), + 'aria-disabled': this.opts.disabled.current ? true : undefined, + 'data-dragover': boolToEmptyStrOrUndef(this.isOver), + 'data-accepting': boolToEmptyStrOrUndef(this.isAccepting), + 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), + 'data-text-value': this.opts.textValue.current, + onpointerup: this.onpointerup + } as const) + ); +} + +// ── Preview ──────────────────────────────────────────────────────────────── + +interface DragPreviewOpts extends ProviderOpts {} + +export class DragPreviewProvider extends Provider { + static create(opts: DragPreviewOpts) { + return new DragPreviewProvider(opts); + } + + readonly provider: DragDropProvider; + + private constructor(opts: DragPreviewOpts) { + super(opts, 'DragDrop', 'preview', attrs.preview); + this.provider = DragDropProvider.require(); + } + + readonly active: Active = readableActive( + () => this.provider.active + ); + + readonly style = $derived.by(() => { + const a = this.provider.active; + if (!a) return { display: 'none' }; + const pt = a.pointer; + if (!pt) { + const rect = a.source.getBoundingClientRect(); + return { + position: 'fixed', + left: `${rect.left}px`, + top: `${rect.top}px`, + 'pointer-events': 'none', + 'z-index': '9999' + }; + } + return { + position: 'fixed', + left: `${pt.x + 12}px`, + top: `${pt.y + 12}px`, + 'pointer-events': 'none', + 'z-index': '9999' + }; + }); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'aria-hidden': true as const, + 'data-active': boolToEmptyStrOrUndef(this.provider.active !== null), + style: this.style + } as const) + ); +} diff --git a/src/uix/soma/components/drag-drop/exports.ts b/src/uix/soma/components/drag-drop/exports.ts new file mode 100644 index 000000000..53863287f --- /dev/null +++ b/src/uix/soma/components/drag-drop/exports.ts @@ -0,0 +1,18 @@ +export { default as Provider } from './components/drag-drop.svelte'; +export { default as Draggable } from './components/drag-drop-draggable.svelte'; +export { default as Droppable } from './components/drag-drop-droppable.svelte'; +export { default as Preview } from './components/drag-drop-preview.svelte'; + +export type { + DragDropProps as ProviderProps, + DraggableProps, + DroppableProps, + DragPreviewProps as PreviewProps, + DragProviderSnippetProps as ProviderSnippetProps, + DraggableSnippetProps, + DroppableSnippetProps, + DragData, + ActiveDrag, + DropEvent, + DragEndEvent +} from './types'; diff --git a/src/uix/soma/components/drag-drop/index.ts b/src/uix/soma/components/drag-drop/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/drag-drop/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/drag-drop/langs.ts b/src/uix/soma/components/drag-drop/langs.ts new file mode 100644 index 000000000..6cf169a58 --- /dev/null +++ b/src/uix/soma/components/drag-drop/langs.ts @@ -0,0 +1,18 @@ +export const DRAG_DROP_LANGS = { + /** Announced when a drag begins via keyboard or pointer. `{{item}}` is the draggable's textValue / aria-label. */ + DRAG_STARTED: '#?components.drag-drop.drag-started|Started dragging {{item}}', + /** Announced when the pointer / keyboard focus enters a valid drop zone. */ + DRAG_OVER_TARGET: '#?components.drag-drop.drag-over|Over drop zone {{target}}', + /** Announced on successful drop. */ + DRAG_DROPPED: '#?components.drag-drop.dropped|Dropped {{item}} on {{target}}', + /** Announced when the drag is cancelled. */ + DRAG_CANCELLED: '#?components.drag-drop.cancelled|Drag cancelled', + /** Announced when no valid drop target is available. */ + DRAG_NO_TARGETS: '#?components.drag-drop.no-targets|No drop targets available', + /** `aria-roledescription` for a Draggable. */ + DRAGGABLE_ROLE: '#?components.drag-drop.draggable-role|draggable', + /** `aria-roledescription` for a Droppable. */ + DROPPABLE_ROLE: '#?components.drag-drop.droppable-role|drop zone', + /** Label for a Droppable currently accepting the active drag. */ + DROPPABLE_ACTIVE: '#?components.drag-drop.droppable-active|active drop zone' +} as const; diff --git a/src/uix/soma/components/drag-drop/types.ts b/src/uix/soma/components/drag-drop/types.ts new file mode 100644 index 000000000..49882e4bd --- /dev/null +++ b/src/uix/soma/components/drag-drop/types.ts @@ -0,0 +1,175 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without } from '../../types'; +import type { PrimitiveDivAttributes } from '../../types'; + +/** Arbitrary drag payload. Consumer-defined shape. */ +export type DragData = Record; + +/** Info about the currently active drag. */ +export type ActiveDrag = { + /** Stable identifier from the Draggable's `value` prop. */ + value: string; + /** Consumer-supplied payload. */ + data: D; + /** Text label (textValue, aria-label, or element textContent) for announcements. */ + label: string; + /** Current pointer position in viewport coords, or `null` for keyboard drags not yet moved. */ + pointer: { x: number; y: number } | null; + /** Source element. */ + source: HTMLElement; +}; + +/** Info passed to `onDrop`. */ +export type DropEvent = { + value: string; + data: D; + /** Drop target DOM element. */ + target: HTMLElement; + /** Drop target label (textValue / aria-label / textContent). */ + targetLabel: string; +}; + +export type DragEndEvent = { + value: string; + data: D; + /** `'drop'` when dropped on a valid target, `'cancel'` when cancelled (Escape / invalid release). */ + outcome: 'drop' | 'cancel'; + target?: HTMLElement; +}; + +export type DragProviderSnippetProps = { + active: ActiveDrag | null; + cancel: () => void; +}; + +export type DraggableSnippetProps = { + dragging: boolean; + handleProps: Record; +}; + +export type DroppableSnippetProps = { + over: boolean; + accepting: boolean; +}; + +// ── Provider ─────────────────────────────────────────────────────────────── + +/** + * Props for `DragDrop.Provider`. + * + * Coordinates pointer + keyboard drag-and-drop across a subtree. Mount one + * Provider around the smallest region that contains both the Draggables and + * the Droppables that participate together. + * + * The Provider does NOT own the data — it only coordinates the gesture + + * announces via live regions. Consumer receives `onDrop` and mutates their + * own state. + */ +export type DragDropProps = WithChild< + { + id?: string; + + /** + * Fires before the drag starts. Call `e.preventDefault()` to cancel. + * (Rarely needed — `Draggable.disabled` is preferred.) + */ + onDragStart?: (e: { value: string; data: DragData; preventDefault: () => void }) => void; + /** Fires when the drag ends (drop or cancel). */ + onDragEnd?: (e: DragEndEvent) => void; + /** Fires on successful drop. */ + onDrop?: (e: DropEvent) => void; + + /** + * Emit screen-reader announcements via the global `Announce` API + * (drag start, over, drop, cancel). @default true + */ + announceEnabled?: boolean; + + children?: Snippet<[DragProviderSnippetProps]>; + }, + DragProviderSnippetProps +> & + Without; + +// ── Draggable ────────────────────────────────────────────────────────────── + +/** + * Props for `DragDrop.Draggable`. + * + * Wraps a DOM element that can be dragged. The consumer can use the snippet + * `handleProps` pattern to restrict the drag "grip" to a child element + * (e.g. a handle icon) — otherwise the whole element is the handle. + */ +export type DraggableProps = WithChild< + { + id?: string; + /** Unique identifier for this item. Returned in onDrop. */ + value: string; + /** Arbitrary payload the consumer receives in onDrop. */ + data?: DragData; + /** Disable dragging. @default false */ + disabled?: boolean; + /** + * Pixels the pointer must move before a drag is recognised. Prevents + * accidental drags on clicks. @default 5 + */ + moveBuffer?: number; + /** Label for announcements. Falls back to aria-label → textContent. */ + textValue?: string; + children?: Snippet<[DraggableSnippetProps]>; + }, + DraggableSnippetProps +> & + Without; + +// ── Droppable ────────────────────────────────────────────────────────────── + +/** + * Props for `DragDrop.Droppable`. + * + * A drop target. When a drag is in progress and the pointer hovers this + * element (or keyboard focus reaches it), `data-dragover` is present. On + * drop, `onDrop` fires on the Provider with the source's value / data. + */ +export type DroppableProps = WithChild< + { + id?: string; + /** + * Filter — return `true` if the current drag can be dropped here. + * When `false`, the droppable is skipped in keyboard nav and pointer + * hits are ignored. @default () => true + */ + accept?: (data: DragData, value: string) => boolean; + /** Label for announcements. Falls back to aria-label → textContent. */ + textValue?: string; + /** Disable drops on this target. @default false */ + disabled?: boolean; + children?: Snippet<[DroppableSnippetProps]>; + }, + DroppableSnippetProps +> & + Without; + +// ── Preview ──────────────────────────────────────────────────────────────── + +/** Snippet props for `DragDrop.Preview`. */ +export type DragPreviewSnippetProps = { + active: ActiveDrag; +}; + +/** + * Props for `DragDrop.Preview`. + * + * Optional floating element that follows the pointer during drag. Positioned + * `fixed` at the current pointer position with `pointer-events: none`. For + * keyboard drags (no pointer position), rendered at the source element's + * position until first move. + */ +export type DragPreviewProps = WithChild< + { + id?: string; + children?: Snippet<[DragPreviewSnippetProps]>; + }, + DragPreviewSnippetProps +> & + Without; diff --git a/src/uix/soma/components/feed/README.md b/src/uix/soma/components/feed/README.md new file mode 100644 index 000000000..70745a987 --- /dev/null +++ b/src/uix/soma/components/feed/README.md @@ -0,0 +1,267 @@ +# Feed + +Implements the WAI-ARIA [Feed pattern](https://www.w3.org/WAI/ARIA/apg/patterns/feed/) — a scrollable stream of `role="article"` children where assistive tech navigates between articles with `PageUp` / `PageDown`. Each article exposes its position via `aria-posinset` / `aria-setsize` so the user always knows where they are. + +Use Feed for activity streams, chat histories, threaded comments — anything where the article count can grow via lazy loading. Use `GridList` or `Listbox` instead when items are bounded and the user picks one. + +## Anatomy + +```svelte + + {#each posts as post (post.id)} + + {post.title} + {post.body} + + {/each} + +``` + +## Parts + +| Part | Element | Description | +| -------------------- | --------- | ------------------------------------------------------------------------------------ | +| `Provider` | `
` | `role="feed"`. Coordinates PageUp/PageDown navigation. | +| `Article` | `
` | `role="article"`. Focusable, auto-computed `aria-posinset` / `aria-setsize` / level. | +| `ArticleTitle` | `
` | `role="heading"` + auto-derived `aria-level` from thread depth. | +| `ArticleDescription` | `
` | Linked via `aria-describedby` on the enclosing Article. | +| `Thread` | `
` | Nested sub-feed (`role="feed"`). Child articles inherit level + 1. | +| `Sentinel` | `
` | Invisible IntersectionObserver probe. Fires `onLoadMore` when visible. | + +## Props + +### `Provider` + +| Prop | Type | Default | Description | +| ----------------- | --------------------- | ---------- | ------------------------------------------------------------------------ | +| `id` | `string` | auto | DOM id. | +| `totalItems` | `number \| undefined` | — | Total across all pages. `undefined` when unknown (infinite / unbounded). | +| `busy` | `boolean` | `false` | Whether more items are loading. Sets `aria-busy`. | +| `onLoadMore` | `() => void` | — | Called when the user PageDown's past the last article. | +| `aria-label` | `string` | translated | Accessible name (default `'Feed'`). | +| `aria-labelledby` | `string` | — | External label id. | + +Snippet props: `{ busy }`. + +### `Article` + +| Prop | Type | Default | Description | +| ----------- | -------- | ------------- | --------------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `posinset` | `number` | auto (1-based DOM index) | Explicit position in the full set (use when DOM order ≠ feed order). | + +Snippet props: `{ index, total }` (0-based `index`, `total` is `totalItems` or `undefined`). + +### `ArticleTitle` + +| Prop | Type | Default | Description | +| ------- | ------------------------ | ---------------------------------------------- | -------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `level` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | derived from thread depth (3 top, 4 nested, …) | Heading level. Explicit value wins. | + +### `ArticleDescription` + +| Prop | Type | Default | Description | +| ---- | -------- | ------- | ----------- | +| `id` | `string` | auto | DOM id. | + +### `Thread` + +| Prop | Type | Default | Description | +| ---- | -------- | ------- | ----------- | +| `id` | `string` | auto | DOM id. | + +### `Sentinel` + +| Prop | Type | Default | Description | +| ------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `rootMargin` | `string` | `'200px'` | IntersectionObserver rootMargin. | +| `threshold` | `number \| number[]` | `0` | IntersectionObserver threshold. | +| `root` | `Element \| Document \| null` | `null` | Observer root (`null` = viewport). | +| `onIntersect` | `() => void` | parent Feed's `onLoadMore` | Callback when the sentinel enters the observed zone. | +| `disabled` | `boolean` | `false` | Skip observer setup. | + +## ARIA + +| Part | Attribute | Value | +| ------------------- | ------------------ | ---------------------------------------- | +| Provider | `role` | `feed` | +| Provider | `aria-busy` | `true` when `busy` | +| Provider | `aria-label` | Translated default or override | +| Article | `role` | `article` | +| Article | `tabindex` | `0` (focusable for PageUp/PageDown nav) | +| Article | `aria-posinset` | Explicit `posinset` or auto-computed | +| Article | `aria-setsize` | `totalItems`, or `-1` when unknown (per APG) | +| Article | `aria-labelledby` | Auto-wired to `ArticleTitle` id | +| Article | `aria-describedby` | Auto-wired to `ArticleDescription` id | +| ArticleTitle | `role` | `heading` | +| ArticleTitle | `aria-level` | As prop (`3` by default) | + +Per [the APG](https://www.w3.org/WAI/ARIA/apg/patterns/feed/): +- `aria-setsize="-1"` means "total unknown" (soma emits this when `totalItems` is undefined). +- `aria-busy="true"` signals that new articles are being loaded; AT may defer announcements. + +## Data Attributes + +| Part | Attribute | Values | +| ------------------- | --------------------------------- | ----------------------------------- | +| Provider | `data-feed` | Always present | +| Provider | `data-busy` | Present when busy | +| Article | `data-feed-article` | Always present | +| Article | `data-posinset` | Numeric position | +| Article | `data-level` | Nesting depth (1 = top, 2 = Thread) | +| ArticleTitle | `data-feed-article-title` | Always present | +| ArticleDescription | `data-feed-article-description` | Always present | +| Thread | `data-feed-thread` | Always present | +| Thread | `data-level` | Thread depth | +| Sentinel | `data-feed-sentinel` | Always present | +| Sentinel | `data-intersecting` | Present while in the observed zone | + +## Keyboard + +| Key | Action | +| ------------------------ | --------------------------------------------------------------------- | +| `PageDown` (on Article) | Focus next Article. At the last one, calls `onLoadMore()`. | +| `PageUp` (on Article) | Focus previous Article. | +| `Ctrl+Home` | Focus first Article. | +| `Ctrl+End` | Focus last Article. | +| `Tab` (within Article) | Enters inner interactive elements per native tab order. | + +## Virtualization + +Compose with [`VirtualList`](../virtual-list/README.md) for long streams: + +```svelte + + posts[i].id}> + {#snippet children({ virtualItems, totalSize })} + +
+ {#each virtualItems as v (v.key)} + + + {posts[v.index].title} + {posts[v.index].body} + + + {/each} +
+
+ {/snippet} +
+ +
+``` + +PageUp/PageDown still work — the Provider's `getArticles()` queries the current DOM, so only mounted articles participate. Pair with `Feed.Sentinel` for auto-load at the viewport's tail. + +## Comparison + +| Feature | Soma | Radix | Ark UI | react-aria | APG | +| ---------------------------------- | :--: | :---: | :----: | :--------: | :-: | +| `role="feed"` root | ✅ | ❌ | ❌ | ❌¹ | ✅ | +| `role="article"` children | ✅ | — | — | — | ✅ | +| `aria-posinset` / `aria-setsize` | ✅ | — | — | — | ✅ | +| `aria-busy` during load | ✅ | — | — | — | ✅ | +| PageUp / PageDown navigation | ✅ | — | — | — | ✅ | +| `onLoadMore` callback | ✅ | — | — | — | — | +| Ctrl+Home / Ctrl+End jumps | ✅ | — | — | — | ✅ | +| Auto-wired title/description | ✅ | — | — | — | — | +| Nested threads (`Feed.Thread`) | ✅ | — | — | ❌ | — | +| Auto-derived heading level | ✅ | — | — | ❌ | — | +| IntersectionObserver auto-load | ✅ | — | — | ✅ | — | +| Virtualization (via compose) | ✅² | — | — | ✅ | — | + +¹ react-aria recommends `GridList` or `ListBox` rather than `role=feed`. Soma ships Feed because WAI-ARIA defines the pattern and it's the correct semantic for activity streams. +² Compose with `VirtualList` — see the section above. No coupling between soma and the virtualizer. + +## Usage + +### Basic infinite feed + +```svelte + + + + {#each posts as post (post.id)} + + {post.title} + {post.body} + + {/each} + +``` + +### Known total (paginated feed) + +```svelte + + {#each visible as c (c.id)} + + + + {/each} + +``` + +### IntersectionObserver auto-load + +Drop a `Feed.Sentinel` inside the Provider. When it scrolls into view it fires the Provider's `onLoadMore` automatically: + +```svelte + + {#each posts as p (p.id)} + ... + {/each} + + +``` + +Keyboard users still trigger `onLoadMore` via `PageDown` at the last article — the Sentinel is additive for pointer/scroll users. + +### Threaded replies + +Wrap replies in a `Feed.Thread` inside the parent Article. Nested articles automatically inherit: + +- `aria-setsize` scoped to the thread (not the root feed). +- `aria-level` on the heading bumped by one per nesting level. +- `data-level` attribute on both Thread and Article. + +```svelte + + {#each posts as post (post.id)} + + {post.title} + {post.body} + + {#if post.replies?.length} + + {#each post.replies as r (r.id)} + + + Reply from {r.author} + {r.body} + + {/each} + + {/if} + + {/each} + +``` + +Threads nest to any depth — the level computation walks up through enclosing Threads. Each Thread is a fresh `role="feed"` with its own posinset/setsize. diff --git a/src/uix/soma/components/feed/components/feed-article-description.svelte b/src/uix/soma/components/feed/components/feed-article-description.svelte new file mode 100644 index 000000000..0cbb1b195 --- /dev/null +++ b/src/uix/soma/components/feed/components/feed-article-description.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/feed/components/feed-article-title.svelte b/src/uix/soma/components/feed/components/feed-article-title.svelte new file mode 100644 index 000000000..7ca99f403 --- /dev/null +++ b/src/uix/soma/components/feed/components/feed-article-title.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/feed/components/feed-article.svelte b/src/uix/soma/components/feed/components/feed-article.svelte new file mode 100644 index 000000000..c3d35aa73 --- /dev/null +++ b/src/uix/soma/components/feed/components/feed-article.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/feed/components/feed-sentinel.svelte b/src/uix/soma/components/feed/components/feed-sentinel.svelte new file mode 100644 index 000000000..55f1b03e5 --- /dev/null +++ b/src/uix/soma/components/feed/components/feed-sentinel.svelte @@ -0,0 +1,45 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/feed/components/feed-thread.svelte b/src/uix/soma/components/feed/components/feed-thread.svelte new file mode 100644 index 000000000..58d56afc0 --- /dev/null +++ b/src/uix/soma/components/feed/components/feed-thread.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/feed/components/feed.svelte b/src/uix/soma/components/feed/components/feed.svelte new file mode 100644 index 000000000..a434b996d --- /dev/null +++ b/src/uix/soma/components/feed/components/feed.svelte @@ -0,0 +1,45 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/feed/exports.ts b/src/uix/soma/components/feed/exports.ts new file mode 100644 index 000000000..499c09ab4 --- /dev/null +++ b/src/uix/soma/components/feed/exports.ts @@ -0,0 +1,17 @@ +export { default as Provider } from './components/feed.svelte'; +export { default as Article } from './components/feed-article.svelte'; +export { default as ArticleTitle } from './components/feed-article-title.svelte'; +export { default as ArticleDescription } from './components/feed-article-description.svelte'; +export { default as Thread } from './components/feed-thread.svelte'; +export { default as Sentinel } from './components/feed-sentinel.svelte'; + +export type { + FeedProps as ProviderProps, + FeedArticleProps as ArticleProps, + FeedArticleTitleProps as ArticleTitleProps, + FeedArticleDescriptionProps as ArticleDescriptionProps, + FeedThreadProps as ThreadProps, + FeedSentinelProps as SentinelProps, + FeedProviderSnippetProps as ProviderSnippetProps, + FeedArticleSnippetProps as ArticleSnippetProps +} from './types'; diff --git a/src/uix/soma/components/feed/feed-provider.svelte.ts b/src/uix/soma/components/feed/feed-provider.svelte.ts new file mode 100644 index 000000000..42641830f --- /dev/null +++ b/src/uix/soma/components/feed/feed-provider.svelte.ts @@ -0,0 +1,434 @@ +import { Provider, context, type WithRefOpts } from '../../provider'; +import { + createAttrs, + registerContract, + boolToEmptyStrOrUndef +} from '../../attrs'; +import { readableActive, state, type Active, type ActiveProps } from '../../reactive'; +import type { SomaKeyboardEvent } from '../../types'; +import { KEYS } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { FEED_LANGS } from './langs'; + +const attrs = createAttrs({ + component: 'feed', + parts: [ + 'root', + 'article', + 'article-title', + 'article-description', + 'thread', + 'sentinel' + ] as const +}); + +registerContract({ + name: 'feed', + version: 1, + parts: { + root: [{ attr: 'data-busy', description: 'Loading more items' }], + article: [ + { attr: 'data-posinset', description: 'Position in the full set' }, + { attr: 'data-level', description: 'Nesting depth (1 = top level)' } + ], + 'article-title': [], + 'article-description': [], + thread: [{ attr: 'data-level', description: 'Nesting depth (1 = top level)' }], + sentinel: [ + { attr: 'data-intersecting', description: 'Currently in viewport' } + ] + } +}); + +// ── Root provider ────────────────────────────────────────────────────────── + +interface FeedOpts + extends WithRefOpts, + ActiveProps<{ + totalItems: number | undefined; + busy: boolean; + ariaLabel: string | undefined; + ariaLabelledby: string | undefined; + onLoadMore: (() => void) | undefined; + }> {} + +export class FeedProvider extends Provider { + static readonly ctx = context('Feed'); + static get(): FeedProvider | undefined { + return this.ctx.getOr(undefined) as FeedProvider | undefined; + } + static require(): FeedProvider { + return this.ctx.get(); + } + + static create(opts: FeedOpts) { + return new FeedProvider(opts); + } + + readonly soma = Soma.get(); + + private constructor(opts: FeedOpts) { + super(opts, 'Feed', 'root', attrs.root, FeedProvider.ctx); + } + + readonly resolvedAriaLabel: Active = readableActive(() => { + if (this.opts.ariaLabelledby.current) return undefined; + return ( + this.opts.ariaLabel.current || + this.soma?.langs.ts(FEED_LANGS.LABEL) || + undefined + ); + }); + + /** All article elements, in DOM order. */ + getArticles(): HTMLElement[] { + const root = this.opts.ref.current; + if (!root) return []; + return Array.from(root.querySelectorAll(`[${attrs.article}]`)).filter( + (el) => el.closest(`[${attrs.root}]`) === root + ); + } + + /** + * Articles that belong directly to the given feed container (root or + * thread). Used to compute posinset/setsize per nesting level. + */ + getArticlesInContainer(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(`[${attrs.article}]`)).filter( + (el) => { + // Closest enclosing feed container (root OR thread). + const enclosing = el.parentElement?.closest( + `[${attrs.root}], [${attrs.thread}]` + ); + return enclosing === container; + } + ); + } + + private focusAt(index: number) { + const articles = this.getArticles(); + if (articles.length === 0) return; + const clamped = Math.max(0, Math.min(articles.length - 1, index)); + articles[clamped]?.focus(); + } + + /** Fired on the feed root (capture Page nav) and on articles (for end-of-feed). */ + handleArticleKeydown(e: SomaKeyboardEvent) { + const articles = this.getArticles(); + if (articles.length === 0) return; + + const current = e.currentTarget; + const currentIndex = articles.indexOf(current); + + if (e.key === KEYS.PAGE_DOWN) { + e.preventDefault(); + if (currentIndex >= articles.length - 1) { + // End of feed — request more. + this.opts.onLoadMore.current?.(); + return; + } + this.focusAt(currentIndex + 1); + return; + } + if (e.key === KEYS.PAGE_UP) { + e.preventDefault(); + this.focusAt(currentIndex - 1); + return; + } + if (e.key === KEYS.HOME && e.ctrlKey) { + e.preventDefault(); + this.focusAt(0); + return; + } + if (e.key === KEYS.END && e.ctrlKey) { + e.preventDefault(); + this.focusAt(articles.length - 1); + return; + } + } + + readonly snippetProps = $derived.by(() => ({ + busy: this.opts.busy.current + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'feed' as const, + 'aria-label': this.resolvedAriaLabel.current, + 'aria-labelledby': this.opts.ariaLabelledby.current, + 'aria-busy': this.opts.busy.current ? true : undefined, + 'data-busy': boolToEmptyStrOrUndef(this.opts.busy.current) + } as const) + ); +} + +// ── Thread (nested article container) ────────────────────────────────────── + +interface FeedThreadOpts extends WithRefOpts {} + +/** + * Contains a nested sub-feed of articles (replies, sub-comments). Emits + * `role="feed"` so assistive tech recognises the nested stream. Children + * `Feed.Article`s inherit the correct nesting level automatically — their + * heading defaults become `aria-level={parent_level + 1}`. + */ +export class FeedThreadProvider extends Provider { + static readonly ctx = context('FeedThread'); + static get(): FeedThreadProvider | undefined { + return this.ctx.getOr(undefined) as FeedThreadProvider | undefined; + } + + static create(opts: FeedThreadOpts) { + return new FeedThreadProvider(opts); + } + + readonly parentArticle: FeedArticleProvider; + readonly level: number; + + private constructor(opts: FeedThreadOpts) { + super(opts, 'Feed', 'thread', attrs.thread, FeedThreadProvider.ctx); + this.parentArticle = FeedArticleProvider.require(); + this.level = this.parentArticle.level + 1; + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'feed' as const, + 'aria-labelledby': this.parentArticle.titleId.current || undefined, + 'data-level': this.level + } as const) + ); +} + +// ── Article ──────────────────────────────────────────────────────────────── + +interface FeedArticleOpts + extends WithRefOpts, + ActiveProps<{ posinset: number | undefined }> {} + +export class FeedArticleProvider extends Provider { + static readonly ctx = context('FeedArticle'); + static get(): FeedArticleProvider | undefined { + return this.ctx.getOr(undefined) as FeedArticleProvider | undefined; + } + static require(): FeedArticleProvider { + return this.ctx.get(); + } + + static create(opts: FeedArticleOpts) { + return new FeedArticleProvider(opts); + } + + readonly provider: FeedProvider; + readonly thread: FeedThreadProvider | undefined; + /** 1 for top-level, +1 for each enclosing `Feed.Thread`. */ + readonly level: number; + + /** Registered by child parts on mount. */ + titleId = state(''); + descriptionId = state(''); + + private constructor(opts: FeedArticleOpts) { + super(opts, 'Feed', 'article', attrs.article, FeedArticleProvider.ctx); + this.provider = FeedProvider.require(); + this.thread = FeedThreadProvider.get(); + this.level = this.thread ? this.thread.level : 1; + } + + /** Enclosing feed container — either the root or a `Feed.Thread`. */ + readonly containerEl = $derived.by(() => { + const el = this.opts.ref.current; + if (!el) return undefined; + return el.parentElement?.closest( + `[${attrs.root}], [${attrs.thread}]` + ) ?? undefined; + }); + + /** DOM-order index of this article in its container (1-based). */ + readonly autoPosinset = $derived.by(() => { + const el = this.opts.ref.current; + const container = this.containerEl; + if (!el || !container) return undefined; + const siblings = this.provider.getArticlesInContainer(container); + const idx = siblings.indexOf(el); + return idx >= 0 ? idx + 1 : undefined; + }); + + readonly resolvedPosinset = $derived.by( + () => this.opts.posinset.current ?? this.autoPosinset + ); + + readonly resolvedSetsize = $derived.by(() => { + // Only the root feed uses the consumer-supplied totalItems; nested + // threads always get -1 (unknown) or the sibling count. + if (this.level === 1) { + const total = this.provider.opts.totalItems.current; + if (total !== undefined) return total; + } + return -1; + }); + + readonly snippetProps = $derived.by(() => ({ + index: (this.resolvedPosinset ?? 1) - 1, + total: + this.provider.opts.totalItems.current !== undefined + ? this.provider.opts.totalItems.current + : undefined + })); + + readonly onkeydown = (e: KeyboardEvent) => { + this.provider.handleArticleKeydown(e as SomaKeyboardEvent); + }; + + readonly props = $derived.by(() => { + const posinset = this.resolvedPosinset; + return this.assertProps({ + ...this.baseProps, + role: 'article' as const, + tabindex: 0, + 'aria-posinset': posinset, + 'aria-setsize': this.resolvedSetsize, + 'aria-labelledby': this.titleId.current || undefined, + 'aria-describedby': this.descriptionId.current || undefined, + 'data-posinset': posinset, + 'data-level': this.level, + onkeydown: this.onkeydown + } as const); + }); +} + +// ── ArticleTitle ─────────────────────────────────────────────────────────── + +interface FeedArticleTitleOpts + extends WithRefOpts, + ActiveProps<{ level: number | undefined }> {} + +export class FeedArticleTitleProvider extends Provider { + static create(opts: FeedArticleTitleOpts) { + return new FeedArticleTitleProvider(opts); + } + + readonly article: FeedArticleProvider; + + private constructor(opts: FeedArticleTitleOpts) { + super(opts, 'Feed', 'article-title', attrs['article-title']); + this.article = FeedArticleProvider.require(); + // A30: direct assignment. + this.article.titleId.current = opts.id.current; + } + + /** + * Heading level: explicit prop wins; otherwise derive from the article's + * nesting depth (top-level → h3, nested once → h4, etc.). Clamped to 1–6. + */ + readonly resolvedLevel = $derived.by(() => { + const explicit = this.opts.level.current; + if (explicit !== undefined && explicit !== null) return explicit; + const derived = 2 + this.article.level; // level=1 → 3; level=2 → 4; … + return Math.min(6, Math.max(1, derived)); + }); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'heading' as const, + 'aria-level': this.resolvedLevel + } as const) + ); +} + +// ── ArticleDescription ───────────────────────────────────────────────────── + +interface FeedArticleDescriptionOpts extends WithRefOpts {} + +export class FeedArticleDescriptionProvider extends Provider { + static create(opts: FeedArticleDescriptionOpts) { + return new FeedArticleDescriptionProvider(opts); + } + + readonly article: FeedArticleProvider; + + private constructor(opts: FeedArticleDescriptionOpts) { + super(opts, 'Feed', 'article-description', attrs['article-description']); + this.article = FeedArticleProvider.require(); + this.article.descriptionId.current = opts.id.current; + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps + } as const) + ); +} + +// ── Sentinel ─────────────────────────────────────────────────────────────── + +interface FeedSentinelOpts + extends WithRefOpts, + ActiveProps<{ + rootMargin: string; + threshold: number | number[]; + root: Element | Document | null; + onIntersect: (() => void) | undefined; + disabled: boolean; + }> {} + +/** + * Invisible probe element that fires a callback when scrolled into view. + * When nested inside a `Feed.Provider`, defaults to calling the Feed's + * `onLoadMore`. When disabled or unmounted, the observer is torn down. + */ +export class FeedSentinelProvider extends Provider { + static create(opts: FeedSentinelOpts) { + return new FeedSentinelProvider(opts); + } + + /** Optional — sentinel can live outside Feed.Provider (e.g. custom streams). */ + readonly feed: FeedProvider | undefined; + intersecting = $state(false); + + private constructor(opts: FeedSentinelOpts) { + super(opts, 'Feed', 'sentinel', attrs.sentinel); + this.feed = FeedProvider.get(); + + $effect(() => { + const el = opts.ref.current; + const disabled = opts.disabled.current; + if (!el || disabled || typeof IntersectionObserver === 'undefined') return; + const rootMargin = opts.rootMargin.current; + const threshold = opts.threshold.current; + const rootOpt = opts.root.current; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.target !== el) continue; + this.intersecting = entry.isIntersecting; + if (entry.isIntersecting) { + const handler = opts.onIntersect.current ?? this.feed?.opts.onLoadMore.current; + handler?.(); + } + } + }, + { + // `root: Document` isn't spec'd for IO — coerce to null. + root: rootOpt instanceof Document ? null : rootOpt, + rootMargin, + threshold + } + ); + observer.observe(el); + return () => observer.disconnect(); + }); + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'aria-hidden': true as const, + 'data-intersecting': boolToEmptyStrOrUndef(this.intersecting) + } as const) + ); +} diff --git a/src/uix/soma/components/feed/index.ts b/src/uix/soma/components/feed/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/feed/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/feed/langs.ts b/src/uix/soma/components/feed/langs.ts new file mode 100644 index 000000000..fd63888ce --- /dev/null +++ b/src/uix/soma/components/feed/langs.ts @@ -0,0 +1,4 @@ +export const FEED_LANGS = { + LABEL: '#?components.feed.label|Feed', + ARTICLE_LABEL: '#?components.feed.article-label|Article' +} as const; diff --git a/src/uix/soma/components/feed/types.ts b/src/uix/soma/components/feed/types.ts new file mode 100644 index 000000000..c677b9a4f --- /dev/null +++ b/src/uix/soma/components/feed/types.ts @@ -0,0 +1,154 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without } from '../../types'; +import type { PrimitiveDivAttributes } from '../../types'; + +/** Snippet props for `Feed.Provider`. */ +export type FeedProviderSnippetProps = { + busy: boolean; +}; + +/** Snippet props for `Feed.Article`. */ +export type FeedArticleSnippetProps = { + index: number; + total: number | undefined; +}; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for `Feed.Provider`. + * + * Implements the WAI-ARIA Feed pattern (`role="feed"`) — a section of + * scrollable content where assistive tech navigates between `role="article"` + * children using PageUp / PageDown. Each article exposes its position via + * `aria-posinset` / `aria-setsize`. + * + * Use Feed for content streams (activity feeds, chat histories, threaded + * comments) where the article count can grow (lazy-loaded). Use `GridList` + * or `Listbox` for bounded item selection. + */ +export type FeedProps = WithChild< + { + /** DOM id. Auto-generated if omitted. */ + id?: string; + + /** + * Total number of items across all pages. Used for each article's + * `aria-setsize`. `undefined` means unknown (AT reads "n of unknown"). + */ + totalItems?: number | undefined; + + /** Whether more items are currently loading. Sets `aria-busy`. @default false */ + busy?: boolean; + + /** + * Fires when the user reaches the end of the loaded items (PageDown on + * the last article, or infinite-scroll observer hit). Consumer loads + * more items and sets `busy` to `true` until the response arrives. + */ + onLoadMore?: () => void; + + /** + * Accessible name for the feed landmark. @default translated `'Feed'` + */ + 'aria-label'?: string; + /** ID of an external label. */ + 'aria-labelledby'?: string; + + children?: Snippet<[FeedProviderSnippetProps]>; + }, + FeedProviderSnippetProps +> & + Without; + +// ── Article ──────────────────────────────────────────────────────────────── + +/** + * Props for `Feed.Article`. + * + * Each article gets `role="article"`, `aria-posinset`, `aria-setsize`, and + * `aria-labelledby` wired to the `Feed.ArticleTitle` (when present). + * + * Articles are focusable (`tabindex=0`) so PageUp/PageDown navigation jumps + * between them. The focus handler on the Provider intercepts PageUp/PageDown. + */ +export type FeedArticleProps = WithChild< + { + id?: string; + /** + * 1-based position of this article in the full set (including + * unloaded pages). @default `index + 1` where `index` is the article's + * order in the Feed at mount time (auto-computed). + */ + posinset?: number; + children?: Snippet<[FeedArticleSnippetProps]>; + }, + FeedArticleSnippetProps +> & + Without< + PrimitiveDivAttributes, + { role?: unknown; 'aria-posinset'?: unknown; 'aria-setsize'?: unknown } + >; + +// ── ArticleTitle ─────────────────────────────────────────────────────────── + +/** + * Props for `Feed.ArticleTitle`. Linked via `aria-labelledby` on the + * enclosing `Article`. When `level` is omitted the heading level is derived + * from the enclosing `Feed.Thread` depth (top level → 3, nested → 4, etc.). + */ +export type FeedArticleTitleProps = WithChild<{ + id?: string; + /** Heading level (1–6). Auto-derived from thread depth when omitted. */ + level?: 1 | 2 | 3 | 4 | 5 | 6; +}> & + Without; + +// ── Thread ───────────────────────────────────────────────────────────────── + +/** + * Props for `Feed.Thread` — a nested sub-feed of articles (replies / + * sub-comments). Renders as `role="feed"` so assistive tech recognises the + * nested stream. Child articles automatically inherit the correct nesting + * level. + */ +export type FeedThreadProps = WithChild<{ + id?: string; +}> & + Without; + +// ── Sentinel ─────────────────────────────────────────────────────────────── + +/** + * Props for `Feed.Sentinel` — an invisible probe element that fires + * `onIntersect` when scrolled into view. Use to trigger `onLoadMore` when + * the user approaches the end of the feed. + */ +export type FeedSentinelProps = WithChild<{ + id?: string; + /** Root margin for the IntersectionObserver. @default '200px' */ + rootMargin?: string; + /** Intersection threshold 0–1. @default 0 */ + threshold?: number | number[]; + /** + * Observer root. `null` → viewport. @default null + */ + root?: Element | Document | null; + /** + * Callback invoked when the sentinel enters the observed zone. When the + * sentinel is inside a `Feed.Provider`, defaults to firing the Feed's + * `onLoadMore`. + */ + onIntersect?: () => void; + /** Disable the observer without unmounting. @default false */ + disabled?: boolean; +}> & + Without; + +// ── ArticleDescription ───────────────────────────────────────────────────── + +/** Props for `Feed.ArticleDescription` — linked via `aria-describedby`. */ +export type FeedArticleDescriptionProps = WithChild<{ + id?: string; +}> & + Without; diff --git a/src/uix/soma/components/grid-list/README.md b/src/uix/soma/components/grid-list/README.md new file mode 100644 index 000000000..ff2f79b9f --- /dev/null +++ b/src/uix/soma/components/grid-list/README.md @@ -0,0 +1,239 @@ +# GridList + +A selectable list with grid semantics (`role="grid"`). Rows are focusable via roving tabindex; cells host content. + +## When to use GridList vs Listbox vs Table vs TreeGrid + +| Component | ARIA role | Use when | +| -------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------- | +| **GridList** (this) | `grid` | Flat selectable list where rows have multiple interactive elements (actions, links, per-row checkboxes). | +| [`Listbox`](../listbox/README.md) | `listbox` | Flat selectable list where rows are atomic options (no inner actions). | +| [`Table`](../table/README.md) | `table` | Tabular data with sorting / filtering / paginating. "Expandable rows" → `Table.RowDetail`. | +| [`TreeGrid`](../tree-grid/README.md) | `treegrid` | Hierarchy + columns (file manager, org chart). `aria-level` auto-derived. | + +## Anatomy + +```svelte + + {#each users as user (user.id)} + + + + + {user.name} + {user.role} + + {/each} + +``` + +## Parts + +| Part | Element | Description | +| -------------------- | ---------- | ---------------------------------------------------------------------- | +| `Provider` | `
` | `role="grid"`, roving tabindex host, selection state. | +| `Row` | `
` | `role="row"`. One per item. Focusable. | +| `Cell` | `
` | `role="gridcell"`. Content slot within a row. | +| `SelectionCheckbox` | ` + +
+ {/snippet} + {#each users as user (user.id)} + + + {user.name} + + + + + {/each} + +``` + +Because the row has `role="row"` and the inner button is a real focusable element, Tab within a selected row reaches the "Remove" button while arrow keys keep navigating rows. + +### Field integration + +```svelte + + Team members + + + + + + {#if error}{error}{/if} + +``` diff --git a/src/uix/soma/components/grid-list/components/grid-list-cell.svelte b/src/uix/soma/components/grid-list/components/grid-list-cell.svelte new file mode 100644 index 000000000..d9eca683a --- /dev/null +++ b/src/uix/soma/components/grid-list/components/grid-list-cell.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/grid-list/components/grid-list-row.svelte b/src/uix/soma/components/grid-list/components/grid-list-row.svelte new file mode 100644 index 000000000..7e95069fb --- /dev/null +++ b/src/uix/soma/components/grid-list/components/grid-list-row.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/grid-list/components/grid-list-selection-checkbox.svelte b/src/uix/soma/components/grid-list/components/grid-list-selection-checkbox.svelte new file mode 100644 index 000000000..a57b5a782 --- /dev/null +++ b/src/uix/soma/components/grid-list/components/grid-list-selection-checkbox.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/grid-list/components/grid-list.svelte b/src/uix/soma/components/grid-list/components/grid-list.svelte new file mode 100644 index 000000000..a333a9dd2 --- /dev/null +++ b/src/uix/soma/components/grid-list/components/grid-list.svelte @@ -0,0 +1,72 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} + {#if name} + {#each value as v (v)} + + {/each} + {/if} +
+{/if} diff --git a/src/uix/soma/components/grid-list/exports.ts b/src/uix/soma/components/grid-list/exports.ts new file mode 100644 index 000000000..efc112821 --- /dev/null +++ b/src/uix/soma/components/grid-list/exports.ts @@ -0,0 +1,14 @@ +export { default as Provider } from './components/grid-list.svelte'; +export { default as Row } from './components/grid-list-row.svelte'; +export { default as Cell } from './components/grid-list-cell.svelte'; +export { default as SelectionCheckbox } from './components/grid-list-selection-checkbox.svelte'; + +export type { + GridListProps as ProviderProps, + GridListRowProps as RowProps, + GridListCellProps as CellProps, + GridListSelectionCheckboxProps as SelectionCheckboxProps, + GridListProviderSnippetProps as ProviderSnippetProps, + GridListRowSnippetProps as RowSnippetProps, + GridListSelectionMode +} from './types'; diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts new file mode 100644 index 000000000..cfb68f745 --- /dev/null +++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts @@ -0,0 +1,650 @@ +import { Provider, context, type WithRefOpts } from '../../provider'; +import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; +import { + readableActive, + type Active, + type ActiveProps, + type StateProps +} from '../../reactive'; +import type { + OnChangeFn, + Direction, + SomaKeyboardEvent, + SomaMouseEvent +} from '../../types'; +import { KEYS, getDirectionalKeys } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { FieldProvider } from '../field/field-provider.svelte'; +import { GRID_LIST_LANGS } from './langs'; +import type { GridListSelectionMode } from './types'; + +const attrs = createAttrs({ + component: 'grid-list', + parts: ['root', 'row', 'cell', 'selection-checkbox'] as const +}); + +registerContract({ + name: 'grid-list', + version: 1, + parts: { + root: [ + { attr: 'data-disabled', description: 'Disabled' }, + { attr: 'data-readonly', description: 'Read-only' }, + { attr: 'data-invalid', description: 'Invalid' }, + { attr: 'data-required', description: 'Required' }, + { attr: 'data-empty', description: 'No rows selected' }, + { + attr: 'data-selection-mode', + values: ['none', 'single', 'multiple'], + description: 'Selection mode' + } + ], + row: [ + { + attr: 'data-state', + values: ['selected', 'unselected'], + description: 'Selection state' + }, + { attr: 'data-highlighted', description: 'Currently focused row' }, + { attr: 'data-disabled', description: 'Row disabled' } + ], + cell: [], + 'selection-checkbox': [ + { + attr: 'data-state', + values: ['checked', 'unchecked'], + description: 'Matches parent row selection' + }, + { attr: 'data-disabled', description: 'Disabled' } + ] + } +}); + +// ── Root provider ────────────────────────────────────────────────────────── + +interface GridListOpts + extends WithRefOpts, + StateProps<{ value: string[] }>, + ActiveProps<{ + selectionMode: GridListSelectionMode; + loop: boolean; + typeahead: boolean; + typeaheadTimeout: number; + disabled: boolean; + readonly: boolean; + required: boolean; + invalid: boolean; + name: string | undefined; + ariaLabel: string | undefined; + ariaLabelledby: string | undefined; + onValueChange: OnChangeFn | undefined; + }> {} + +export class GridListProvider extends Provider { + static readonly ctx = context('GridList'); + static get(): GridListProvider | undefined { + return this.ctx.getOr(undefined) as GridListProvider | undefined; + } + static require(): GridListProvider { + return this.ctx.get(); + } + + static create(opts: GridListOpts) { + return new GridListProvider(opts); + } + + readonly soma = Soma.get(); + readonly field = FieldProvider.get(); + + private typeaheadBuffer = ''; + private typeaheadTimer: ReturnType | null = null; + /** Anchor for Shift+Click range selection. */ + private anchor: string | null = null; + + private constructor(opts: GridListOpts) { + super(opts, 'GridList', 'root', attrs.root, GridListProvider.ctx); + + $effect(() => { + return () => { + if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer); + }; + }); + } + + // ── Field-aware flags ─────────────────────────────────────────────────── + + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || (this.field?.isDisabled ?? false) + ); + readonly isReadonly = $derived.by( + () => this.opts.readonly.current || (this.field?.isReadonly ?? false) + ); + readonly isRequired = $derived.by( + () => this.opts.required.current || (this.field?.isRequired ?? false) + ); + readonly isInvalid = $derived.by( + () => this.opts.invalid.current || (this.field?.isInvalid ?? false) + ); + + readonly resolvedDir: Active = readableActive( + () => this.soma?.presentation.getDir() ?? 'ltr' + ); + + readonly resolvedAriaLabel: Active = readableActive(() => + this.opts.ariaLabelledby.current + ? undefined + : this.opts.ariaLabel.current || + this.soma?.langs.ts(GRID_LIST_LANGS.LABEL) || + undefined + ); + + readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0); + + // ── DOM queries ───────────────────────────────────────────────────────── + + /** Enabled row elements in DOM order, scoped to this root. */ + getRows(): HTMLElement[] { + const root = this.opts.ref.current; + if (!root) return []; + return Array.from( + root.querySelectorAll(`[${attrs.row}]:not([data-disabled])`) + ).filter((el) => el.closest(`[${attrs.root}]`) === root); + } + + /** All rows including disabled — used for range-select over contiguous rows. */ + private getAllRows(): HTMLElement[] { + const root = this.opts.ref.current; + if (!root) return []; + return Array.from(root.querySelectorAll(`[${attrs.row}]`)).filter( + (el) => el.closest(`[${attrs.root}]`) === root + ); + } + + /** Single lifted derivation for the roving-tabindex target — O(1) per row. */ + readonly rovingTargetEl = $derived.by(() => { + const rows = this.getRows(); + if (rows.length === 0) return undefined; + const selected = new Set(this.opts.value.current); + return ( + rows.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ?? + rows[0] + ); + }); + + // ── Selection ─────────────────────────────────────────────────────────── + + isSelected(value: string): boolean { + return this.opts.value.current.includes(value); + } + + select(value: string, mode: 'replace' | 'toggle' | 'range') { + if (this.isDisabled || this.isReadonly) return; + const selectionMode = this.opts.selectionMode.current; + if (selectionMode === 'none') return; + const current = this.opts.value.current; + + let next: string[]; + if (mode === 'range' && selectionMode === 'multiple' && this.anchor) { + next = this.computeRange(this.anchor, value); + } else if (mode === 'toggle' && selectionMode === 'multiple') { + next = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + } else { + // 'replace' or 'toggle' on single mode + next = current.length === 1 && current[0] === value ? current : [value]; + } + + if (next !== current) { + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + } + if (mode !== 'range') this.anchor = value; + } + + private computeRange(from: string, to: string): string[] { + const all = this.getAllRows().map((el) => el.getAttribute('data-value') ?? ''); + const i = all.indexOf(from); + const j = all.indexOf(to); + if (i < 0 || j < 0) return [to]; + const [lo, hi] = i <= j ? [i, j] : [j, i]; + return all.slice(lo, hi + 1); + } + + selectAll() { + if ( + this.isDisabled || + this.isReadonly || + this.opts.selectionMode.current !== 'multiple' + ) + return; + const next = this.getRows().map((el) => el.getAttribute('data-value') ?? ''); + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + } + + clear() { + if (this.isDisabled || this.isReadonly) return; + if (this.opts.value.current.length === 0) return; + this.opts.value.current = []; + this.opts.onValueChange.current?.([]); + this.anchor = null; + } + + // ── Keyboard navigation ───────────────────────────────────────────────── + + private focusAt(index: number) { + const rows = this.getRows(); + if (rows.length === 0) return; + const clamped = Math.max(0, Math.min(rows.length - 1, index)); + rows[clamped]?.focus(); + } + + /** + * Interactive descendants of a row, in tab order. Used for cell-level + * 2D navigation (ArrowLeft/Right within a row). + */ + private getFocusableCellsInRow(row: HTMLElement): HTMLElement[] { + const selector = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([data-grid-list-row])'; + return Array.from(row.querySelectorAll(selector)).filter( + (el) => el.closest(`[${attrs.row}]`) === row && !el.hasAttribute('data-grid-list-row') + ); + } + + /** ArrowRight on a row: move focus to the first focusable cell descendant. */ + private focusFirstCellInRow(row: HTMLElement): boolean { + const cells = this.getFocusableCellsInRow(row); + if (cells.length === 0) return false; + cells[0]!.focus(); + return true; + } + + /** ArrowLeft/Right within a row: cycle between focusable descendants. */ + handleCellKeydown(e: SomaKeyboardEvent) { + if (this.isDisabled) return; + const dir = this.resolvedDir.current; + const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal'); + const target = e.target as HTMLElement | null; + if (!target) return; + const row = target.closest(`[${attrs.row}]`); + if (!row) return; + + if (e.key === nextKey) { + const cells = this.getFocusableCellsInRow(row); + const idx = cells.indexOf(target); + if (idx < 0) return; + if (idx < cells.length - 1) { + e.preventDefault(); + e.stopPropagation(); + cells[idx + 1]!.focus(); + } + return; + } + if (e.key === prevKey) { + const cells = this.getFocusableCellsInRow(row); + const idx = cells.indexOf(target); + if (idx < 0) return; + e.preventDefault(); + e.stopPropagation(); + if (idx === 0) { + // Return focus to the row itself. + row.focus(); + } else { + cells[idx - 1]!.focus(); + } + return; + } + // ArrowUp/Down / Home / End bubble up to the row handler so vertical + // navigation still works while focus is inside a cell. + } + + handleRowKeydown(e: SomaKeyboardEvent) { + if (this.isDisabled) return; + + const dir = this.resolvedDir.current; + // GridList is always vertical for navigation (grid pattern). + const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical'); + const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal'); + const rows = this.getRows(); + if (rows.length === 0) return; + + const currentIndex = rows.indexOf(e.currentTarget); + const loop = this.opts.loop.current; + + // Horizontal — only handled when focus is on the row itself (not a cell). + // From the row, ArrowRight enters the first focusable cell. ArrowLeft + // is a no-op (you're already at the row level). + if (e.target === e.currentTarget && e.key === hNextKey) { + if (this.focusFirstCellInRow(e.currentTarget)) { + e.preventDefault(); + return; + } + } + if (e.target === e.currentTarget && e.key === hPrevKey) { + // no-op; consume so the event doesn't scroll the viewport. + return; + } + + if (e.key === nextKey) { + e.preventDefault(); + const next = loop + ? (currentIndex + 1) % rows.length + : Math.min(currentIndex + 1, rows.length - 1); + this.focusAt(next); + return; + } + if (e.key === prevKey) { + e.preventDefault(); + const prev = loop + ? (currentIndex - 1 + rows.length) % rows.length + : Math.max(currentIndex - 1, 0); + this.focusAt(prev); + return; + } + if (e.key === KEYS.HOME) { + e.preventDefault(); + this.focusAt(0); + return; + } + if (e.key === KEYS.END) { + e.preventDefault(); + this.focusAt(rows.length - 1); + return; + } + if (e.key === KEYS.PAGE_UP) { + e.preventDefault(); + this.focusAt(Math.max(currentIndex - 10, 0)); + return; + } + if (e.key === KEYS.PAGE_DOWN) { + e.preventDefault(); + this.focusAt(Math.min(currentIndex + 10, rows.length - 1)); + return; + } + + if (e.key === KEYS.SPACE) { + e.preventDefault(); + const value = e.currentTarget.getAttribute('data-value') ?? undefined; + if (value !== undefined) { + const mode = + e.shiftKey && this.opts.selectionMode.current === 'multiple' + ? 'range' + : e.ctrlKey || e.metaKey + ? 'toggle' + : this.opts.selectionMode.current === 'multiple' + ? 'toggle' + : 'replace'; + this.select(value, mode); + } + return; + } + + if ( + (e.ctrlKey || e.metaKey) && + (e.key === 'a' || e.key === 'A') && + this.opts.selectionMode.current === 'multiple' + ) { + e.preventDefault(); + this.selectAll(); + return; + } + + if (e.key === KEYS.ESCAPE) { + if (this.opts.value.current.length > 0) { + e.preventDefault(); + this.clear(); + } + return; + } + + if ( + this.opts.typeahead.current && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + e.key.length === 1 && + /\S/.test(e.key) + ) { + this.handleTypeahead(e.key, currentIndex); + } + } + + private handleTypeahead(char: string, fromIndex: number) { + this.typeaheadBuffer += char.toLowerCase(); + if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer); + this.typeaheadTimer = setTimeout(() => { + this.typeaheadBuffer = ''; + }, this.opts.typeaheadTimeout.current); + + const rows = this.getRows(); + if (rows.length === 0) return; + const needle = this.typeaheadBuffer; + const start = needle.length === 1 ? fromIndex + 1 : fromIndex; + for (let i = 0; i < rows.length; i++) { + const idx = (start + i) % rows.length; + const el = rows[idx]; + if (!el) continue; + const text = (el.getAttribute('data-text-value') || el.textContent || '') + .trim() + .toLowerCase(); + if (text.startsWith(needle)) { + el.focus(); + return; + } + } + } + + handleRowClick(value: string, e: SomaMouseEvent) { + if (this.isDisabled || this.isReadonly) return; + const mode = + e.shiftKey && this.opts.selectionMode.current === 'multiple' + ? 'range' + : (e.ctrlKey || e.metaKey) && this.opts.selectionMode.current === 'multiple' + ? 'toggle' + : 'replace'; + this.select(value, mode); + e.currentTarget.focus(); + } + + // ── Root props ────────────────────────────────────────────────────────── + + readonly snippetProps = $derived.by(() => ({ + value: this.opts.value.current, + isEmpty: this.isEmpty, + selectAll: () => this.selectAll(), + clear: () => this.clear() + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'grid' as const, + 'aria-label': this.resolvedAriaLabel.current, + 'aria-labelledby': this.opts.ariaLabelledby.current, + 'aria-multiselectable': + this.opts.selectionMode.current === 'multiple' ? true : undefined, + 'aria-disabled': this.isDisabled ? true : undefined, + 'aria-readonly': this.isReadonly ? true : undefined, + 'aria-invalid': this.isInvalid ? true : undefined, + 'aria-required': this.isRequired ? true : undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), + 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), + 'data-required': boolToEmptyStrOrUndef(this.isRequired), + 'data-empty': boolToEmptyStrOrUndef(this.isEmpty), + 'data-selection-mode': this.opts.selectionMode.current + } as const) + ); +} + +// ── Row ──────────────────────────────────────────────────────────────────── + +interface GridListRowOpts + extends WithRefOpts, + ActiveProps<{ + value: string; + textValue: string | undefined; + disabled: boolean; + }> {} + +export class GridListRowProvider extends Provider { + static create(opts: GridListRowOpts) { + return new GridListRowProvider(opts); + } + + readonly provider: GridListProvider; + + private constructor(opts: GridListRowOpts) { + super(opts, 'GridList', 'row', attrs.row); + this.provider = GridListProvider.require(); + } + + readonly isSelected = $derived.by(() => + this.provider.isSelected(this.opts.value.current) + ); + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || this.provider.isDisabled + ); + readonly isRovingTarget = $derived.by( + () => this.opts.ref.current === this.provider.rovingTargetEl + ); + + readonly onclick = (e: MouseEvent) => { + if (this.isDisabled) return; + this.provider.handleRowClick(this.opts.value.current, e as SomaMouseEvent); + }; + + readonly onkeydown = (e: KeyboardEvent) => { + this.provider.handleRowKeydown(e as SomaKeyboardEvent); + }; + + readonly snippetProps = $derived.by(() => ({ + selected: this.isSelected, + disabled: this.isDisabled + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'row' as const, + tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1, + 'aria-selected': + this.provider.opts.selectionMode.current === 'none' + ? undefined + : this.isSelected, + 'aria-disabled': this.isDisabled ? true : undefined, + 'data-value': this.opts.value.current, + 'data-text-value': this.opts.textValue.current, + 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const), + 'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + onclick: this.onclick, + onkeydown: this.onkeydown + } as const) + ); +} + +// ── Cell ─────────────────────────────────────────────────────────────────── + +interface GridListCellOpts extends WithRefOpts {} + +export class GridListCellProvider extends Provider { + static create(opts: GridListCellOpts) { + return new GridListCellProvider(opts); + } + + readonly provider: GridListProvider; + + private constructor(opts: GridListCellOpts) { + super(opts, 'GridList', 'cell', attrs.cell); + this.provider = GridListProvider.require(); + } + + /** + * Delegate horizontal arrow navigation to the Provider so the user can + * move between focusable descendants of a row with ArrowLeft/Right, and + * ArrowLeft from the first cell returns focus to the row. + */ + readonly onkeydown = (e: KeyboardEvent) => { + this.provider.handleCellKeydown(e as SomaKeyboardEvent); + }; + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'gridcell' as const, + onkeydown: this.onkeydown + } as const) + ); +} + +// ── SelectionCheckbox ────────────────────────────────────────────────────── + +interface GridListSelectionCheckboxOpts + extends WithRefOpts, + ActiveProps<{ ariaLabel: string | undefined }> {} + +export class GridListSelectionCheckboxProvider extends Provider { + static create(opts: GridListSelectionCheckboxOpts) { + return new GridListSelectionCheckboxProvider(opts); + } + + readonly provider: GridListProvider; + /** Resolved from the nearest enclosing Row's value. */ + private rowValue: string | null = null; + + private constructor(opts: GridListSelectionCheckboxOpts) { + super(opts, 'GridList', 'selection-checkbox', attrs['selection-checkbox']); + this.provider = GridListProvider.require(); + } + + /** Read the enclosing row's value lazily (on click). */ + private findRowValue(el: HTMLElement | null): string | null { + if (!el) return null; + const row = el.closest(`[${attrs.row}]`); + return row?.getAttribute('data-value') ?? null; + } + + readonly isChecked = $derived.by(() => { + if (!this.rowValue) return false; + return this.provider.isSelected(this.rowValue); + }); + + readonly onclick = (e: MouseEvent) => { + e.stopPropagation(); + const target = e.currentTarget as HTMLElement; + this.rowValue = this.findRowValue(target); + if (!this.rowValue) return; + this.provider.select(this.rowValue, 'toggle'); + }; + + readonly resolvedAriaLabel = $derived.by( + () => + this.opts.ariaLabel.current || + this.provider.soma?.langs.ts(GRID_LIST_LANGS.SELECT_ROW) || + undefined + ); + + readonly props = $derived.by(() => { + // Read current row association via DOM for aria-state — safe on every render. + const el = this.opts.ref.current; + this.rowValue = this.findRowValue(el); + const checked = this.rowValue + ? this.provider.isSelected(this.rowValue) + : false; + return this.assertProps({ + ...this.baseProps, + type: 'button' as const, + role: 'checkbox' as const, + 'aria-checked': checked, + 'aria-label': this.resolvedAriaLabel, + 'aria-disabled': this.provider.isDisabled || this.provider.isReadonly || undefined, + 'data-state': checked ? ('checked' as const) : ('unchecked' as const), + 'data-disabled': boolToEmptyStrOrUndef( + this.provider.isDisabled || this.provider.isReadonly + ), + onclick: this.onclick + } as const); + }); +} diff --git a/src/uix/soma/components/grid-list/index.ts b/src/uix/soma/components/grid-list/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/grid-list/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/grid-list/langs.ts b/src/uix/soma/components/grid-list/langs.ts new file mode 100644 index 000000000..4b484459f --- /dev/null +++ b/src/uix/soma/components/grid-list/langs.ts @@ -0,0 +1,5 @@ +export const GRID_LIST_LANGS = { + LABEL: '#?components.grid-list.label|Grid list', + SELECT_ROW: '#?components.grid-list.select-row|Select row', + SELECT_ALL: '#?components.grid-list.select-all|Select all' +} as const; diff --git a/src/uix/soma/components/grid-list/types.ts b/src/uix/soma/components/grid-list/types.ts new file mode 100644 index 000000000..be3195240 --- /dev/null +++ b/src/uix/soma/components/grid-list/types.ts @@ -0,0 +1,132 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without, OnChangeFn } from '../../types'; +import type { + PrimitiveDivAttributes, + PrimitiveButtonAttributes +} from '../../types'; + +/** Selection mode. */ +export type GridListSelectionMode = 'none' | 'single' | 'multiple'; + +/** Snippet props for `GridList.Provider`. */ +export type GridListProviderSnippetProps = { + value: string[]; + isEmpty: boolean; + selectAll: () => void; + clear: () => void; +}; + +/** Snippet props for `GridList.Row`. */ +export type GridListRowSnippetProps = { + selected: boolean; + disabled: boolean; +}; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for `GridList.Provider`. + * + * A selectable list with grid semantics (`role="grid"`) — rows are focusable, + * cells are the content slots. Supports keyboard navigation, multi-select via + * Shift/Ctrl click, typeahead, and form integration via a hidden input. + * + * Use `GridList` over `Listbox` when rows contain multiple interactive + * elements (actions, links, checkboxes) — the `grid` role lets assistive + * tech navigate those with Tab / cell-level commands. Use `Listbox` when + * rows are atomic options. + */ +export type GridListProps = WithChild< + { + /** DOM id. Auto-generated if omitted. */ + id?: string; + + /** Selected row values. Bindable. @default [] */ + value?: string[]; + /** Fires on selection change. */ + onValueChange?: OnChangeFn; + + /** Selection mode. @default 'single' */ + selectionMode?: GridListSelectionMode; + + /** Whether arrow navigation wraps at the ends. @default false */ + loop?: boolean; + /** Whether typing characters jumps focus to matching row. @default true */ + typeahead?: boolean; + /** Ms before the typeahead buffer resets. @default 500 */ + typeaheadTimeout?: number; + + // Flags — OR-merged with enclosing `Field.Provider` + /** @default false */ + disabled?: boolean; + /** @default false */ + readonly?: boolean; + /** @default false */ + required?: boolean; + /** @default false */ + invalid?: boolean; + + /** Name for form submission. */ + name?: string; + + /** + * Accessible name for the grid. Ignored when `aria-labelledby` is set + * or when inside a `Field.Provider` with a `Field.Label`. + * @default translated `'Grid list'` + */ + 'aria-label'?: string; + /** ID of an external element that labels this grid. */ + 'aria-labelledby'?: string; + + children?: Snippet<[GridListProviderSnippetProps]>; + }, + GridListProviderSnippetProps +> & + Without; + +// ── Row ──────────────────────────────────────────────────────────────────── + +/** + * Props for `GridList.Row`. + * + * Rendered as `role="row"`. One row per `value`. Participates in roving + * tabindex — only the selected (or first) row has `tabindex=0`. + */ +export type GridListRowProps = WithChild< + { + id?: string; + /** Unique value for this row. Used for selection + typeahead. */ + value: string; + /** + * Optional text used by typeahead search. Falls back to the row's + * `textContent`. Useful when the visible text is stylized (icons only, etc). + */ + textValue?: string; + /** Whether the row is disabled. @default false */ + disabled?: boolean; + children?: Snippet<[GridListRowSnippetProps]>; + }, + GridListRowSnippetProps +> & + Without; + +// ── Cell ─────────────────────────────────────────────────────────────────── + +/** Props for `GridList.Cell` — rendered as `role="gridcell"`. */ +export type GridListCellProps = WithChild<{ + id?: string; +}> & + Without; + +// ── SelectionCheckbox ────────────────────────────────────────────────────── + +/** + * Props for `GridList.SelectionCheckbox` — toggles row selection, with an + * accessible label that matches the row's text. + */ +export type GridListSelectionCheckboxProps = WithChild<{ + id?: string; + /** Accessible name override. @default translated `'Select row'` */ + 'aria-label'?: string; +}> & + Without; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index a82da0bb2..0d62c47b3 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -1,9 +1,12 @@ export * as Accordion from './accordion'; export * as AlertDialog from './alert-dialog'; +export * as Announce from './announce'; +export * as Avatar from './avatar'; export * as Breadcrumb from './breadcrumb'; export * as Calendar from './calendar'; export * as Carousel from './carousel'; export * as Checkbox from './checkbox'; +export * as Clipboard from './clipboard'; export * as Collapsible from './collapsible'; export * as ColorField from './color-field'; export * as ColorPicker from './color-picker'; @@ -15,23 +18,29 @@ export * as DatePicker from './date-picker'; export * as DateRangeField from './date-range-field'; export * as DateRangePicker from './date-range-picker'; export * as Dialog from './dialog'; +export * as DragDrop from './drag-drop'; export * as DropdownMenu from './dropdown-menu'; export * as Editable from './editable'; +export * as Feed from './feed'; export * as Field from './field'; export * as FileUpload from './file-upload'; export * as Form from './form'; +export * as GridList from './grid-list'; export * as LinkPreview from './link-preview'; export * as Listbox from './listbox'; export * as Menubar from './menubar'; +export * as Meter from './meter'; export * as NavigationMenu from './navigation-menu'; export * as NumberField from './number-field'; export * as Pagination from './pagination'; export * as PinInput from './pin-input'; export * as Popover from './popover'; +export * as Progress from './progress'; export * as RadioGroup from './radio-group'; export * as RangeCalendar from './range-calendar'; export * as RatingGroup from './rating-group'; export * as ScrollArea from './scroll-area'; +export * as SearchField from './search-field'; export * as Select from './select'; export * as Slider from './slider'; export * as Splitter from './splitter'; @@ -39,6 +48,7 @@ export * as Stepper from './stepper'; export * as Switch from './switch'; export * as Table from './table'; export * as Tabs from './tabs'; +export * as TagGroup from './tag-group'; export * as TagsInput from './tags-input'; export * as TimeField from './time-field'; export * as TimePicker from './time-picker'; @@ -49,6 +59,7 @@ export * as Toggle from './toggle'; export * as ToggleGroup from './toggle-group'; export * as Toolbar from './toolbar'; export * as Tooltip from './tooltip'; +export * as TreeGrid from './tree-grid'; export * as TreeView from './tree-view'; export * as VirtualGrid from './virtual-grid'; export * as VirtualList from './virtual-list'; diff --git a/src/uix/soma/components/meter/README.md b/src/uix/soma/components/meter/README.md new file mode 100644 index 000000000..8ccc418ce --- /dev/null +++ b/src/uix/soma/components/meter/README.md @@ -0,0 +1,143 @@ +# Meter + +A `role="meter"` for a known-range value — disk usage, battery, score, rating strength. Unlike `Progress`, the value is static (not tied to a duration), and the component exposes zone semantics via `low` / `high` / `optimum` so themes can color each zone differently. + +Rule of thumb: use `Progress` for "how much of this task is done", use `Meter` for "where does this value sit in the range". + +## Anatomy + +```svelte + + + +``` + +## Parts + +| Part | Element | Description | +| ----------- | -------- | ------------------------------------------------------------------------ | +| `Provider` | `
` | `role="meter"`. Emits ARIA value attrs, `data-state`, `--soma-meter-value-pct`. | +| `Indicator` | `
` | Decorative fill. Inherits `data-state` for per-zone styling. | + +## Props + +### `Provider` + +| Prop | Type | Default | Description | +| ----------------- | -------- | ------------------------- | -------------------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `value` | `number` | `0` | Current value. | +| `min` | `number` | `0` | Lower bound. | +| `max` | `number` | `100` | Upper bound. | +| `low` | `number` | `min` | Upper edge of the "below" zone. | +| `high` | `number` | `max` | Lower edge of the "above" zone. | +| `optimum` | `number` | `(low + high) / 2` | Optimum target. Read-only ARIA hint; doesn't affect zone computation. | +| `valueText` | `string` | `${value} / ${max}` | Override for `aria-valuetext`. | +| `aria-label` | `string` | translated `Meter` | Accessible name. | +| `aria-labelledby` | `string` | — | External label. | + +## ARIA + +| Part | Attribute | Value | +| -------- | ----------------- | ------------------------------- | +| Provider | `role` | `meter` | +| Provider | `aria-valuemin` | Value of `min` | +| Provider | `aria-valuemax` | Value of `max` | +| Provider | `aria-valuenow` | Value of `value` | +| Provider | `aria-valuetext` | Resolved value text | +| Provider | `aria-label` | Translated default or override | + +Note: `aria-valuelow`/`aria-valuehigh`/`aria-valueoptimum` are **not** emitted — those were part of the original HTML `` element only, not the `role="meter"` ARIA semantics. Zones are expressed via `data-state` for styling instead. + +## Data Attributes + +| Part | Attribute | Values | +| --------- | --------------------- | ------------------------------------- | +| Provider | `data-meter` | Always present | +| Provider | `data-state` | `below` \| `optimum` \| `above` | +| Provider | `data-value` | Numeric value | +| Provider | `data-min` | Numeric min | +| Provider | `data-max` | Numeric max | +| Indicator | `data-meter-indicator`| Always present | +| Indicator | `data-state` | Inherited from provider | + +Zone resolution: +- `value < low` → `below` +- `value > high` → `above` +- otherwise → `optimum` + +## CSS Variables + +| Variable | Part | Description | +| -------------------------- | -------- | -------------------------------------------------- | +| `--soma-meter-value-pct` | Provider | 0–100 percentage, clamped to `[min, max]`. | + +## Comparison + +| Feature | Soma | Radix | Ark UI | bits-ui | react-aria | +| ------------------------------------ | :--: | :---: | :----: | :-----: | :--------: | +| Dedicated component | ✅ | ❌¹ | ✅ | ❌ | ✅ | +| `role="meter"` | ✅ | — | ✅ | — | ✅ | +| `low` / `high` / `optimum` props | ✅ | — | ✅ | — | ✅² | +| `data-state` zone attribute | ✅ | — | ✅ | — | ❌ | +| `--soma-meter-value-pct` CSS var | ✅ | — | ✅ | — | ❌ | +| Translated default `aria-label` | ✅ | — | ❌ | — | ❌ | +| Indicator part | ✅ | — | ✅ | — | ❌ | +| `aria-valuetext` override | ✅ | — | ✅ | — | ✅ | + +¹ Radix does not ship a Meter primitive. +² react-aria's `Meter` returns `formatOptions` for percentage/unit formatting. Soma pushes that to `valueText` so the consumer controls exact text. + +## Usage + +### Battery indicator + +```svelte + + + +``` + +### Password strength (3 zones → 3 colors) + +```svelte + + + +``` + +```css +[data-meter] { + position: relative; + height: 8px; + border-radius: 4px; + background: #e2e8f0; + overflow: hidden; +} +[data-meter-indicator] { + position: absolute; + inset: 0; + width: calc(var(--soma-meter-value-pct, 0) * 1%); + transition: width 150ms ease-out; +} +[data-meter][data-state='below'] [data-meter-indicator] { background: #dc2626; } +[data-meter][data-state='optimum'] [data-meter-indicator] { background: #16a34a; } +[data-meter][data-state='above'] [data-meter-indicator] { background: #f59e0b; } +``` + +### Disk usage (`above` is bad) + +```svelte + + + +``` + +Zone coloring is inverted here — `above` (near-full) should be red, `optimum` (plenty free) should be green. Because soma emits `data-state` values instead of baking in a semantics, the theme is free to assign colors as the domain requires. diff --git a/src/uix/soma/components/meter/components/meter-indicator.svelte b/src/uix/soma/components/meter/components/meter-indicator.svelte new file mode 100644 index 000000000..4ac7f42c3 --- /dev/null +++ b/src/uix/soma/components/meter/components/meter-indicator.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/meter/components/meter.svelte b/src/uix/soma/components/meter/components/meter.svelte new file mode 100644 index 000000000..907fa5a4c --- /dev/null +++ b/src/uix/soma/components/meter/components/meter.svelte @@ -0,0 +1,53 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/meter/exports.ts b/src/uix/soma/components/meter/exports.ts new file mode 100644 index 000000000..40d738749 --- /dev/null +++ b/src/uix/soma/components/meter/exports.ts @@ -0,0 +1,8 @@ +export { default as Provider } from './components/meter.svelte'; +export { default as Indicator } from './components/meter-indicator.svelte'; + +export type { + MeterProps as ProviderProps, + MeterIndicatorProps as IndicatorProps, + MeterZone +} from './types'; diff --git a/src/uix/soma/components/meter/index.ts b/src/uix/soma/components/meter/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/meter/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/meter/langs.ts b/src/uix/soma/components/meter/langs.ts new file mode 100644 index 000000000..969677c1f --- /dev/null +++ b/src/uix/soma/components/meter/langs.ts @@ -0,0 +1,4 @@ +/** Idlangref constants for the Meter component. */ +export const METER_LANGS = { + LABEL: '#?components.meter.label|Meter', +} as const; diff --git a/src/uix/soma/components/meter/meter-provider.svelte.ts b/src/uix/soma/components/meter/meter-provider.svelte.ts new file mode 100644 index 000000000..d47793554 --- /dev/null +++ b/src/uix/soma/components/meter/meter-provider.svelte.ts @@ -0,0 +1,163 @@ +import { Provider, context, type WithRefOpts } from '../../provider'; +import { createAttrs, registerContract } from '../../attrs'; +import { readableActive, type Active, type ActiveProps } from '../../reactive'; +import { Soma } from '../../core/soma.svelte'; +import { METER_LANGS } from './langs'; +import type { MeterZone } from './types'; + +const attrs = createAttrs({ + component: 'meter', + parts: ['root', 'indicator'] as const +}); + +registerContract({ + name: 'meter', + version: 1, + parts: { + root: [ + { + attr: 'data-state', + values: ['below', 'optimum', 'above'], + description: 'Zone relative to low/high thresholds' + }, + { attr: 'data-value', description: 'Current value' }, + { attr: 'data-min', description: 'Min value' }, + { attr: 'data-max', description: 'Max value' } + ], + indicator: [ + { + attr: 'data-state', + values: ['below', 'optimum', 'above'], + description: 'Zone (inherited from root)' + } + ] + } +}); + +function computeZone( + value: number, + low: number, + high: number +): MeterZone { + if (value < low) return 'below'; + if (value > high) return 'above'; + return 'optimum'; +} + +// ── Root provider ────────────────────────────────────────────────────────── + +interface MeterOpts + extends WithRefOpts, + ActiveProps<{ + value: number; + min: number; + max: number; + low: number | undefined; + high: number | undefined; + optimum: number | undefined; + ariaLabel: string | undefined; + ariaLabelledby: string | undefined; + valueText: string | undefined; + }> {} + +export class MeterProvider extends Provider { + static readonly ctx = context('Meter'); + static get(): MeterProvider | undefined { + return this.ctx.getOr(undefined) as MeterProvider | undefined; + } + static require(): MeterProvider { + return this.ctx.get(); + } + + static create(opts: MeterOpts) { + return new MeterProvider(opts); + } + + readonly soma = Soma.get(); + + private constructor(opts: MeterOpts) { + super(opts, 'Meter', 'root', attrs.root, MeterProvider.ctx); + } + + readonly resolvedLow = $derived.by( + () => this.opts.low.current ?? this.opts.min.current + ); + readonly resolvedHigh = $derived.by( + () => this.opts.high.current ?? this.opts.max.current + ); + readonly resolvedOptimum = $derived.by( + () => this.opts.optimum.current ?? (this.resolvedLow + this.resolvedHigh) / 2 + ); + + readonly state: MeterZone = $derived.by(() => + computeZone(this.opts.value.current, this.resolvedLow, this.resolvedHigh) + ); + + /** 0–100 percentage for CSS vars. Clamped to the min/max range. */ + readonly percentage = $derived.by(() => { + const v = this.opts.value.current; + const min = this.opts.min.current; + const max = this.opts.max.current; + if (max <= min) return 0; + return Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100)); + }); + + readonly resolvedAriaLabel: Active = readableActive(() => { + if (this.opts.ariaLabelledby.current) return undefined; + return ( + this.opts.ariaLabel.current || + this.soma?.langs.ts(METER_LANGS.LABEL) || + undefined + ); + }); + + readonly resolvedValueText = $derived.by(() => { + const explicit = this.opts.valueText.current; + if (explicit) return explicit; + return `${this.opts.value.current} / ${this.opts.max.current}`; + }); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'meter' as const, + 'aria-valuemin': this.opts.min.current, + 'aria-valuemax': this.opts.max.current, + 'aria-valuenow': this.opts.value.current, + 'aria-valuetext': this.resolvedValueText, + 'aria-label': this.resolvedAriaLabel.current, + 'aria-labelledby': this.opts.ariaLabelledby.current, + 'data-state': this.state, + 'data-value': this.opts.value.current, + 'data-min': this.opts.min.current, + 'data-max': this.opts.max.current, + style: { + '--soma-meter-value-pct': `${this.percentage}` + } + } as const) + ); +} + +// ── Indicator ────────────────────────────────────────────────────────────── + +interface MeterIndicatorOpts extends WithRefOpts {} + +export class MeterIndicatorProvider extends Provider { + static create(opts: MeterIndicatorOpts) { + return new MeterIndicatorProvider(opts); + } + + readonly provider: MeterProvider; + + private constructor(opts: MeterIndicatorOpts) { + super(opts, 'Meter', 'indicator', attrs.indicator); + this.provider = MeterProvider.require(); + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'data-state': this.provider.state + } as const) + ); +} diff --git a/src/uix/soma/components/meter/types.ts b/src/uix/soma/components/meter/types.ts new file mode 100644 index 000000000..71e6fc92a --- /dev/null +++ b/src/uix/soma/components/meter/types.ts @@ -0,0 +1,72 @@ +import type { WithChild, Without } from '../../types'; +import type { PrimitiveDivAttributes } from '../../types'; + +/** Zone derived from value vs low/high/optimum thresholds. */ +export type MeterZone = 'below' | 'above' | 'optimum'; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for the root `Meter.Provider`. + * + * Renders `
` with the full ARIA valuenow/min/max triplet. + * Unlike `Progress` (which tracks completion over time), `Meter` represents + * a fixed-point measurement within a range — disk usage, password strength, + * battery level, CPU load. + * + * The `low` / `high` / `optimum` thresholds classify the current value into + * one of three zones (`below` / `optimum` / `above`), exposed via + * `data-state` for CSS styling. + * + * The `Indicator` sub-part is a scaled fill, positioned via the + * `--soma-meter-value-pct` CSS variable. + */ +export type MeterProps = WithChild<{ + /** DOM id. Auto-generated if omitted. */ + id?: string; + + /** Current value. Required. @default 0 */ + value?: number; + /** Minimum. @default 0 */ + min?: number; + /** Maximum. @default 100 */ + max?: number; + + /** + * Lower threshold. When `value < low` → `data-state='below'`. + * Defaults to `min` (no below zone). + */ + low?: number; + /** + * Upper threshold. When `value > high` → `data-state='above'`. + * Defaults to `max` (no above zone). + */ + high?: number; + /** + * Optimum target within `[low, high]`. Used by assistive tech to + * describe whether the current value is trending toward or away from + * the ideal. Defaults to the midpoint of `[low, high]`. + */ + optimum?: number; + + /** Override the default accessible label. Defaults to translated `'Meter'`. */ + 'aria-label'?: string; + /** External element that labels the Meter. */ + 'aria-labelledby'?: string; + + /** Optional value text for screen readers. Falls back to `value / max`. */ + valueText?: string; +}> & + Without; + +// ── Indicator ────────────────────────────────────────────────────────────── + +/** + * Props for `Meter.Indicator` — decorative scaled fill. Style via the + * `--soma-meter-value-pct` CSS variable and the `data-state` attribute + * (`below` / `optimum` / `above`) on the root. + */ +export type MeterIndicatorProps = WithChild<{ + id?: string; +}> & + Without; diff --git a/src/uix/soma/components/pagination/langs.ts b/src/uix/soma/components/pagination/langs.ts index d2939748a..4a2d4f768 100644 --- a/src/uix/soma/components/pagination/langs.ts +++ b/src/uix/soma/components/pagination/langs.ts @@ -3,4 +3,5 @@ export const PAGINATION_LANGS = { LABEL: '#?components.pagination.label|Pagination', PREV: '#?common.buttons.prev|Previous page', NEXT: '#?common.buttons.next|Next page', + PAGE: '#?components.pagination.page|Page {{value}}', } as const; diff --git a/src/uix/soma/components/pagination/pagination-provider.svelte.ts b/src/uix/soma/components/pagination/pagination-provider.svelte.ts index c2e11c2fd..5c823b65c 100644 --- a/src/uix/soma/components/pagination/pagination-provider.svelte.ts +++ b/src/uix/soma/components/pagination/pagination-provider.svelte.ts @@ -322,7 +322,9 @@ export class PaginationItemProvider extends Provider { type: 'button' as const, 'aria-current': this.isSelected ? ('page' as const) : undefined, 'aria-label': - this.provider.soma?.langs.t('soma.pagination.page', { page: this.opts.value.current }) || `Page ${this.opts.value.current}`, + this.provider.soma?.langs.t(PAGINATION_LANGS.PAGE, { + value: String(this.opts.value.current) + }) || `Page ${this.opts.value.current}`, 'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-value': this.opts.value.current, 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), diff --git a/src/uix/soma/components/progress/README.md b/src/uix/soma/components/progress/README.md new file mode 100644 index 000000000..ff826dbcf --- /dev/null +++ b/src/uix/soma/components/progress/README.md @@ -0,0 +1,136 @@ +# Progress + +A `role="progressbar"` that shows the completion of a known-duration task. Supports determinate (numeric) and indeterminate (`value=null`) modes. The decorative Indicator is driven by the `--soma-progress-value-pct` CSS custom property. + +For a value rendered inside a known range (disk usage, score), use `Meter` instead. + +## Anatomy + +```svelte + + + +``` + +## Parts + +| Part | Element | Description | +| ----------- | -------- | ------------------------------------------------------------------------------------- | +| `Provider` | `
` | `role="progressbar"`. Emits ARIA value attrs + `--soma-progress-value-pct`. | +| `Indicator` | `
` | Decorative fill. Read `--soma-progress-value-pct` and size accordingly. | + +## Props + +### `Provider` + +| Prop | Type | Default | Description | +| ------------------ | ------------------------ | -------------------- | ---------------------------------------------------------------------------------------- | +| `id` | `string` | auto | DOM id. | +| `value` | `number \| null` | `0` | Current progress. `null` → indeterminate (no ARIA value, `data-state='indeterminate'`). | +| `min` | `number` | `0` | Lower bound. | +| `max` | `number` | `100` | Upper bound. | +| `valueText` | `string` | `${value} / ${max}` | Override for `aria-valuetext`. Provide when the percentage would be misleading (e.g. `"75 of 100 MB"`). | +| `aria-label` | `string` | translated `Loading` | Accessible name. Ignored when `aria-labelledby` is set. | +| `aria-labelledby` | `string` | — | External label. | + +## ARIA + +| Part | Attribute | Value | +| -------- | ----------------- | -------------------------------------------------- | +| Provider | `role` | `progressbar` | +| Provider | `aria-valuemin` | Value of `min` | +| Provider | `aria-valuemax` | Value of `max` | +| Provider | `aria-valuenow` | Value of `value` (omitted when indeterminate) | +| Provider | `aria-valuetext` | Resolved value text (omitted when indeterminate) | +| Provider | `aria-label` | Translated default or override | + +Indeterminate mode omits `aria-valuenow` / `aria-valuetext`, matching the WAI-ARIA spec (unknown value). + +## Data Attributes + +| Part | Attribute | Values | +| --------- | ----------------------- | --------------------------------------------- | +| Provider | `data-progress` | Always present | +| Provider | `data-state` | `indeterminate` \| `loading` \| `loaded` | +| Provider | `data-value` | Numeric value (omitted when indeterminate) | +| Provider | `data-min` | Numeric min | +| Provider | `data-max` | Numeric max | +| Indicator | `data-progress-indicator` | Always present | +| Indicator | `data-state` | Inherited from provider | + +State resolution: +- `value === null` → `indeterminate` +- `value >= max` → `loaded` +- otherwise → `loading` + +## CSS Variables + +| Variable | Part | Description | +| ---------------------------- | -------- | -------------------------------------------------------- | +| `--soma-progress-value-pct` | Provider | 0–100 percentage, clamped. Omitted when indeterminate. | + +## Comparison + +| Feature | Soma | Radix | Ark UI | bits-ui | react-aria | +| ---------------------------------------- | :--: | :---: | :----: | :-----: | :--------: | +| `role="progressbar"` | ✅ | ✅ | ✅ | ✅ | ✅ | +| Indeterminate (`value=null`) | ✅ | ✅ | ✅ | ✅ | ✅ | +| `aria-valuetext` override | ✅ | ❌ | ❌ | ❌ | ✅ | +| `--soma-progress-value-pct` CSS var | ✅ | ⚠️¹ | ✅ | ✅ | ❌ | +| `data-state` reflects loading/loaded | ✅ | ✅ | ✅ | ✅ | ❌ | +| Translated default `aria-label` | ✅ | ❌ | ❌ | ❌ | ❌ | +| `min` configurable | ✅ | ❌² | ✅ | ✅ | ✅ | + +¹ Radix uses `--radix-progress-indicator-max-width`; same shape, different name. +² Radix hardcodes `min=0`. + +## Usage + +### Determinate + +```svelte + + + +``` + +### Indeterminate + +```svelte + + + +``` + +```css +[data-progress][data-state='indeterminate'] [data-progress-indicator] { + width: 40%; + animation: slide 1s infinite linear; +} +@keyframes slide { + from { transform: translateX(-100%); } + to { transform: translateX(250%); } +} +``` + +### Styling the fill + +```css +[data-progress] { + position: relative; + height: 12px; + border-radius: 6px; + background: #e2e8f0; + overflow: hidden; +} +[data-progress-indicator] { + position: absolute; + inset: 0; + width: calc(var(--soma-progress-value-pct, 0) * 1%); + background: #0070f3; + transition: width 150ms ease-out; +} +[data-progress][data-state='loaded'] [data-progress-indicator] { + background: #16a34a; +} +``` diff --git a/src/uix/soma/components/progress/components/progress-indicator.svelte b/src/uix/soma/components/progress/components/progress-indicator.svelte new file mode 100644 index 000000000..be10436da --- /dev/null +++ b/src/uix/soma/components/progress/components/progress-indicator.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/progress/components/progress.svelte b/src/uix/soma/components/progress/components/progress.svelte new file mode 100644 index 000000000..3dc0c1db7 --- /dev/null +++ b/src/uix/soma/components/progress/components/progress.svelte @@ -0,0 +1,47 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/progress/exports.ts b/src/uix/soma/components/progress/exports.ts new file mode 100644 index 000000000..7dc884595 --- /dev/null +++ b/src/uix/soma/components/progress/exports.ts @@ -0,0 +1,8 @@ +export { default as Provider } from './components/progress.svelte'; +export { default as Indicator } from './components/progress-indicator.svelte'; + +export type { + ProgressProps as ProviderProps, + ProgressIndicatorProps as IndicatorProps, + ProgressState +} from './types'; diff --git a/src/uix/soma/components/progress/index.ts b/src/uix/soma/components/progress/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/progress/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/progress/langs.ts b/src/uix/soma/components/progress/langs.ts new file mode 100644 index 000000000..5f7813d11 --- /dev/null +++ b/src/uix/soma/components/progress/langs.ts @@ -0,0 +1,4 @@ +/** Idlangref constants for the Progress component. */ +export const PROGRESS_LANGS = { + LABEL: '#?components.progress.label|Loading', +} as const; diff --git a/src/uix/soma/components/progress/progress-provider.svelte.ts b/src/uix/soma/components/progress/progress-provider.svelte.ts new file mode 100644 index 000000000..3a30f257b --- /dev/null +++ b/src/uix/soma/components/progress/progress-provider.svelte.ts @@ -0,0 +1,154 @@ +import { Provider, context, type WithRefOpts } from '../../provider'; +import { createAttrs, registerContract } from '../../attrs'; +import { readableActive, type Active, type ActiveProps } from '../../reactive'; +import { Soma } from '../../core/soma.svelte'; +import { PROGRESS_LANGS } from './langs'; +import type { ProgressState } from './types'; + +const attrs = createAttrs({ + component: 'progress', + parts: ['root', 'indicator'] as const +}); + +registerContract({ + name: 'progress', + version: 1, + parts: { + root: [ + { + attr: 'data-state', + values: ['indeterminate', 'loading', 'loaded'], + description: 'Progress state derived from value' + }, + { attr: 'data-value', description: 'Current value, omitted when indeterminate' }, + { attr: 'data-max', description: 'Max value' }, + { attr: 'data-min', description: 'Min value' } + ], + indicator: [ + { + attr: 'data-state', + values: ['indeterminate', 'loading', 'loaded'], + description: 'Progress state (inherited from root)' + } + ] + } +}); + +function computeState(value: number | null, min: number, max: number): ProgressState { + if (value === null) return 'indeterminate'; + if (value >= max) return 'loaded'; + // Treat "below min" still as loading; consumers can clamp externally. + return 'loading'; +} + +// ── Root provider ────────────────────────────────────────────────────────── + +interface ProgressOpts + extends WithRefOpts, + ActiveProps<{ + value: number | null; + max: number; + min: number; + ariaLabel: string | undefined; + ariaLabelledby: string | undefined; + valueText: string | undefined; + }> {} + +export class ProgressProvider extends Provider { + static readonly ctx = context('Progress'); + static get(): ProgressProvider | undefined { + return this.ctx.getOr(undefined) as ProgressProvider | undefined; + } + static require(): ProgressProvider { + return this.ctx.get(); + } + + static create(opts: ProgressOpts) { + return new ProgressProvider(opts); + } + + readonly soma = Soma.get(); + + private constructor(opts: ProgressOpts) { + super(opts, 'Progress', 'root', attrs.root, ProgressProvider.ctx); + } + + readonly state: ReadonlyState = $derived.by(() => { + const v = this.opts.value.current; + return computeState(v, this.opts.min.current, this.opts.max.current); + }); + + /** 0–100 percentage for CSS vars. Clamped to the min/max range. */ + readonly percentage = $derived.by(() => { + const v = this.opts.value.current; + if (v === null) return 0; + const min = this.opts.min.current; + const max = this.opts.max.current; + if (max <= min) return 0; + return Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100)); + }); + + readonly resolvedAriaLabel: Active = readableActive(() => { + if (this.opts.ariaLabelledby.current) return undefined; + return ( + this.opts.ariaLabel.current || + this.soma?.langs.ts(PROGRESS_LANGS.LABEL) || + undefined + ); + }); + + readonly resolvedValueText = $derived.by(() => { + const explicit = this.opts.valueText.current; + if (explicit) return explicit; + const v = this.opts.value.current; + if (v === null) return undefined; + return `${v} / ${this.opts.max.current}`; + }); + + readonly props = $derived.by(() => { + const value = this.opts.value.current; + return this.assertProps({ + ...this.baseProps, + role: 'progressbar' as const, + 'aria-valuemin': this.opts.min.current, + 'aria-valuemax': this.opts.max.current, + 'aria-valuenow': value ?? undefined, + 'aria-valuetext': this.resolvedValueText, + 'aria-label': this.resolvedAriaLabel.current, + 'aria-labelledby': this.opts.ariaLabelledby.current, + 'data-state': this.state, + 'data-value': value ?? undefined, + 'data-max': this.opts.max.current, + 'data-min': this.opts.min.current, + style: { + '--soma-progress-value-pct': `${this.percentage}` + } + } as const); + }); +} + +type ReadonlyState = ProgressState; + +// ── Indicator ────────────────────────────────────────────────────────────── + +interface ProgressIndicatorOpts extends WithRefOpts {} + +export class ProgressIndicatorProvider extends Provider { + static create(opts: ProgressIndicatorOpts) { + return new ProgressIndicatorProvider(opts); + } + + readonly provider: ProgressProvider; + + private constructor(opts: ProgressIndicatorOpts) { + super(opts, 'Progress', 'indicator', attrs.indicator); + this.provider = ProgressProvider.require(); + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'data-state': this.provider.state + } as const) + ); +} diff --git a/src/uix/soma/components/progress/types.ts b/src/uix/soma/components/progress/types.ts new file mode 100644 index 000000000..bf5262429 --- /dev/null +++ b/src/uix/soma/components/progress/types.ts @@ -0,0 +1,62 @@ +import type { WithChild, Without } from '../../types'; +import type { PrimitiveDivAttributes } from '../../types'; + +/** Progress state derived from value + min + max. */ +export type ProgressState = 'indeterminate' | 'loading' | 'loaded'; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for the root `Progress.Provider`. + * + * Renders `
` with the full ARIA valuenow/min/max + * triplet. Supports indeterminate mode (`value=null`) for unknown-duration + * operations like initial data fetches. + * + * The `Indicator` sub-part is a scaled fill bar, positioned via the + * `--soma-progress-value-pct` CSS variable (0–100). + */ +export type ProgressProps = WithChild<{ + /** DOM id. Auto-generated if omitted. */ + id?: string; + + /** + * Current progress value. `null` renders the bar in indeterminate mode + * (the Indicator is still present but has no meaningful width, and + * `data-state='indeterminate'`). + * @default null + */ + value?: number | null; + /** Maximum value. @default 100 */ + max?: number; + /** Minimum value. @default 0 */ + min?: number; + + /** + * Override the default accessible label. When omitted, the Progress uses + * a translated `'Loading'`. + */ + 'aria-label'?: string; + /** External element that labels the Progress. */ + 'aria-labelledby'?: string; + + /** + * Optional value text for screen readers (e.g. `"75 of 100 MB"` instead + * of the bare number). React Aria ships this as `valueLabel` — we surface + * the same hook via `valueText`. Falls back to `value / max` when unset. + */ + valueText?: string; +}> & + Without; + +// ── Indicator ────────────────────────────────────────────────────────────── + +/** + * Props for `Progress.Indicator` — decorative scaled fill. Style its width + * (or transform) via the `--soma-progress-value-pct` CSS variable emitted + * on the root. + */ +export type ProgressIndicatorProps = WithChild<{ + id?: string; +}> & + Without; diff --git a/src/uix/soma/components/search-field/README.md b/src/uix/soma/components/search-field/README.md new file mode 100644 index 000000000..dcd2455fe --- /dev/null +++ b/src/uix/soma/components/search-field/README.md @@ -0,0 +1,155 @@ +# SearchField + +Text input tuned for search: `` with an integrated clear button, Escape-to-clear, Enter-to-submit, and `Field.Provider` OR-merge for `disabled` / `readonly` / `required` / `invalid`. + +## Anatomy + +```svelte + runSearch(v)}> + + × + +``` + +## Parts + +| Part | Element | Description | +| -------------- | ----------- | -------------------------------------------------------------------------- | +| `Provider` | `
` | Root context. Holds value, flags (disabled/readonly/required/invalid). | +| `Input` | `` | `type="search" role="searchbox"`. Reads OR-merged state from Field. | +| `ClearTrigger` | ` +{/if} diff --git a/src/uix/soma/components/search-field/components/search-field-input.svelte b/src/uix/soma/components/search-field/components/search-field-input.svelte new file mode 100644 index 000000000..39cdfd823 --- /dev/null +++ b/src/uix/soma/components/search-field/components/search-field-input.svelte @@ -0,0 +1,30 @@ + + + diff --git a/src/uix/soma/components/search-field/components/search-field.svelte b/src/uix/soma/components/search-field/components/search-field.svelte new file mode 100644 index 000000000..3eb16f5f4 --- /dev/null +++ b/src/uix/soma/components/search-field/components/search-field.svelte @@ -0,0 +1,64 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(state.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/search-field/exports.ts b/src/uix/soma/components/search-field/exports.ts new file mode 100644 index 000000000..11c95fb95 --- /dev/null +++ b/src/uix/soma/components/search-field/exports.ts @@ -0,0 +1,10 @@ +export { default as Provider } from './components/search-field.svelte'; +export { default as Input } from './components/search-field-input.svelte'; +export { default as ClearTrigger } from './components/search-field-clear-trigger.svelte'; + +export type { + SearchFieldProps as ProviderProps, + SearchFieldInputProps as InputProps, + SearchFieldClearTriggerProps as ClearTriggerProps, + SearchFieldProviderSnippetProps as ProviderSnippetProps +} from './types'; diff --git a/src/uix/soma/components/search-field/index.ts b/src/uix/soma/components/search-field/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/search-field/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/search-field/langs.ts b/src/uix/soma/components/search-field/langs.ts new file mode 100644 index 000000000..417e6850c --- /dev/null +++ b/src/uix/soma/components/search-field/langs.ts @@ -0,0 +1,5 @@ +/** Idlangref constants for the SearchField component. */ +export const SEARCH_FIELD_LANGS = { + LABEL: '#?components.search-field.label|Search', + CLEAR: '#?components.search-field.clear|Clear search', +} as const; diff --git a/src/uix/soma/components/search-field/search-field-provider.svelte.ts b/src/uix/soma/components/search-field/search-field-provider.svelte.ts new file mode 100644 index 000000000..13e2c5fd0 --- /dev/null +++ b/src/uix/soma/components/search-field/search-field-provider.svelte.ts @@ -0,0 +1,303 @@ +import { Provider, context, type WithRefOpts } from '../../provider'; +import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; +import { + readableActive, + state, + type Active, + type ActiveProps, + type StateProps +} from '../../reactive'; +import type { + OnChangeFn, + SomaInputEvent, + SomaKeyboardEvent, + SomaMouseEvent, + SomaFocusEvent +} from '../../types'; +import { KEYS } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { FieldProvider } from '../field/field-provider.svelte'; +import { SEARCH_FIELD_LANGS } from './langs'; + +const attrs = createAttrs({ + component: 'search-field', + parts: ['root', 'input', 'clear-trigger'] as const +}); + +registerContract({ + name: 'search-field', + version: 1, + parts: { + root: [ + { attr: 'data-disabled', description: 'Disabled flag' }, + { attr: 'data-readonly', description: 'Read-only flag' }, + { attr: 'data-invalid', description: 'Invalid flag' }, + { attr: 'data-required', description: 'Required flag' }, + { attr: 'data-focused', description: 'Input has focus' }, + { attr: 'data-empty', description: 'Value is empty' } + ], + input: [ + { attr: 'data-disabled', description: 'Disabled flag' }, + { attr: 'data-readonly', description: 'Read-only flag' } + ], + 'clear-trigger': [{ attr: 'data-disabled', description: 'Disabled flag' }] + } +}); + +// ── Root provider ────────────────────────────────────────────────────────── + +interface SearchFieldOpts + extends + WithRefOpts, + StateProps<{ value: string }>, + ActiveProps<{ + inputId: string; + disabled: boolean; + readonly: boolean; + required: boolean; + invalid: boolean; + name: string | undefined; + placeholder: string | undefined; + clearOnEscape: boolean; + ariaLabel: string | undefined; + onValueChange: OnChangeFn | undefined; + onSubmit: ((value: string) => void) | undefined; + onClear: (() => void) | undefined; + }> {} + +export class SearchFieldProvider extends Provider { + static readonly ctx = context('SearchField'); + static get(): SearchFieldProvider | undefined { + return this.ctx.getOr(undefined) as SearchFieldProvider | undefined; + } + static require(): SearchFieldProvider { + return this.ctx.get(); + } + + static create(opts: SearchFieldOpts) { + return new SearchFieldProvider(opts); + } + + readonly soma = Soma.get(); + readonly field = FieldProvider.get(); + + inputRef = state(null); + focused = $state(false); + + private constructor(opts: SearchFieldOpts) { + super(opts, 'SearchField', 'root', attrs.root, SearchFieldProvider.ctx); + // Register the Input's id with the parent Field so `Field.Label`'s + // `for` attribute lands on the interactive element. Direct assign — + // NOT $effect (A30). + if (this.field) this.field.inputId.current = opts.inputId.current; + } + + // ── Field-aware flags (OR-merge) ───────────────────────────────────────── + + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || (this.field?.isDisabled ?? false) + ); + readonly isReadonly = $derived.by( + () => this.opts.readonly.current || (this.field?.isReadonly ?? false) + ); + readonly isRequired = $derived.by( + () => this.opts.required.current || (this.field?.isRequired ?? false) + ); + readonly isInvalid = $derived.by( + () => this.opts.invalid.current || (this.field?.isInvalid ?? false) + ); + + readonly isEmpty = $derived.by(() => this.opts.value.current === ''); + + // ── Resolved aria-label ───────────────────────────────────────────────── + + readonly resolvedAriaLabel: Active = readableActive(() => { + // If a Field.Label is present (labelId registered), it takes over via + // aria-labelledby — no need for aria-label on the input. + if (this.field?.labelId.current) return undefined; + return ( + this.opts.ariaLabel.current || + this.soma?.langs.ts(SEARCH_FIELD_LANGS.LABEL) || + undefined + ); + }); + + setInputRef(el: HTMLInputElement | null) { + this.inputRef.current = el; + } + + // ── Mutations ─────────────────────────────────────────────────────────── + + commit(next: string) { + if (this.isDisabled || this.isReadonly) return; + if (next === this.opts.value.current) return; + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + } + + clear = () => { + if (this.isDisabled || this.isReadonly) return; + const wasNonEmpty = this.opts.value.current !== ''; + this.opts.value.current = ''; + if (wasNonEmpty) { + this.opts.onValueChange.current?.(''); + this.opts.onClear.current?.(); + } + this.inputRef.current?.focus(); + }; + + submit() { + if (this.isDisabled) return; + this.opts.onSubmit.current?.(this.opts.value.current); + } + + // ── Input events ──────────────────────────────────────────────────────── + + readonly oninput = (e: SomaInputEvent) => { + if (this.isDisabled || this.isReadonly) { + e.preventDefault(); + return; + } + this.commit(e.currentTarget.value); + }; + + readonly onkeydown = (e: SomaKeyboardEvent) => { + if (e.key === KEYS.ENTER) { + e.preventDefault(); + this.submit(); + return; + } + if (e.key === KEYS.ESCAPE && this.opts.clearOnEscape.current && !this.isEmpty) { + e.preventDefault(); + this.clear(); + } + }; + + readonly onfocus = (_e: SomaFocusEvent) => { + this.focused = true; + }; + + readonly onblur = (_e: SomaFocusEvent) => { + this.focused = false; + }; + + // ── Snippet + root props ──────────────────────────────────────────────── + + readonly snippetProps = $derived.by(() => ({ + value: this.opts.value.current, + isEmpty: this.isEmpty, + isFocused: this.focused, + clear: this.clear + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), + 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), + 'data-required': boolToEmptyStrOrUndef(this.isRequired), + 'data-focused': boolToEmptyStrOrUndef(this.focused), + 'data-empty': boolToEmptyStrOrUndef(this.isEmpty) + } as const) + ); +} + +// ── Input ────────────────────────────────────────────────────────────────── + +interface SearchFieldInputOpts extends WithRefOpts {} + +export class SearchFieldInputProvider extends Provider { + static create(opts: SearchFieldInputOpts) { + return new SearchFieldInputProvider(opts); + } + + readonly provider: SearchFieldProvider; + + private constructor(opts: SearchFieldInputOpts) { + super(opts, 'SearchField', 'input', attrs.input, undefined, (el) => { + this.provider.setInputRef(el as HTMLInputElement | null); + }); + this.provider = SearchFieldProvider.require(); + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + type: 'search' as const, + role: 'searchbox' as const, + value: this.provider.opts.value.current, + name: this.provider.opts.name.current, + placeholder: this.provider.opts.placeholder.current, + autocomplete: 'off' as const, + spellcheck: false, + required: this.provider.isRequired || undefined, + disabled: this.provider.isDisabled || undefined, + readonly: this.provider.isReadonly || undefined, + 'aria-label': this.provider.resolvedAriaLabel.current, + 'aria-labelledby': this.provider.field?.labelId.current || undefined, + 'aria-invalid': this.provider.isInvalid || undefined, + 'aria-describedby': + [ + this.provider.field?.helperId.current, + this.provider.field?.errorId.current + ] + .filter(Boolean) + .join(' ') || undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly), + oninput: this.provider.oninput, + onkeydown: this.provider.onkeydown, + onfocus: this.provider.onfocus, + onblur: this.provider.onblur + } as const) + ); +} + +// ── ClearTrigger ─────────────────────────────────────────────────────────── + +interface SearchFieldClearTriggerOpts + extends WithRefOpts, + ActiveProps<{ ariaLabel: string | undefined }> {} + +export class SearchFieldClearTriggerProvider extends Provider { + static create(opts: SearchFieldClearTriggerOpts) { + return new SearchFieldClearTriggerProvider(opts); + } + + readonly provider: SearchFieldProvider; + + private constructor(opts: SearchFieldClearTriggerOpts) { + super(opts, 'SearchField', 'clear-trigger', attrs['clear-trigger']); + this.provider = SearchFieldProvider.require(); + } + + readonly onclick = (_e: SomaMouseEvent) => { + this.provider.clear(); + }; + + readonly resolvedAriaLabel: Active = readableActive( + () => + this.opts.ariaLabel.current || + this.provider.soma?.langs.ts(SEARCH_FIELD_LANGS.CLEAR) || + undefined + ); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + type: 'button' as const, + 'aria-label': this.resolvedAriaLabel.current, + disabled: + this.provider.isDisabled || + this.provider.isReadonly || + this.provider.isEmpty || + undefined, + 'data-disabled': boolToEmptyStrOrUndef( + this.provider.isDisabled || this.provider.isReadonly || this.provider.isEmpty + ), + tabindex: -1, + onclick: this.onclick + } as const) + ); +} diff --git a/src/uix/soma/components/search-field/types.ts b/src/uix/soma/components/search-field/types.ts new file mode 100644 index 000000000..a080c9418 --- /dev/null +++ b/src/uix/soma/components/search-field/types.ts @@ -0,0 +1,114 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without, OnChangeFn } from '../../types'; +import type { + PrimitiveDivAttributes, + PrimitiveInputAttributes, + PrimitiveButtonAttributes +} from '../../types'; + +/** Snippet props exposed by `SearchField.Provider`. */ +export type SearchFieldProviderSnippetProps = { + value: string; + /** `true` when `value === ''`. */ + isEmpty: boolean; + /** `true` while the internal Input has focus. */ + isFocused: boolean; + /** Clears the value and refocuses the Input. */ + clear: () => void; +}; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for the root `SearchField.Provider`. + * + * A `
` container with a `searchbox`-roled `` and an + * optional integrated `ClearTrigger` button. Participates in `Field.Provider` + * — `disabled` / `readonly` / `required` / `invalid` are OR-merged. + * + * Two callback paths: `onValueChange` fires on every keystroke (live + * filtering), `onSubmit` fires on Enter (formal search). They are independent + * — consumers can use either or both. + */ +export type SearchFieldProps = WithChild< + { + /** DOM id for the root container. Auto-generated if omitted. */ + id?: string; + /** DOM id for the inner input. Auto-generated if omitted. */ + inputId?: string; + + /** Current value. Bindable. @default '' */ + value?: string; + /** Fired on every value change (each keystroke, paste, clear). */ + onValueChange?: OnChangeFn; + /** Fired when the user presses Enter in the input. */ + onSubmit?: (value: string) => void; + /** Fired after the user clears the value (via ClearTrigger or Escape). */ + onClear?: () => void; + + /** When `true`, Escape in the Input clears the value. @default true */ + clearOnEscape?: boolean; + + // Flags — OR-merged with the enclosing `Field.Provider` + /** @default false */ + disabled?: boolean; + /** @default false */ + readonly?: boolean; + /** @default false */ + required?: boolean; + /** External invalid flag. @default false */ + invalid?: boolean; + + // Form + /** Name for native form submission. */ + name?: string; + /** Placeholder forwarded to the Input. */ + placeholder?: string; + /** + * Accessible name for the search input. Ignored when inside a + * `Field.Provider` with a `Field.Label` (the Label wins via + * `aria-labelledby`). Defaults to a translated `'Search'`. + */ + 'aria-label'?: string; + + children?: Snippet<[SearchFieldProviderSnippetProps]>; + }, + SearchFieldProviderSnippetProps +> & + Without; + +// ── Input ────────────────────────────────────────────────────────────────── + +/** + * Props for `SearchField.Input` — the real ``. Consumer + * never passes `value` / `onchange` directly; the Provider drives both. + */ +export type SearchFieldInputProps = WithChild< + { id?: string }, + { _default: never }, + HTMLInputElement +> & + Without< + PrimitiveInputAttributes, + { value?: unknown; type?: unknown; onchange?: unknown; oninput?: unknown } + >; + +// ── ClearTrigger ─────────────────────────────────────────────────────────── + +/** + * Props for `SearchField.ClearTrigger` — button that clears the Input and + * restores focus to it. Emits `data-empty` on the root so consumers can + * hide the trigger via CSS when the field is empty: + * + * ```css + * [data-search-field][data-empty] [data-search-field-clear-trigger] { + * display: none; + * } + * ``` + */ +export type SearchFieldClearTriggerProps = WithChild<{ + id?: string; + /** Accessible name. @default translated `'Clear search'` */ + 'aria-label'?: string; +}> & + Without; diff --git a/src/uix/soma/components/table/README.md b/src/uix/soma/components/table/README.md index 4af426122..62f5cd29a 100644 --- a/src/uix/soma/components/table/README.md +++ b/src/uix/soma/components/table/README.md @@ -1,6 +1,20 @@ # Table -Headless table with sorting, filtering, row selection, pagination, column pinning, column sizing, and expandable rows. Provides a reactive state manager (`createTable`) and Svelte components with ARIA, keyboard support, and `data-*` attributes for styling. +Headless table with sorting, filtering, row selection, pagination, column pinning, column sizing, and **row detail panels** (disclosure pattern). Provides a reactive state manager (`createTable`) and Svelte components with ARIA, keyboard support, and `data-*` attributes for styling. + +Table is for **flat tabular data**. Emits `role="table"` with grid-cell semantics; Tab + click for interaction, no arrow-key navigation between rows. + +## When to use Table vs TreeGrid vs GridList + +soma ships three WAI-ARIA patterns for tabular / list display. They are **not interchangeable** — pick by semantic intent: + +| Component | ARIA role | Keyboard contract | Use when | +| -------------------------------------------- | ------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| **Table** (this) | `table` | Tab + click. No arrow-key row nav. | Flat tabular data. Sort/filter/paginate large lists. Row expansion is a **detail panel** (disclosure), not hierarchy. | +| [`TreeGrid`](../tree-grid/README.md) | `treegrid` | Roving tabindex + ArrowUp/Down rows + ArrowRight/Left expand/collapse. | **Hierarchical** rows (parent + children of the same shape): file browsers, org charts, nested tasks. `aria-level` auto-derived. | +| [`GridList`](../grid-list/README.md) | `grid` | Roving tabindex + ArrowUp/Down rows + ArrowLeft/Right between focusable cells. | Flat **selectable** lists where rows contain multiple interactive elements (actions, links, per-row checkboxes). | + +**"Expandable rows" in Table = row detail panel**, not sub-rows. If you need true hierarchy → TreeGrid. Table emits `role="table"` and cannot honestly carry `aria-level` semantics. ## Anatomy @@ -56,15 +70,17 @@ Headless table with sorting, filtering, row selection, pagination, column pinnin ## Parts -| Part | Element | Description | -| -------------- | --------- | ---------------------------------------------------------------------------------- | -| `Provider` | `` | Root. Renders `
` with translated `aria-label`. | -| `Header` | `` | Table header section. | -| `Body` | `` | Table body section. | -| `Footer` | `` | Table footer section. | -| `ColumnHeader` | `` | Table row. Emits `data-selected`, `data-expanded`, `data-depth`, `aria-expanded`. | -| `Cell` | `
` | Column header. Emits `data-sortable`, `data-sorted`, `data-pinned`, sizing styles. | -| `Row` | `
` | Table cell. Emits `data-pinned`, sizing styles, `aria-colindex`. | +| Part | Element | Description | +| -------------------- | ---------- | ---------------------------------------------------------------------------------- | +| `Provider` | `` | Root. Renders `
` with translated `aria-label`. | +| `Header` | `` | Table header section. | +| `Body` | `` | Table body section. | +| `Footer` | `` | Table footer section. | +| `ColumnHeader` | `` | Table row. Emits `data-selected`. | +| `Cell` | `` | Full-width disclosure panel rendered below the row when open. `
` | Column header. Emits `data-sortable`, `data-sorted`, `data-pinned`, sizing styles. | +| `Row` | `
` | Table cell. Emits `data-pinned`, sizing styles, `aria-colindex`. | +| `RowDetailTrigger` | `
` inside. | ## State Manager (`createTable`) @@ -93,9 +109,9 @@ const table = createTable({ pageSize: 10, }, - // Expansion - getRowSubRows?: (row) => row.children, // sub-rows for tree expansion - getRowCanExpand?: (row) => boolean, // override expand check (detail panels) + // Row detail (disclosure pattern — NOT hierarchy; use TreeGrid for that) + getRowCanShowDetail?: (row) => boolean, // defaults to `() => true` + initialRowDetailOpen?: { [rowId: string]: boolean }, // Total row count for server-side pagination rowCount?: number, @@ -109,7 +125,7 @@ const table = createTable({ onColumnVisibilityChange?: (visibility) => void, onColumnPinningChange?: (pinning) => void, onColumnSizingChange?: (sizing) => void, - onExpandedChange?: (expanded) => void, + onRowDetailOpenChange?: (state) => void, }); // Row counts @@ -160,12 +176,14 @@ interface ColumnDef { | Provider (``) | `aria-label` | Translated label (default: 'Data table') | | ColumnHeader | `role` | `columnheader` | | ColumnHeader | `aria-sort` | `ascending` \| `descending` \| `none` (only when sortable) | -| Row | `role` | `row` | -| Row | `aria-rowindex` | 1-based row index | -| Row | `aria-selected` | `true` \| `false` (when selection enabled) | -| Row | `aria-expanded` | `true` \| `false` (when expandable) | -| Cell | `role` | `gridcell` | -| Cell | `aria-colindex` | 1-based column index | +| Row | `role` | `row` | +| Row | `aria-rowindex` | 1-based row index | +| Row | `aria-selected` | `true` \| `false` (when selection enabled) | +| Cell | `role` | `gridcell` | +| Cell | `aria-colindex` | 1-based column index | +| RowDetailTrigger | `aria-expanded` | `true` \| `false` — open state of the detail panel | +| RowDetailTrigger | `aria-controls` | id of the sibling `Table.RowDetail` | +| RowDetailTrigger | `aria-label` | Translated `Show details` / `Hide details` swap | ## Data Attributes @@ -175,15 +193,17 @@ interface ColumnDef { | ColumnHeader | `data-sortable` | Present when `enableSorting: true` | | ColumnHeader | `data-sorted` | `asc` \| `desc` (when actively sorted) | | ColumnHeader | `data-pinned` | `left` \| `right` (when pinned) | -| Row | `data-table-row` | Always present | -| Row | `data-selected` | Present when selected | -| Row | `data-expanded` | Present when expanded | -| Row | `data-depth` | Nesting depth as string (`"1"`, `"2"`, ...) for sub-rows | -| Cell | `data-table-cell` | Always present | -| Cell | `data-pinned` | `left` \| `right` (when column is pinned) | -| Header | `data-table-header` | Always present | -| Body | `data-table-body` | Always present | -| Footer | `data-table-footer` | Always present | +| Row | `data-table-row` | Always present | +| Row | `data-selected` | Present when selected | +| Cell | `data-table-cell` | Always present | +| Cell | `data-pinned` | `left` \| `right` (when column is pinned) | +| Header | `data-table-header` | Always present | +| Body | `data-table-body` | Always present | +| Footer | `data-table-footer` | Always present | +| RowDetailTrigger | `data-table-row-detail-trigger` | Always present | +| RowDetailTrigger | `data-state` | `open \| closed` | +| RowDetail | `data-table-row-detail` | Always present | +| RowDetail | `data-state` | `open \| closed` (hidden from DOM when closed) | ## Sorting @@ -316,84 +336,55 @@ table.getIsColumnVisible('email'); table.setColumnVisibility({ email: false, age: false }); ``` -## Expandable Rows - -Two patterns supported: +## Row Detail Panels (disclosure pattern) -### Sub-rows (tree/hierarchy) +A row can show a **detail panel** — a full-width disclosure region rendered below it with any content (bio, invoice line-items, a nested form, a sub-Table). This is **not** hierarchy: the parent row and its detail have different shapes. For true hierarchical data where children are rows of the same schema, use [`TreeGrid`](../tree-grid/README.md) instead. -Provide `getRowSubRows` to define hierarchical data. Expanded sub-rows are flattened into `table.rows`. - -```ts -const table = createTable({ - data: () => orgData, - columns, - getRowSubRows: (row) => row.children ?? [] -}); -``` +The disclosure wiring is fully handled by the components: `Table.RowDetailTrigger` emits `aria-expanded` + `aria-controls` pointing at the `Table.RowDetail`, which is hidden from the DOM (via `hidden`) when closed. ```svelte -{#each table.rows as row (row.id)} - - - {#each row.cells as cell, i} - - {cell.value} - - {/each} - -{/each} + + {#each table.rows as row (row.id)} + + + {#each row.cells as cell, i} + {cell.value} + {/each} + + + +

{row.original.name} — {row.original.email}

+
... +
+ {/each} + ``` -### Detail panels (expandable content) +Both parts take `{row}` explicitly. `Table.RowDetail` is a **sibling ``** of `Table.Row` (HTML forbids nested ``); the Trigger's `aria-controls` points at the Detail via a DOM id derived deterministically from `row.id`. -Use `getRowCanExpand: () => true` without `getRowSubRows`. The consumer renders a detail `` when expanded. +### Imperative API ```ts -const table = createTable({ - data: () => people, - columns, - getRowCanExpand: () => true -}); +table.toggleRowDetail(rowId); +table.setRowDetailOpen({ '1': true, '3': true }); +table.getIsRowDetailOpen(rowId); +table.getCanShowRowDetail(rowId); // honors getRowCanShowDetail config +table.closeAllRowDetails(); +table.rowDetailOpen; // current state (read-only) ``` -```svelte -{#each table.rows as row (row.id)} - - - {#each row.cells as cell, i} - {cell.value} - {/each} - - {#if row.getIsExpanded} - - - - - {/if} -{/each} -``` +### Opting rows out -### Expansion API +By default every row can show a detail. Pass `getRowCanShowDetail` to opt some rows out — the consumer should also conditionally render `` to match. ```ts -table.toggleRowExpanded(rowId); -table.getIsRowExpanded(rowId); -table.getCanRowExpand(rowId); -table.toggleAllRowsExpanded(); +const table = createTable({ + data: () => people, + columns, + getRowCanShowDetail: (row) => row.role !== 'placeholder' +}); ``` ## Comparison with reference libraries @@ -418,7 +409,7 @@ table.toggleAllRowsExpanded(); | Column visibility | Yes | No | Yes | | Column resizing | Yes | Partial | Yes (programmatic, no drag) | | Column pinning | Yes | No | Yes (left/right, sticky) | -| Row expansion (sub-rows) | Yes | No | Yes | -| Row expansion (detail) | Yes | No | Yes | +| Row expansion (hierarchy) | Yes | No | **Use TreeGrid instead** | +| Row expansion (detail panel)| Yes | No | Yes (`RowDetail`) | | Grouping / aggregation | Yes | No | No | -| Virtual scrolling | Via TanStack Virtual | No | No | +| Virtual scrolling | Via TanStack Virtual | No | Via composition with VirtualList | diff --git a/src/uix/soma/components/table/components/table-row-detail-trigger.svelte b/src/uix/soma/components/table/components/table-row-detail-trigger.svelte new file mode 100644 index 000000000..cca9f6dd0 --- /dev/null +++ b/src/uix/soma/components/table/components/table-row-detail-trigger.svelte @@ -0,0 +1,39 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/table/components/table-row-detail.svelte b/src/uix/soma/components/table/components/table-row-detail.svelte new file mode 100644 index 000000000..b9b480b38 --- /dev/null +++ b/src/uix/soma/components/table/components/table-row-detail.svelte @@ -0,0 +1,39 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + + +{/if} diff --git a/src/uix/soma/components/table/exports.ts b/src/uix/soma/components/table/exports.ts index c7cf9ec08..ffc45c6c7 100644 --- a/src/uix/soma/components/table/exports.ts +++ b/src/uix/soma/components/table/exports.ts @@ -5,6 +5,8 @@ export { default as Footer } from './components/table-footer.svelte'; export { default as ColumnHeader } from './components/table-column-header.svelte'; export { default as Row } from './components/table-row.svelte'; export { default as Cell } from './components/table-cell.svelte'; +export { default as RowDetail } from './components/table-row-detail.svelte'; +export { default as RowDetailTrigger } from './components/table-row-detail-trigger.svelte'; export { createTable, filterFns } from './table-core.svelte'; export type { BuiltInFilterFn } from './table-core.svelte'; @@ -20,7 +22,7 @@ export type { ColumnPinningState, ColumnPinningPosition, ColumnSizingState, - ExpandedState, + RowDetailState, ColumnFilterEntry, ColumnFiltersState, TableConfig, @@ -37,5 +39,7 @@ export type { TableFooterProps as FooterProps, TableColumnHeaderProps as ColumnHeaderProps, TableRowProps as RowProps, - TableCellProps as CellProps + TableCellProps as CellProps, + TableRowDetailProps as RowDetailProps, + TableRowDetailTriggerProps as RowDetailTriggerProps } from './types'; diff --git a/src/uix/soma/components/table/langs.ts b/src/uix/soma/components/table/langs.ts index ce4161c5a..bbf3a3c9d 100644 --- a/src/uix/soma/components/table/langs.ts +++ b/src/uix/soma/components/table/langs.ts @@ -1,3 +1,5 @@ export const TABLE_LANGS = { - LABEL: '#?components.table.label|Data table' + LABEL: '#?components.table.label|Data table', + ROW_DETAIL_EXPAND: '#?components.table.row-detail-expand|Show details', + ROW_DETAIL_COLLAPSE: '#?components.table.row-detail-collapse|Hide details' } as const; diff --git a/src/uix/soma/components/table/table-core.svelte.ts b/src/uix/soma/components/table/table-core.svelte.ts index c4c099dbe..99b546113 100644 --- a/src/uix/soma/components/table/table-core.svelte.ts +++ b/src/uix/soma/components/table/table-core.svelte.ts @@ -80,9 +80,14 @@ export interface ColumnPinningState { export type ColumnSizingState = Record; -// ── Expansion ──────────────────────────────────────────────────────────────── +// ── Row detail (disclosure pattern — NOT hierarchy) ───────────────────────── +// +// Tracks which rows currently have their detail panel open. Use TreeGrid for +// true hierarchical data (`role="treegrid"` with `aria-level`/`aria-expanded` +// on rows). This is the table-disclosure pattern: a row with a "show more" +// toggle that reveals a full-width panel of extra content below. -export type ExpandedState = Record; +export type RowDetailState = Record; export type ColumnPinningPosition = 'left' | 'right'; @@ -230,16 +235,6 @@ export interface TableRow { index: number; original: TData; cells: TableCell[]; - /** Depth in the expanded tree. @default 0 */ - depth: number; - /** Parent row ID. Undefined for root rows. */ - parentId?: string; - /** Sub-rows when expanded. Empty if no sub-rows or not expanded. */ - subRows: TableRow[]; - /** Whether this row can be expanded (has sub-rows). */ - getCanExpand: boolean; - /** Whether this row is currently expanded. */ - getIsExpanded: boolean; } // ── Table instance ─────────────────────────────────────────────────────────── @@ -306,13 +301,17 @@ export interface TableConfig { initialColumnSizing?: ColumnSizingState; onColumnSizingChange?: OnChangeFn; - // ── Expansion ──────────────────────────────────────────────────────── - /** Function to get sub-rows for a row. Enables expandable rows. */ - getRowSubRows?: (row: TData) => TData[]; - /** Function to determine if a row can expand. Default: checks getRowSubRows. */ - getRowCanExpand?: (row: TData) => boolean; - initialExpanded?: ExpandedState; - onExpandedChange?: OnChangeFn; + // ── Row detail (disclosure pattern) ────────────────────────────────── + /** + * Predicate — when `true`, the row shows a detail disclosure. The consumer + * renders the detail content via ``. Default: every row + * can have a detail (consumers opt in per row via the template). + */ + getRowCanShowDetail?: (row: TData) => boolean; + /** Initially-open row-detail map keyed by row id. */ + initialRowDetailOpen?: RowDetailState; + /** Fires on any row-detail open/close change. */ + onRowDetailOpenChange?: OnChangeFn; /** Total row count (for server-side pagination). */ rowCount?: number; @@ -383,12 +382,13 @@ export interface TableInstance { getPinnedOffset(columnId: string): number | undefined; resetColumnSizes(): void; - // ── Expansion ──────────────────────────────────────────────────────── - readonly expanded: ExpandedState; - toggleRowExpanded(rowId: string): void; - getIsRowExpanded(rowId: string): boolean; - getCanRowExpand(rowId: string): boolean; - toggleAllRowsExpanded(): void; + // ── Row detail (disclosure pattern — NOT hierarchy) ───────────────── + readonly rowDetailOpen: RowDetailState; + toggleRowDetail(rowId: string): void; + setRowDetailOpen(state: RowDetailState): void; + getIsRowDetailOpen(rowId: string): boolean; + getCanShowRowDetail(rowId: string): boolean; + closeAllRowDetails(): void; // ── Pagination ─────────────────────────────────────────────────────── readonly pagination: PaginationState; @@ -424,7 +424,7 @@ export function createTable(config: TableConfig): TableInstance sibling to the row) ─────── + +interface TableRowDetailOpts + extends WithRefOpts, + ActiveProps<{ row: TableRow }> {} + +/** + * Renders as a `` spanning all columns. Visible only while the associated + * row's detail is open. + * + * Because HTML does NOT allow nested ``, `RowDetail` is a **sibling** of + * `Table.Row` in the DOM tree. It therefore cannot use a Svelte context from + * the row — the consumer passes the `{row}` object explicitly, matching the + * rest of Table's API (``, ``). + * + * The DOM `id` is derived deterministically from `row.id` so the + * `RowDetailTrigger` (inside the row) can target it via `aria-controls` + * without any registration. + */ +export class TableRowDetailProvider extends Provider { + static create(opts: TableRowDetailOpts) { + return new TableRowDetailProvider(opts); + } + + readonly provider: TableProvider; + + private constructor(opts: TableRowDetailOpts) { + super(opts, 'Table', 'row-detail', attrs['row-detail']); + this.provider = TableProvider.require(); + } + + private get row(): TableRow { + return this.opts.row.current; + } + + readonly isOpen = $derived.by(() => + this.provider.table.getIsRowDetailOpen(this.row.id) + ); + + readonly colSpan = $derived.by(() => this.provider.table.getVisibleColumns().length); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + id: tableRowDetailDomId(this.row.id), + role: 'row' as const, + hidden: this.isOpen ? undefined : true, + 'data-state': this.isOpen ? ('open' as const) : ('closed' as const) + } as const) + ); +} + +// ── RowDetailTrigger (disclosure button — lives inside Row's cells) ──────── + +interface TableRowDetailTriggerOpts + extends WithRefOpts, + ActiveProps<{ + row: TableRow; + ariaLabel: string | undefined; + }> {} + +/** + * Toggles the given row's detail panel. Emits `aria-expanded` + + * `aria-controls` pointing at the sibling `Table.RowDetail` (id derived + * deterministically from `row.id`). Click / Space / Enter toggles; + * `stopPropagation` prevents the Row's selection handler from firing. + * + * The `{row}` prop is mandatory — mirrors the rest of Table's API. + */ +export class TableRowDetailTriggerProvider extends Provider { + static create(opts: TableRowDetailTriggerOpts) { + return new TableRowDetailTriggerProvider(opts); + } + + readonly provider: TableProvider; + + private constructor(opts: TableRowDetailTriggerOpts) { + super(opts, 'Table', 'row-detail-trigger', attrs['row-detail-trigger']); + this.provider = TableProvider.require(); + } + + private get row(): TableRow { + return this.opts.row.current; + } + + readonly isOpen = $derived.by(() => + this.provider.table.getIsRowDetailOpen(this.row.id) + ); + + readonly onclick = (e: MouseEvent) => { + // Prevent the row's own onclick (selection) from firing. + e.stopPropagation(); + this.provider.table.toggleRowDetail(this.row.id); + }; + + readonly onkeydown = (e: KeyboardEvent) => { + if (e.key === KEYS.SPACE || e.key === KEYS.ENTER) { + e.preventDefault(); + e.stopPropagation(); + this.provider.table.toggleRowDetail(this.row.id); + } + }; + + readonly resolvedAriaLabel = $derived.by(() => { + if (this.opts.ariaLabel.current) return this.opts.ariaLabel.current; + const key = this.isOpen ? TABLE_LANGS.ROW_DETAIL_COLLAPSE : TABLE_LANGS.ROW_DETAIL_EXPAND; + return this.provider.soma?.langs.ts(key); + }); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + type: 'button' as const, + 'aria-expanded': this.isOpen, + 'aria-controls': tableRowDetailDomId(this.row.id), + 'aria-label': this.resolvedAriaLabel, + 'data-state': this.isOpen ? ('open' as const) : ('closed' as const), + onclick: this.onclick, + onkeydown: this.onkeydown + } as const) + ); +} diff --git a/src/uix/soma/components/table/types.ts b/src/uix/soma/components/table/types.ts index ed0348d7c..613138481 100644 --- a/src/uix/soma/components/table/types.ts +++ b/src/uix/soma/components/table/types.ts @@ -7,7 +7,8 @@ import type { PrimitiveTFootAttributes, PrimitiveTHAttributes, PrimitiveTDAttributes, - PrimitiveTRAttributes + PrimitiveTRAttributes, + PrimitiveButtonAttributes } from '../../types'; import type { TableInstance, TableHeader, TableRow, TableCell } from './table-core.svelte'; @@ -69,3 +70,34 @@ export type TableCellProps = WithChild<{ colIndex: number; }> & Without; + +/** + * Props for `Table.RowDetail` — full-width disclosure panel rendered as a + * **sibling ``** below the row when its detail is open. + * + * The DOM id is derived deterministically from `row.id` for `aria-controls` + * wiring from the Trigger. This is the **disclosure pattern** — an + * expandable info panel about the row. For hierarchical data (child rows + * with the same shape as the parent), use `TreeGrid` instead. + */ +export type TableRowDetailProps = WithChild<{ + /** Optional consumer id. `aria-controls` from the Trigger uses a deterministic id derived from `row.id`. */ + id?: string; + /** The row object from `table.rows`. Required. */ + row: TableRow; +}> & + Without; + +/** + * Props for `Table.RowDetailTrigger` — a button (typically inside a Cell of + * the parent Row) that toggles that row's detail panel. Emits + * `aria-expanded` + `aria-controls` pointing at the sibling `Table.RowDetail`. + */ +export type TableRowDetailTriggerProps = WithChild<{ + id?: string; + /** The row object from `table.rows`. Required. */ + row: TableRow; + /** Accessible name override. Defaults to translated `'Show details'` / `'Hide details'` swap. */ + 'aria-label'?: string; +}> & + Without; diff --git a/src/uix/soma/components/tag-group/README.md b/src/uix/soma/components/tag-group/README.md new file mode 100644 index 000000000..33d734f91 --- /dev/null +++ b/src/uix/soma/components/tag-group/README.md @@ -0,0 +1,200 @@ +# TagGroup + +A read-only grouping of tags — filter chips, selected values, resource labels. Renders as `role="grid"` with one `role="row"` per tag and an inner remove button when applicable. Supports keyboard navigation (arrows between tags, Delete/Backspace to remove), single/multiple selection, and disabled states per tag. + +Distinct from [`TagsInput`](../tags-input/README.md): TagGroup is **display-only** (no text entry). Use TagsInput when the user types to add tags; use TagGroup when tags come from a source the user can't freely edit. + +## Anatomy + +```svelte + items = items.filter(i => i !== v)}> + Filters + {#each items as item (item)} + + {item} + × + + {/each} + +``` + +## Parts + +| Part | Element | Description | +| -------------- | ---------- | -------------------------------------------------------------------- | +| `Provider` | `
` | `role="grid"`. Selection state, keyboard coordinator. | +| `Label` | `
` | External label linked via `aria-labelledby`. | +| `Item` | `
` | `role="row"`. One per tag. Focusable. | +| `Link` | `` | Navigable tag variant. Behaves like `Item` but renders as an anchor. | +| `RemoveButton` | ` +{/if} diff --git a/src/uix/soma/components/tag-group/components/tag-group.svelte b/src/uix/soma/components/tag-group/components/tag-group.svelte new file mode 100644 index 000000000..98ed77ee7 --- /dev/null +++ b/src/uix/soma/components/tag-group/components/tag-group.svelte @@ -0,0 +1,57 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/tag-group/exports.ts b/src/uix/soma/components/tag-group/exports.ts new file mode 100644 index 000000000..1424bfc71 --- /dev/null +++ b/src/uix/soma/components/tag-group/exports.ts @@ -0,0 +1,16 @@ +export { default as Provider } from './components/tag-group.svelte'; +export { default as Label } from './components/tag-group-label.svelte'; +export { default as Item } from './components/tag-group-item.svelte'; +export { default as Link } from './components/tag-group-link.svelte'; +export { default as RemoveButton } from './components/tag-group-remove-button.svelte'; + +export type { + TagGroupProps as ProviderProps, + TagGroupLabelProps as LabelProps, + TagGroupItemProps as ItemProps, + TagGroupLinkProps as LinkProps, + TagGroupRemoveButtonProps as RemoveButtonProps, + TagGroupProviderSnippetProps as ProviderSnippetProps, + TagGroupItemSnippetProps as ItemSnippetProps, + TagGroupSelectionMode +} from './types'; diff --git a/src/uix/soma/components/tag-group/index.ts b/src/uix/soma/components/tag-group/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/tag-group/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/tag-group/langs.ts b/src/uix/soma/components/tag-group/langs.ts new file mode 100644 index 000000000..7d6896474 --- /dev/null +++ b/src/uix/soma/components/tag-group/langs.ts @@ -0,0 +1,4 @@ +export const TAG_GROUP_LANGS = { + LABEL: '#?components.tag-group.label|Tags', + REMOVE: '#?components.tag-group.remove|Remove {{tag}}' +} as const; diff --git a/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts b/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts new file mode 100644 index 000000000..dc7bfd3a2 --- /dev/null +++ b/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts @@ -0,0 +1,490 @@ +import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider'; +import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; +import { + readableActive, + state, + type Active, + type ActiveProps, + type StateProps +} from '../../reactive'; +import type { + OnChangeFn, + Direction, + SomaKeyboardEvent, + SomaMouseEvent +} from '../../types'; +import { KEYS, getDirectionalKeys } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { TAG_GROUP_LANGS } from './langs'; +import type { TagGroupSelectionMode } from './types'; + +const attrs = createAttrs({ + component: 'tag-group', + parts: ['root', 'label', 'item', 'link', 'remove-button'] as const +}); + +registerContract({ + name: 'tag-group', + version: 1, + parts: { + root: [ + { attr: 'data-disabled', description: 'All tags disabled' }, + { attr: 'data-empty', description: 'No tags' }, + { + attr: 'data-selection-mode', + values: ['none', 'single', 'multiple'], + description: 'Selection mode' + } + ], + label: [], + item: [ + { + attr: 'data-state', + values: ['selected', 'unselected'], + description: 'Selection state' + }, + { attr: 'data-highlighted', description: 'Under keyboard focus' }, + { attr: 'data-disabled', description: 'This tag disabled' } + ], + link: [ + { attr: 'data-highlighted', description: 'Under keyboard focus' }, + { attr: 'data-disabled', description: 'This tag disabled' } + ], + 'remove-button': [] + } +}); + +// ── Root provider ────────────────────────────────────────────────────────── + +interface TagGroupOpts + extends WithRefOpts, + StateProps<{ value: string[] }>, + ActiveProps<{ + items: string[] | undefined; + selectionMode: TagGroupSelectionMode; + disabled: boolean; + ariaLabel: string | undefined; + ariaLabelledby: string | undefined; + onValueChange: OnChangeFn | undefined; + onRemove: ((value: string) => void) | undefined; + }> {} + +export class TagGroupProvider extends Provider { + static readonly ctx = context('TagGroup'); + static get(): TagGroupProvider | undefined { + return this.ctx.getOr(undefined) as TagGroupProvider | undefined; + } + static require(): TagGroupProvider { + return this.ctx.get(); + } + + static create(opts: TagGroupOpts) { + return new TagGroupProvider(opts); + } + + readonly soma = Soma.get(); + + /** Registered by `TagGroup.Label` on mount. */ + labelId = state(''); + + private constructor(opts: TagGroupOpts) { + super(opts, 'TagGroup', 'root', attrs.root, TagGroupProvider.ctx); + } + + readonly resolvedDir: Active = readableActive( + () => this.soma?.presentation.getDir() ?? 'ltr' + ); + + readonly resolvedAriaLabel: Active = readableActive(() => { + if (this.opts.ariaLabelledby.current || this.labelId.current) return undefined; + return ( + this.opts.ariaLabel.current || + this.soma?.langs.ts(TAG_GROUP_LANGS.LABEL) || + undefined + ); + }); + + readonly resolvedAriaLabelledby = $derived.by( + () => this.opts.ariaLabelledby.current || this.labelId.current || undefined + ); + + readonly isEmpty = $derived.by(() => { + const items = this.opts.items.current; + return (items?.length ?? 0) === 0; + }); + + // ── DOM queries ───────────────────────────────────────────────────────── + + getItems(): HTMLElement[] { + const root = this.opts.ref.current; + if (!root) return []; + return Array.from( + root.querySelectorAll(`[${attrs.item}]:not([data-disabled])`) + ).filter((el) => el.closest(`[${attrs.root}]`) === root); + } + + readonly rovingTargetEl = $derived.by(() => { + const items = this.getItems(); + if (items.length === 0) return undefined; + const selected = new Set(this.opts.value.current); + return ( + items.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ?? + items[0] + ); + }); + + // ── Selection / removal ──────────────────────────────────────────────── + + isSelected(value: string): boolean { + return this.opts.value.current.includes(value); + } + + select(value: string) { + if (this.opts.disabled.current) return; + const mode = this.opts.selectionMode.current; + if (mode === 'none') return; + const current = this.opts.value.current; + let next: string[]; + if (mode === 'multiple') { + next = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + } else { + next = current.length === 1 && current[0] === value ? [] : [value]; + } + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + } + + remove(value: string) { + if (this.opts.disabled.current) return; + // Remove from selection if present. + const sel = this.opts.value.current; + if (sel.includes(value)) { + const nextSel = sel.filter((v) => v !== value); + this.opts.value.current = nextSel; + this.opts.onValueChange.current?.(nextSel); + } + this.opts.onRemove.current?.(value); + } + + clear() { + if (this.opts.disabled.current) return; + if (this.opts.value.current.length === 0) return; + this.opts.value.current = []; + this.opts.onValueChange.current?.([]); + } + + // ── Keyboard ──────────────────────────────────────────────────────────── + + private focusAt(index: number) { + const items = this.getItems(); + if (items.length === 0) return; + const clamped = Math.max(0, Math.min(items.length - 1, index)); + items[clamped]?.focus(); + } + + handleItemKeydown(e: SomaKeyboardEvent, value: string) { + if (this.opts.disabled.current) return; + const dir = this.resolvedDir.current; + const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal'); + const items = this.getItems(); + if (items.length === 0) return; + const currentIndex = items.indexOf(e.currentTarget); + + if (e.key === nextKey) { + e.preventDefault(); + this.focusAt(currentIndex + 1); + return; + } + if (e.key === prevKey) { + e.preventDefault(); + this.focusAt(currentIndex - 1); + return; + } + if (e.key === KEYS.ARROW_DOWN || e.key === KEYS.ARROW_UP) { + e.preventDefault(); + const delta = e.key === KEYS.ARROW_DOWN ? 1 : -1; + this.focusAt(currentIndex + delta); + return; + } + if (e.key === KEYS.HOME) { + e.preventDefault(); + this.focusAt(0); + return; + } + if (e.key === KEYS.END) { + e.preventDefault(); + this.focusAt(items.length - 1); + return; + } + if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { + if (this.opts.selectionMode.current !== 'none') { + e.preventDefault(); + this.select(value); + } + return; + } + if (e.key === KEYS.DELETE || e.key === KEYS.BACKSPACE) { + // Backspace/Delete on a focused tag removes it and moves focus to prev/next. + e.preventDefault(); + const nextFocus = items[currentIndex + 1] ?? items[currentIndex - 1] ?? null; + this.remove(value); + // Defer focus until DOM updates. + queueMicrotask(() => nextFocus?.focus()); + return; + } + } + + handleItemClick(value: string, e: SomaMouseEvent) { + if (this.opts.disabled.current) return; + if (this.opts.selectionMode.current !== 'none') { + this.select(value); + } + e.currentTarget.focus(); + } + + readonly snippetProps = $derived.by(() => ({ + value: this.opts.value.current, + isEmpty: this.isEmpty, + clear: () => this.clear() + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'grid' as const, + 'aria-label': this.resolvedAriaLabel.current, + 'aria-labelledby': this.resolvedAriaLabelledby, + 'aria-multiselectable': + this.opts.selectionMode.current === 'multiple' ? true : undefined, + 'aria-disabled': this.opts.disabled.current ? true : undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), + 'data-empty': boolToEmptyStrOrUndef(this.isEmpty), + 'data-selection-mode': this.opts.selectionMode.current + } as const) + ); +} + +// ── Label ────────────────────────────────────────────────────────────────── + +interface TagGroupLabelOpts extends WithRefOpts {} + +export class TagGroupLabelProvider extends Provider { + static create(opts: TagGroupLabelOpts) { + return new TagGroupLabelProvider(opts); + } + + readonly provider: TagGroupProvider; + + private constructor(opts: TagGroupLabelOpts) { + super(opts, 'TagGroup', 'label', attrs.label); + this.provider = TagGroupProvider.require(); + // A30: direct assignment in constructor, not $effect. + this.provider.labelId.current = opts.id.current; + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps + } as const) + ); +} + +// ── Item ─────────────────────────────────────────────────────────────────── + +interface TagGroupItemOpts + extends WithRefOpts, + ActiveProps<{ + value: string; + disabled: boolean; + textValue: string | undefined; + }> {} + +export class TagGroupItemProvider extends Provider { + static readonly ctx = context('TagGroupItem'); + static get(): TagGroupItemProvider | undefined { + return this.ctx.getOr(undefined) as TagGroupItemProvider | undefined; + } + static require(): TagGroupItemProvider { + return this.ctx.get(); + } + + static create(opts: TagGroupItemOpts) { + return new TagGroupItemProvider(opts); + } + + readonly provider: TagGroupProvider; + + private constructor(opts: TagGroupItemOpts) { + super(opts, 'TagGroup', 'item', attrs.item, TagGroupItemProvider.ctx); + this.provider = TagGroupProvider.require(); + } + + readonly isSelected = $derived.by(() => + this.provider.isSelected(this.opts.value.current) + ); + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || this.provider.opts.disabled.current + ); + readonly isRovingTarget = $derived.by( + () => this.opts.ref.current === this.provider.rovingTargetEl + ); + + readonly remove = () => this.provider.remove(this.opts.value.current); + + readonly onclick = (e: MouseEvent) => { + if (this.isDisabled) return; + this.provider.handleItemClick( + this.opts.value.current, + e as SomaMouseEvent + ); + }; + readonly onkeydown = (e: KeyboardEvent) => { + this.provider.handleItemKeydown( + e as SomaKeyboardEvent, + this.opts.value.current + ); + }; + + readonly snippetProps = $derived.by(() => ({ + selected: this.isSelected, + disabled: this.isDisabled, + remove: this.remove + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'row' as const, + tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1, + 'aria-selected': + this.provider.opts.selectionMode.current === 'none' + ? undefined + : this.isSelected, + 'aria-disabled': this.isDisabled ? true : undefined, + 'data-value': this.opts.value.current, + 'data-text-value': this.opts.textValue.current, + 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const), + 'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + onclick: this.onclick, + onkeydown: this.onkeydown + } as const) + ); +} + +// ── Link ─────────────────────────────────────────────────────────────────── + +interface TagGroupLinkOpts + extends WithRefOpts, + ActiveProps<{ + value: string; + href: string; + disabled: boolean; + textValue: string | undefined; + }> {} + +/** + * Link variant — renders `` instead of `
` for navigable tags. + * Participates in roving tabindex + keyboard navigation just like `Item`. + */ +export class TagGroupLinkProvider extends Provider { + static create(opts: TagGroupLinkOpts) { + return new TagGroupLinkProvider(opts); + } + + readonly provider: TagGroupProvider; + + private constructor(opts: TagGroupLinkOpts) { + // 'link' renders as but carries the same data-attr part name so + // the root's selector `[${attrs.item}]` picks it up too — we add a + // DOM-query bridge via `data-tag-group-item` + 'data-tag-group-link'. + super(opts, 'TagGroup', 'link', attrs.link); + this.provider = TagGroupProvider.require(); + } + + readonly isSelected = $derived.by(() => + this.provider.isSelected(this.opts.value.current) + ); + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || this.provider.opts.disabled.current + ); + readonly isRovingTarget = $derived.by( + () => this.opts.ref.current === this.provider.rovingTargetEl + ); + + readonly remove = () => this.provider.remove(this.opts.value.current); + + readonly onkeydown = (e: KeyboardEvent) => { + this.provider.handleItemKeydown( + e as SomaKeyboardEvent, + this.opts.value.current + ); + }; + + readonly props = $derived.by(() => { + const disabled = this.isDisabled; + return this.assertProps({ + ...this.baseProps, + // We also emit the `item` part attr so the Provider's DOM queries + // find Links alongside Items — both participate in the roving set. + [attrs.item]: '', + role: 'row' as const, + href: disabled ? undefined : this.opts.href.current, + tabindex: disabled ? -1 : this.isRovingTarget ? 0 : -1, + 'aria-disabled': disabled ? true : undefined, + 'data-value': this.opts.value.current, + 'data-text-value': this.opts.textValue.current, + 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const), + 'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget), + 'data-disabled': boolToEmptyStrOrUndef(disabled), + onkeydown: this.onkeydown + } as const); + }); +} + +// ── RemoveButton ─────────────────────────────────────────────────────────── + +interface TagGroupRemoveButtonOpts + extends ProviderOpts, + ActiveProps<{ ariaLabel: string | undefined }> {} + +export class TagGroupRemoveButtonProvider extends Provider { + static create(opts: TagGroupRemoveButtonOpts) { + return new TagGroupRemoveButtonProvider(opts); + } + + readonly provider: TagGroupProvider; + readonly item: TagGroupItemProvider; + + private constructor(opts: TagGroupRemoveButtonOpts) { + super(opts, 'TagGroup', 'remove-button', attrs['remove-button']); + this.provider = TagGroupProvider.require(); + this.item = TagGroupItemProvider.require(); + } + + readonly resolvedAriaLabel = $derived.by(() => { + if (this.opts.ariaLabel.current) return this.opts.ariaLabel.current; + return ( + this.provider.soma?.langs.t(TAG_GROUP_LANGS.REMOVE, { + tag: this.item.opts.value.current + }) || 'Remove' + ); + }); + + readonly onclick = (e: MouseEvent) => { + e.stopPropagation(); + this.item.remove(); + }; + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + type: 'button' as const, + 'aria-label': this.resolvedAriaLabel, + tabindex: 0, + onclick: this.onclick + } as const) + ); +} diff --git a/src/uix/soma/components/tag-group/types.ts b/src/uix/soma/components/tag-group/types.ts new file mode 100644 index 000000000..9c73f7cdb --- /dev/null +++ b/src/uix/soma/components/tag-group/types.ts @@ -0,0 +1,130 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without, OnChangeFn } from '../../types'; +import type { + PrimitiveDivAttributes, + PrimitiveButtonAttributes, + PrimitiveAnchorAttributes +} from '../../types'; + +/** Selection mode. */ +export type TagGroupSelectionMode = 'none' | 'single' | 'multiple'; + +/** Snippet props for `TagGroup.Provider`. */ +export type TagGroupProviderSnippetProps = { + value: string[]; + isEmpty: boolean; + clear: () => void; +}; + +/** Snippet props for `TagGroup.Item`. */ +export type TagGroupItemSnippetProps = { + selected: boolean; + disabled: boolean; + remove: () => void; +}; + +// ── Root provider ────────────────────────────────────────────────────────── + +/** + * Props for `TagGroup.Provider`. + * + * A read-only grouping of tags — filter chips, selected values, labels on a + * resource. Renders as `role="grid"` with one row per tag for keyboard + * navigation (arrow keys between tags, Delete to remove). Each tag may be + * a plain span, a link, or have a RemoveButton. + * + * Distinct from `TagsInput`: TagGroup is **display-only** (no text entry). + * Use TagsInput when the user types to add tags; use TagGroup when tags come + * from selection elsewhere. + */ +export type TagGroupProps = WithChild< + { + /** DOM id. Auto-generated if omitted. */ + id?: string; + + /** List of all tag values, in render order. Not bindable — the consumer owns the list. */ + items?: string[]; + /** Current selection. Bindable. @default [] */ + value?: string[]; + /** Fires on selection change. */ + onValueChange?: OnChangeFn; + /** Fires when a tag is removed via RemoveButton / Delete key. */ + onRemove?: (value: string) => void; + + /** Selection mode. @default 'none' */ + selectionMode?: TagGroupSelectionMode; + + /** @default false */ + disabled?: boolean; + + /** + * Accessible name for the group. @default translated `'Tags'` + */ + 'aria-label'?: string; + /** ID of an external label. */ + 'aria-labelledby'?: string; + + children?: Snippet<[TagGroupProviderSnippetProps]>; + }, + TagGroupProviderSnippetProps +> & + Without; + +// ── Item ─────────────────────────────────────────────────────────────────── + +/** + * Props for `TagGroup.Item`. + * + * Rendered as `role="row"` wrapping a `role="gridcell"` (so nested interactive + * elements — the remove button, a link — are individually navigable with Tab + * within the row). + */ +export type TagGroupItemProps = WithChild< + { + id?: string; + /** Unique value for this tag. */ + value: string; + /** Whether this specific tag is disabled. @default false */ + disabled?: boolean; + /** Optional text value for typeahead (falls back to textContent). */ + textValue?: string; + children?: Snippet<[TagGroupItemSnippetProps]>; + }, + TagGroupItemSnippetProps +> & + Without; + +// ── Link variant ─────────────────────────────────────────────────────────── + +/** + * Props for `TagGroup.Link` — variant of Item that renders as an anchor. + * Use when the tag navigates somewhere. + */ +export type TagGroupLinkProps = WithChild<{ + id?: string; + value: string; + href: string; + disabled?: boolean; + textValue?: string; +}> & + Without; + +// ── RemoveButton ─────────────────────────────────────────────────────────── + +/** + * Props for `TagGroup.RemoveButton` — removes the enclosing tag on click. + */ +export type TagGroupRemoveButtonProps = WithChild<{ + id?: string; + /** Accessible name. Defaults to translated `'Remove {tag}'`. */ + 'aria-label'?: string; +}> & + Without; + +// ── Label ────────────────────────────────────────────────────────────────── + +/** Props for `TagGroup.Label` — external label linked via `aria-labelledby`. */ +export type TagGroupLabelProps = WithChild<{ + id?: string; +}> & + Without; diff --git a/src/uix/soma/components/tree-grid/README.md b/src/uix/soma/components/tree-grid/README.md new file mode 100644 index 000000000..7574da630 --- /dev/null +++ b/src/uix/soma/components/tree-grid/README.md @@ -0,0 +1,198 @@ +# TreeGrid + +Implements the WAI-ARIA [treegrid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/) — a tabular grid with hierarchical rows. + +## When to use TreeGrid vs Table vs GridList vs TreeView + +| Component | ARIA role | Use when | +| -------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------- | +| **TreeGrid** (this) | `treegrid` | Hierarchy + columns: file manager, org chart, nested tasks with due-date columns. | +| [`Table`](../table/README.md) | `table` | Flat tabular data. "Expandable rows" are **detail panels** via `Table.RowDetail` — not hierarchy. | +| [`GridList`](../grid-list/README.md) | `grid` | Flat selectable list where rows have multiple interactive elements. | +| [`TreeView`](../tree-view/README.md) | `tree` | Hierarchy without columns (plain nested labels). | + +## Anatomy + +```svelte + + + Name + Size + + + {#each tree as node} + + + ▸ + {node.name} + + {node.size} + + + + + {/each} + +``` + +## Parts + +| Part | Element | Description | +| --------------- | ---------- | ------------------------------------------------------------------------------- | +| `Provider` | `
` | `role="treegrid"`. Selection + expansion state + keyboard coordinator. | +| `Header` | `
` | `role="row"`. Optional column-header row. | +| `ColumnHeader` | `
` | `role="columnheader"`. One per column. | +| `Row` | `
` | `role="row"`. Focusable via roving tabindex. `aria-level` auto-derived. | +| `RowChildren` | `
` | `role="group"`. Wraps child rows. Rendered only when the parent is expanded. | +| `Cell` | `
` | `role="gridcell"`. Content slot. | +| `ExpandTrigger` | ` +{/if} diff --git a/src/uix/soma/components/tree-grid/components/tree-grid-header.svelte b/src/uix/soma/components/tree-grid/components/tree-grid-header.svelte new file mode 100644 index 000000000..f622038b4 --- /dev/null +++ b/src/uix/soma/components/tree-grid/components/tree-grid-header.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/tree-grid/components/tree-grid-row-children.svelte b/src/uix/soma/components/tree-grid/components/tree-grid-row-children.svelte new file mode 100644 index 000000000..3e2b757e1 --- /dev/null +++ b/src/uix/soma/components/tree-grid/components/tree-grid-row-children.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else if provider.isExpanded} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/tree-grid/components/tree-grid-row.svelte b/src/uix/soma/components/tree-grid/components/tree-grid-row.svelte new file mode 100644 index 000000000..e9b611814 --- /dev/null +++ b/src/uix/soma/components/tree-grid/components/tree-grid-row.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/tree-grid/components/tree-grid.svelte b/src/uix/soma/components/tree-grid/components/tree-grid.svelte new file mode 100644 index 000000000..6ee3bfb27 --- /dev/null +++ b/src/uix/soma/components/tree-grid/components/tree-grid.svelte @@ -0,0 +1,71 @@ + + +{#if child} + {@render child({ ...provider.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(provider.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/tree-grid/exports.ts b/src/uix/soma/components/tree-grid/exports.ts new file mode 100644 index 000000000..cc12b0af8 --- /dev/null +++ b/src/uix/soma/components/tree-grid/exports.ts @@ -0,0 +1,20 @@ +export { default as Provider } from './components/tree-grid.svelte'; +export { default as Header } from './components/tree-grid-header.svelte'; +export { default as ColumnHeader } from './components/tree-grid-column-header.svelte'; +export { default as Row } from './components/tree-grid-row.svelte'; +export { default as RowChildren } from './components/tree-grid-row-children.svelte'; +export { default as Cell } from './components/tree-grid-cell.svelte'; +export { default as ExpandTrigger } from './components/tree-grid-expand-trigger.svelte'; + +export type { + TreeGridProps as ProviderProps, + TreeGridHeaderProps as HeaderProps, + TreeGridColumnHeaderProps as ColumnHeaderProps, + TreeGridRowProps as RowProps, + TreeGridRowChildrenProps as RowChildrenProps, + TreeGridCellProps as CellProps, + TreeGridExpandTriggerProps as ExpandTriggerProps, + TreeGridProviderSnippetProps as ProviderSnippetProps, + TreeGridRowSnippetProps as RowSnippetProps, + TreeGridSelectionMode +} from './types'; diff --git a/src/uix/soma/components/tree-grid/index.ts b/src/uix/soma/components/tree-grid/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/tree-grid/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/tree-grid/langs.ts b/src/uix/soma/components/tree-grid/langs.ts new file mode 100644 index 000000000..e0cbc0e80 --- /dev/null +++ b/src/uix/soma/components/tree-grid/langs.ts @@ -0,0 +1,5 @@ +export const TREE_GRID_LANGS = { + LABEL: '#?components.tree-grid.label|Tree grid', + EXPAND: '#?components.tree-grid.expand|Expand row', + COLLAPSE: '#?components.tree-grid.collapse|Collapse row' +} as const; diff --git a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts new file mode 100644 index 000000000..3ef1cfbad --- /dev/null +++ b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts @@ -0,0 +1,725 @@ +import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider'; +import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; +import { + readableActive, + state, + type Active, + type ActiveProps, + type StateProps +} from '../../reactive'; +import type { + OnChangeFn, + Direction, + SomaKeyboardEvent, + SomaMouseEvent +} from '../../types'; +import { KEYS, getDirectionalKeys } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { TREE_GRID_LANGS } from './langs'; +import type { TreeGridSelectionMode } from './types'; + +const attrs = createAttrs({ + component: 'tree-grid', + parts: [ + 'root', + 'header', + 'column-header', + 'row', + 'row-children', + 'cell', + 'expand-trigger' + ] as const +}); + +registerContract({ + name: 'tree-grid', + version: 1, + parts: { + root: [ + { attr: 'data-disabled', description: 'Disabled' }, + { attr: 'data-readonly', description: 'Read-only' }, + { attr: 'data-empty', description: 'No selection' }, + { + attr: 'data-selection-mode', + values: ['none', 'single', 'multiple'], + description: 'Selection mode' + } + ], + header: [], + 'column-header': [], + row: [ + { + attr: 'data-state', + values: ['selected', 'unselected'], + description: 'Selection state' + }, + { + attr: 'data-expanded', + values: ['true', 'false'], + description: 'Expanded state (only on rows with children)' + }, + { attr: 'data-highlighted', description: 'Under keyboard focus' }, + { attr: 'data-disabled', description: 'Disabled' }, + { attr: 'data-level', description: 'Nesting level (1-based)' } + ], + 'row-children': [], + cell: [], + 'expand-trigger': [ + { + attr: 'data-state', + values: ['open', 'closed'], + description: 'Expand state' + } + ] + } +}); + +// ── Root provider ────────────────────────────────────────────────────────── + +interface TreeGridOpts + extends WithRefOpts, + StateProps<{ + expanded: string[]; + value: string[]; + }>, + ActiveProps<{ + selectionMode: TreeGridSelectionMode; + loop: boolean; + typeahead: boolean; + typeaheadTimeout: number; + disabled: boolean; + readonly: boolean; + ariaLabel: string | undefined; + ariaLabelledby: string | undefined; + onValueChange: OnChangeFn | undefined; + onExpandedChange: OnChangeFn | undefined; + }> {} + +export class TreeGridProvider extends Provider { + static readonly ctx = context('TreeGrid'); + static get(): TreeGridProvider | undefined { + return this.ctx.getOr(undefined) as TreeGridProvider | undefined; + } + static require(): TreeGridProvider { + return this.ctx.get(); + } + + static create(opts: TreeGridOpts) { + return new TreeGridProvider(opts); + } + + readonly soma = Soma.get(); + + private typeaheadBuffer = ''; + private typeaheadTimer: ReturnType | null = null; + private anchor: string | null = null; + + private constructor(opts: TreeGridOpts) { + super(opts, 'TreeGrid', 'root', attrs.root, TreeGridProvider.ctx); + $effect(() => { + return () => { + if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer); + }; + }); + } + + readonly resolvedDir: Active = readableActive( + () => this.soma?.presentation.getDir() ?? 'ltr' + ); + + readonly resolvedAriaLabel: Active = readableActive(() => + this.opts.ariaLabelledby.current + ? undefined + : this.opts.ariaLabel.current || + this.soma?.langs.ts(TREE_GRID_LANGS.LABEL) || + undefined + ); + + readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0); + + // ── DOM queries ───────────────────────────────────────────────────────── + + /** All visible, enabled rows (not hidden inside a collapsed parent). */ + getVisibleRows(): HTMLElement[] { + const root = this.opts.ref.current; + if (!root) return []; + const all = Array.from( + root.querySelectorAll(`[${attrs.row}]:not([data-disabled])`) + ).filter((el) => el.closest(`[${attrs.root}]`) === root); + // Exclude rows inside collapsed `data-expanded="false"` ancestor row-children. + return all.filter((el) => { + let cursor: HTMLElement | null = el.parentElement; + while (cursor && cursor !== root) { + if (cursor.matches(`[${attrs['row-children']}]`)) { + const parentRow = cursor.parentElement?.closest( + `[${attrs.row}]` + ); + if (parentRow && parentRow.getAttribute('data-expanded') === 'false') { + return false; + } + } + cursor = cursor.parentElement; + } + return true; + }); + } + + /** All rows (including those hidden inside collapsed ancestors) for range selection. */ + getAllRows(): HTMLElement[] { + const root = this.opts.ref.current; + if (!root) return []; + return Array.from(root.querySelectorAll(`[${attrs.row}]`)).filter( + (el) => el.closest(`[${attrs.root}]`) === root + ); + } + + readonly rovingTargetEl = $derived.by(() => { + const rows = this.getVisibleRows(); + if (rows.length === 0) return undefined; + const selected = new Set(this.opts.value.current); + return ( + rows.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ?? + rows[0] + ); + }); + + // ── Expand / collapse ────────────────────────────────────────────────── + + isExpanded(value: string): boolean { + return this.opts.expanded.current.includes(value); + } + + toggleExpand(value: string) { + if (this.opts.disabled.current) return; + const current = this.opts.expanded.current; + const next = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + this.opts.expanded.current = next; + this.opts.onExpandedChange.current?.(next); + } + + expand(value: string) { + if (this.opts.disabled.current) return; + if (this.opts.expanded.current.includes(value)) return; + const next = [...this.opts.expanded.current, value]; + this.opts.expanded.current = next; + this.opts.onExpandedChange.current?.(next); + } + + collapse(value: string) { + if (this.opts.disabled.current) return; + if (!this.opts.expanded.current.includes(value)) return; + const next = this.opts.expanded.current.filter((v) => v !== value); + this.opts.expanded.current = next; + this.opts.onExpandedChange.current?.(next); + } + + // ── Selection ─────────────────────────────────────────────────────────── + + isSelected(value: string): boolean { + return this.opts.value.current.includes(value); + } + + select(value: string, mode: 'replace' | 'toggle' | 'range') { + if (this.opts.disabled.current || this.opts.readonly.current) return; + const selectionMode = this.opts.selectionMode.current; + if (selectionMode === 'none') return; + const current = this.opts.value.current; + + let next: string[]; + if (mode === 'range' && selectionMode === 'multiple' && this.anchor) { + next = this.computeRange(this.anchor, value); + } else if (mode === 'toggle' && selectionMode === 'multiple') { + next = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + } else { + next = current.length === 1 && current[0] === value ? current : [value]; + } + + if (next !== current) { + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + } + if (mode !== 'range') this.anchor = value; + } + + private computeRange(from: string, to: string): string[] { + // Range uses visible rows so Shift+Click doesn't select hidden descendants. + const all = this.getVisibleRows().map( + (el) => el.getAttribute('data-value') ?? '' + ); + const i = all.indexOf(from); + const j = all.indexOf(to); + if (i < 0 || j < 0) return [to]; + const [lo, hi] = i <= j ? [i, j] : [j, i]; + return all.slice(lo, hi + 1); + } + + selectAll() { + if (this.opts.selectionMode.current !== 'multiple') return; + const next = this.getVisibleRows().map( + (el) => el.getAttribute('data-value') ?? '' + ); + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + } + + clear() { + if (this.opts.value.current.length === 0) return; + this.opts.value.current = []; + this.opts.onValueChange.current?.([]); + this.anchor = null; + } + + // ── Keyboard ──────────────────────────────────────────────────────────── + + private focusAt(index: number) { + const rows = this.getVisibleRows(); + if (rows.length === 0) return; + const clamped = Math.max(0, Math.min(rows.length - 1, index)); + rows[clamped]?.focus(); + } + + /** Find the parent row of the given row element (traverse up past row-children). */ + private parentRow(row: HTMLElement): HTMLElement | undefined { + let cursor: HTMLElement | null = row.parentElement; + while (cursor && cursor !== this.opts.ref.current) { + if (cursor.matches(`[${attrs['row-children']}]`)) { + const parent = cursor.parentElement?.closest(`[${attrs.row}]`); + if (parent) return parent; + } + cursor = cursor.parentElement; + } + return undefined; + } + + handleRowKeydown(e: SomaKeyboardEvent) { + if (this.opts.disabled.current) return; + const row = e.currentTarget; + const value = row.getAttribute('data-value') ?? ''; + const hasChildren = row.getAttribute('data-has-children') === 'true'; + const expandedAttr = row.getAttribute('data-expanded'); + + const dir = this.resolvedDir.current; + const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal'); + const rows = this.getVisibleRows(); + if (rows.length === 0) return; + const currentIndex = rows.indexOf(row); + const loop = this.opts.loop.current; + + // ArrowDown / ArrowUp + if (e.key === KEYS.ARROW_DOWN) { + e.preventDefault(); + const next = loop + ? (currentIndex + 1) % rows.length + : Math.min(currentIndex + 1, rows.length - 1); + this.focusAt(next); + return; + } + if (e.key === KEYS.ARROW_UP) { + e.preventDefault(); + const prev = loop + ? (currentIndex - 1 + rows.length) % rows.length + : Math.max(currentIndex - 1, 0); + this.focusAt(prev); + return; + } + + // ArrowRight / ArrowLeft (RTL-aware via h*Key). APG: + // - ArrowRight on collapsed branch → expand + // - ArrowRight on expanded branch → focus first child + // - ArrowRight on leaf → no-op (or move into first cell) + // - ArrowLeft on expanded branch → collapse + // - ArrowLeft on collapsed branch or leaf → focus parent row + if (e.key === hNextKey) { + if (hasChildren && expandedAttr === 'false') { + e.preventDefault(); + this.expand(value); + return; + } + if (hasChildren && expandedAttr === 'true') { + e.preventDefault(); + // Focus first child visible row (the next in visible-order beneath this one + // whose parent row == this row). + const next = rows[currentIndex + 1]; + if (next && this.parentRow(next) === row) next.focus(); + return; + } + // Leaf: fall through so the browser can move into any focusable cell. + return; + } + if (e.key === hPrevKey) { + if (hasChildren && expandedAttr === 'true') { + e.preventDefault(); + this.collapse(value); + return; + } + // Collapsed branch or leaf → focus parent row. + const parent = this.parentRow(row); + if (parent) { + e.preventDefault(); + parent.focus(); + } + return; + } + + if (e.key === KEYS.HOME) { + e.preventDefault(); + if (e.ctrlKey) this.focusAt(0); + else this.focusAt(0); + return; + } + if (e.key === KEYS.END) { + e.preventDefault(); + this.focusAt(rows.length - 1); + return; + } + if (e.key === KEYS.PAGE_UP) { + e.preventDefault(); + this.focusAt(Math.max(currentIndex - 10, 0)); + return; + } + if (e.key === KEYS.PAGE_DOWN) { + e.preventDefault(); + this.focusAt(Math.min(currentIndex + 10, rows.length - 1)); + return; + } + + if (e.key === KEYS.SPACE) { + e.preventDefault(); + const mode = + e.shiftKey && this.opts.selectionMode.current === 'multiple' + ? 'range' + : e.ctrlKey || e.metaKey + ? 'toggle' + : this.opts.selectionMode.current === 'multiple' + ? 'toggle' + : 'replace'; + this.select(value, mode); + return; + } + + if (e.key === KEYS.ENTER && hasChildren) { + e.preventDefault(); + this.toggleExpand(value); + return; + } + + if ( + (e.ctrlKey || e.metaKey) && + (e.key === 'a' || e.key === 'A') && + this.opts.selectionMode.current === 'multiple' + ) { + e.preventDefault(); + this.selectAll(); + return; + } + + if (e.key === KEYS.ESCAPE) { + if (this.opts.value.current.length > 0) { + e.preventDefault(); + this.clear(); + } + return; + } + + if ( + this.opts.typeahead.current && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + e.key.length === 1 && + /\S/.test(e.key) + ) { + this.handleTypeahead(e.key, currentIndex); + } + } + + private handleTypeahead(char: string, fromIndex: number) { + this.typeaheadBuffer += char.toLowerCase(); + if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer); + this.typeaheadTimer = setTimeout(() => { + this.typeaheadBuffer = ''; + }, this.opts.typeaheadTimeout.current); + + const rows = this.getVisibleRows(); + if (rows.length === 0) return; + const needle = this.typeaheadBuffer; + const start = needle.length === 1 ? fromIndex + 1 : fromIndex; + for (let i = 0; i < rows.length; i++) { + const idx = (start + i) % rows.length; + const el = rows[idx]; + if (!el) continue; + const text = (el.getAttribute('data-text-value') || el.textContent || '') + .trim() + .toLowerCase(); + if (text.startsWith(needle)) { + el.focus(); + return; + } + } + } + + handleRowClick(value: string, e: SomaMouseEvent) { + if (this.opts.disabled.current || this.opts.readonly.current) return; + const mode = + e.shiftKey && this.opts.selectionMode.current === 'multiple' + ? 'range' + : (e.ctrlKey || e.metaKey) && this.opts.selectionMode.current === 'multiple' + ? 'toggle' + : 'replace'; + this.select(value, mode); + e.currentTarget.focus(); + } + + readonly snippetProps = $derived.by(() => ({ + expanded: this.opts.expanded.current, + selected: this.opts.value.current, + toggleExpand: (v: string) => this.toggleExpand(v), + selectAll: () => this.selectAll(), + clear: () => this.clear() + })); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'treegrid' as const, + 'aria-label': this.resolvedAriaLabel.current, + 'aria-labelledby': this.opts.ariaLabelledby.current, + 'aria-multiselectable': + this.opts.selectionMode.current === 'multiple' ? true : undefined, + 'aria-disabled': this.opts.disabled.current ? true : undefined, + 'aria-readonly': this.opts.readonly.current ? true : undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), + 'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current), + 'data-empty': boolToEmptyStrOrUndef(this.isEmpty), + 'data-selection-mode': this.opts.selectionMode.current + } as const) + ); +} + +// ── Header ───────────────────────────────────────────────────────────────── + +interface TreeGridHeaderOpts extends WithRefOpts {} + +export class TreeGridHeaderProvider extends Provider { + static create(opts: TreeGridHeaderOpts) { + return new TreeGridHeaderProvider(opts); + } + + private constructor(opts: TreeGridHeaderOpts) { + super(opts, 'TreeGrid', 'header', attrs.header); + TreeGridProvider.require(); + } + + readonly props = $derived.by(() => + this.assertProps({ ...this.baseProps, role: 'row' as const } as const) + ); +} + +// ── ColumnHeader ─────────────────────────────────────────────────────────── + +interface TreeGridColumnHeaderOpts extends WithRefOpts {} + +export class TreeGridColumnHeaderProvider extends Provider { + static create(opts: TreeGridColumnHeaderOpts) { + return new TreeGridColumnHeaderProvider(opts); + } + + private constructor(opts: TreeGridColumnHeaderOpts) { + super(opts, 'TreeGrid', 'column-header', attrs['column-header']); + TreeGridProvider.require(); + } + + readonly props = $derived.by(() => + this.assertProps({ ...this.baseProps, role: 'columnheader' as const } as const) + ); +} + +// ── Row ──────────────────────────────────────────────────────────────────── + +interface TreeGridRowOpts + extends WithRefOpts, + ActiveProps<{ + value: string; + hasChildren: boolean; + disabled: boolean; + textValue: string | undefined; + }> {} + +export class TreeGridRowProvider extends Provider { + static readonly ctx = context('TreeGridRow'); + static get(): TreeGridRowProvider | undefined { + return this.ctx.getOr(undefined) as TreeGridRowProvider | undefined; + } + static require(): TreeGridRowProvider { + return this.ctx.get(); + } + + static create(opts: TreeGridRowOpts) { + return new TreeGridRowProvider(opts); + } + + readonly provider: TreeGridProvider; + readonly parentRow: TreeGridRowProvider | undefined; + readonly level: number; + + private constructor(opts: TreeGridRowOpts) { + super(opts, 'TreeGrid', 'row', attrs.row, TreeGridRowProvider.ctx); + this.provider = TreeGridProvider.require(); + this.parentRow = TreeGridRowProvider.get(); + this.level = (this.parentRow?.level ?? 0) + 1; + } + + readonly isSelected = $derived.by(() => + this.provider.isSelected(this.opts.value.current) + ); + readonly isExpanded = $derived.by(() => + this.provider.isExpanded(this.opts.value.current) + ); + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || this.provider.opts.disabled.current + ); + readonly isRovingTarget = $derived.by( + () => this.opts.ref.current === this.provider.rovingTargetEl + ); + + readonly onclick = (e: MouseEvent) => { + if (this.isDisabled) return; + this.provider.handleRowClick( + this.opts.value.current, + e as SomaMouseEvent + ); + }; + readonly onkeydown = (e: KeyboardEvent) => { + this.provider.handleRowKeydown(e as SomaKeyboardEvent); + }; + + readonly snippetProps = $derived.by(() => ({ + expanded: this.isExpanded, + selected: this.isSelected, + disabled: this.isDisabled, + level: this.level, + hasChildren: this.opts.hasChildren.current + })); + + readonly props = $derived.by(() => { + const hasChildren = this.opts.hasChildren.current; + return this.assertProps({ + ...this.baseProps, + role: 'row' as const, + tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1, + 'aria-level': this.level, + 'aria-expanded': hasChildren ? this.isExpanded : undefined, + 'aria-selected': + this.provider.opts.selectionMode.current === 'none' + ? undefined + : this.isSelected, + 'aria-disabled': this.isDisabled ? true : undefined, + 'data-value': this.opts.value.current, + 'data-text-value': this.opts.textValue.current, + 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const), + 'data-expanded': hasChildren ? String(this.isExpanded) : undefined, + 'data-has-children': hasChildren ? 'true' : 'false', + 'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + 'data-level': this.level, + onclick: this.onclick, + onkeydown: this.onkeydown + } as const); + }); +} + +// ── RowChildren ──────────────────────────────────────────────────────────── + +interface TreeGridRowChildrenOpts extends WithRefOpts {} + +export class TreeGridRowChildrenProvider extends Provider { + static create(opts: TreeGridRowChildrenOpts) { + return new TreeGridRowChildrenProvider(opts); + } + + readonly row: TreeGridRowProvider; + + private constructor(opts: TreeGridRowChildrenOpts) { + super(opts, 'TreeGrid', 'row-children', attrs['row-children']); + this.row = TreeGridRowProvider.require(); + } + + readonly isExpanded = $derived.by(() => this.row.isExpanded); + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'group' as const + } as const) + ); +} + +// ── Cell ─────────────────────────────────────────────────────────────────── + +interface TreeGridCellOpts extends WithRefOpts {} + +export class TreeGridCellProvider extends Provider { + static create(opts: TreeGridCellOpts) { + return new TreeGridCellProvider(opts); + } + + private constructor(opts: TreeGridCellOpts) { + super(opts, 'TreeGrid', 'cell', attrs.cell); + TreeGridProvider.require(); + } + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + role: 'gridcell' as const + } as const) + ); +} + +// ── ExpandTrigger ────────────────────────────────────────────────────────── + +interface TreeGridExpandTriggerOpts + extends ProviderOpts, + ActiveProps<{ ariaLabel: string | undefined }> {} + +export class TreeGridExpandTriggerProvider extends Provider { + static create(opts: TreeGridExpandTriggerOpts) { + return new TreeGridExpandTriggerProvider(opts); + } + + readonly provider: TreeGridProvider; + readonly row: TreeGridRowProvider; + + private constructor(opts: TreeGridExpandTriggerOpts) { + super(opts, 'TreeGrid', 'expand-trigger', attrs['expand-trigger']); + this.provider = TreeGridProvider.require(); + this.row = TreeGridRowProvider.require(); + } + + readonly resolvedAriaLabel = $derived.by(() => { + if (this.opts.ariaLabel.current) return this.opts.ariaLabel.current; + const key = this.row.isExpanded ? TREE_GRID_LANGS.COLLAPSE : TREE_GRID_LANGS.EXPAND; + return this.provider.soma?.langs.ts(key); + }); + + readonly onclick = (e: MouseEvent) => { + e.stopPropagation(); + this.provider.toggleExpand(this.row.opts.value.current); + }; + + readonly props = $derived.by(() => + this.assertProps({ + ...this.baseProps, + type: 'button' as const, + tabindex: -1, + 'aria-label': this.resolvedAriaLabel, + 'aria-hidden': true as const, + 'data-state': this.row.isExpanded ? ('open' as const) : ('closed' as const), + onclick: this.onclick + } as const) + ); +} diff --git a/src/uix/soma/components/tree-grid/types.ts b/src/uix/soma/components/tree-grid/types.ts new file mode 100644 index 000000000..8a475ecc1 --- /dev/null +++ b/src/uix/soma/components/tree-grid/types.ts @@ -0,0 +1,153 @@ +import type { Snippet } from 'svelte'; +import type { WithChild, Without, OnChangeFn } from '../../types'; +import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types'; + +/** Selection mode. */ +export type TreeGridSelectionMode = 'none' | 'single' | 'multiple'; + +export type TreeGridProviderSnippetProps = { + expanded: string[]; + selected: string[]; + toggleExpand: (value: string) => void; + selectAll: () => void; + clear: () => void; +}; + +export type TreeGridRowSnippetProps = { + expanded: boolean; + selected: boolean; + disabled: boolean; + level: number; + hasChildren: boolean; +}; + +// ── Provider ─────────────────────────────────────────────────────────────── + +/** + * Props for `TreeGrid.Provider`. + * + * Implements the WAI-ARIA [treegrid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/) + * — a tabular grid with hierarchical rows. Each row can have child rows, + * and the first cell typically carries the expand/collapse trigger. + * + * Use TreeGrid when the data has BOTH a hierarchy AND columnar attributes + * (file managers, org charts with departments, nested task lists with + * priority/due-date columns). Use `TreeView` for hierarchy-only and `Table` + * for flat tabular data. + */ +export type TreeGridProps = WithChild< + { + id?: string; + + /** Expanded row values. Bindable. @default [] */ + expanded?: string[]; + /** Fires on expansion change. */ + onExpandedChange?: OnChangeFn; + + /** Selected row values. Bindable. @default [] */ + value?: string[]; + /** Fires on selection change. */ + onValueChange?: OnChangeFn; + + /** Selection mode. @default 'single' */ + selectionMode?: TreeGridSelectionMode; + + /** Whether arrow navigation wraps at top/bottom. @default false */ + loop?: boolean; + /** Typeahead search. @default true */ + typeahead?: boolean; + /** Typeahead buffer timeout in ms. @default 500 */ + typeaheadTimeout?: number; + + /** @default false */ + disabled?: boolean; + /** @default false */ + readonly?: boolean; + + /** Accessible name. @default translated `'Tree grid'` */ + 'aria-label'?: string; + /** ID of an external label. */ + 'aria-labelledby'?: string; + + children?: Snippet<[TreeGridProviderSnippetProps]>; + }, + TreeGridProviderSnippetProps +> & + Without; + +// ── Header (optional) ────────────────────────────────────────────────────── + +/** Props for `TreeGrid.Header` — a `role="row"` of `role="columnheader"` cells. */ +export type TreeGridHeaderProps = WithChild<{ + id?: string; +}> & + Without; + +/** Props for `TreeGrid.ColumnHeader`. */ +export type TreeGridColumnHeaderProps = WithChild<{ + id?: string; +}> & + Without; + +// ── Row ──────────────────────────────────────────────────────────────────── + +/** + * Props for `TreeGrid.Row`. + * + * Rendered as `role="row"`. The consumer declares nesting by placing child + * Rows inside — no registry is needed. `level` is auto-derived from the DOM + * ancestry (1 for top-level rows, +1 for each Row ancestor). + * + * When `hasChildren` is `true`, the row carries `aria-expanded` and + * participates in the expand/collapse keyboard contract. + */ +export type TreeGridRowProps = WithChild< + { + id?: string; + /** Unique value. */ + value: string; + /** Whether the row has child rows (drives `aria-expanded`). @default false */ + hasChildren?: boolean; + /** Whether this row is disabled. @default false */ + disabled?: boolean; + /** Text for typeahead. */ + textValue?: string; + children?: Snippet<[TreeGridRowSnippetProps]>; + }, + TreeGridRowSnippetProps +> & + Without; + +// ── Cell ─────────────────────────────────────────────────────────────────── + +/** Props for `TreeGrid.Cell` — `role="gridcell"`. */ +export type TreeGridCellProps = WithChild<{ + id?: string; +}> & + Without; + +// ── RowChildren (container for child rows) ───────────────────────────────── + +/** + * Props for `TreeGrid.RowChildren` — renders child rows when the parent is + * expanded. Rendered as `role="group"` (or present-only container). Provides + * the ancestor marker used to auto-derive `level`. + */ +export type TreeGridRowChildrenProps = WithChild<{ + id?: string; +}> & + Without; + +// ── ExpandTrigger ────────────────────────────────────────────────────────── + +/** + * Props for `TreeGrid.ExpandTrigger` — the toggle for expand/collapse. Place + * inside a Cell (typically the first one). Emits the correct `aria-label` + * based on the row's expanded state. + */ +export type TreeGridExpandTriggerProps = WithChild<{ + id?: string; + /** Explicit label override. Defaults to translated `'Expand'` / `'Collapse'` swap. */ + 'aria-label'?: string; +}> & + Without; diff --git a/src/uix/soma/core/langs.ts b/src/uix/soma/core/langs.ts index 88a9f3ecc..b1c883639 100644 --- a/src/uix/soma/core/langs.ts +++ b/src/uix/soma/core/langs.ts @@ -27,11 +27,46 @@ export const componentLangs = { cancel: { es: 'Cancelar', en: 'Cancel' }, }, + announce: { + label: { es: 'Anuncios', en: 'Announcements' }, + }, + + avatar: {}, + breadcrumb: { label: { es: 'Migas de pan', en: 'Breadcrumb' }, ellipsis: { es: 'Más', en: 'More' }, }, + clipboard: { + copy: { es: 'Copiar al portapapeles', en: 'Copy to clipboard' }, + copied: { es: 'Copiado', en: 'Copied' }, + }, + + feed: { + label: { es: 'Feed', en: 'Feed' }, + 'article-label': { es: 'Artículo', en: 'Article' }, + }, + + 'grid-list': { + label: { es: 'Lista en cuadrícula', en: 'Grid list' }, + 'select-row': { es: 'Seleccionar fila', en: 'Select row' }, + 'select-all': { es: 'Seleccionar todo', en: 'Select all' }, + }, + + meter: { + label: { es: 'Medidor', en: 'Meter' }, + }, + + progress: { + label: { es: 'Cargando', en: 'Loading' }, + }, + + 'search-field': { + label: { es: 'Buscar', en: 'Search' }, + clear: { es: 'Borrar búsqueda', en: 'Clear search' }, + }, + combobox: { toggle: { es: 'Alternar', en: 'Toggle' }, }, @@ -183,6 +218,32 @@ export const componentLangs = { 'format-select': { es: 'Formato de color', en: 'Color format' }, }, + 'drag-drop': { + 'drag-started': { + es: 'Arrastrando {{item}}', + en: 'Started dragging {{item}}', + }, + 'drag-over': { + es: 'Sobre zona de destino {{target}}', + en: 'Over drop zone {{target}}', + }, + dropped: { + es: 'Soltado {{item}} sobre {{target}}', + en: 'Dropped {{item}} on {{target}}', + }, + cancelled: { es: 'Arrastre cancelado', en: 'Drag cancelled' }, + 'no-targets': { + es: 'Sin zonas de destino disponibles', + en: 'No drop targets available', + }, + 'draggable-role': { es: 'arrastrable', en: 'draggable' }, + 'droppable-role': { es: 'zona de destino', en: 'drop zone' }, + 'droppable-active': { + es: 'zona de destino activa', + en: 'active drop zone', + }, + }, + drawer: { trigger: { es: 'Abrir cajón', en: 'Open drawer' }, }, @@ -248,6 +309,8 @@ export const componentLangs = { globalFilter: { es: 'Buscar en todas las columnas...', en: 'Search all columns...' }, allRoles: { es: 'Todos los roles', en: 'All roles' }, columns: { es: 'Columnas', en: 'Columns' }, + 'row-detail-expand': { es: 'Mostrar detalles', en: 'Show details' }, + 'row-detail-collapse': { es: 'Ocultar detalles', en: 'Hide details' }, }, 'number-field': { @@ -258,6 +321,11 @@ export const componentLangs = { label: { es: 'Principal', en: 'Main' }, }, + 'tag-group': { + label: { es: 'Etiquetas', en: 'Tags' }, + remove: { es: 'Eliminar {{tag}}', en: 'Remove {{tag}}' }, + }, + 'tags-input': { clear: { es: 'Limpiar todo', en: 'Clear all' }, }, @@ -267,6 +335,12 @@ export const componentLangs = { 'scrollbar-horizontal': { es: 'Barra de desplazamiento horizontal', en: 'Horizontal scrollbar' }, }, + 'tree-grid': { + label: { es: 'Cuadrícula de árbol', en: 'Tree grid' }, + expand: { es: 'Expandir fila', en: 'Expand row' }, + collapse: { es: 'Colapsar fila', en: 'Collapse row' }, + }, + 'tree-view': { label: { es: 'Árbol', en: 'Tree' }, }, diff --git a/src/uix/soma/index.ts b/src/uix/soma/index.ts index 361e7b815..e013c39f1 100644 --- a/src/uix/soma/index.ts +++ b/src/uix/soma/index.ts @@ -34,7 +34,6 @@ export { type AttrsReturn, assertContract, registerContract, - type ComponentContract, boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef,
- {#if row.getCanExpand} - - {/if} - + ▶ +
- -
- -

{row.original.name} — {row.original.email}

-
+ {@render children?.()} +
(config.initialColumnPinning ?? {}); let columnSizing = $state(config.initialColumnSizing ?? {}); - let expanded = $state(config.initialExpanded ?? {}); + let rowDetailOpen = $state(config.initialRowDetailOpen ?? {}); // ── Derived: sorted + paginated rows ───────────────────────────────── @@ -474,68 +474,30 @@ export function createTable(config: TableConfig): TableInstance { - const rowIdx = counter.value++; - const baseId = getRowId(data, globalIdx); - const id = parentId != null ? `${parentId}.${baseId}` : baseId; + function buildRow(data: TData, globalIdx: number, rowIdx: number): TableRow { + const id = getRowId(data, globalIdx); const orderedCols = getOrderedColumns(); const cells: TableCell[] = orderedCols.map((col) => ({ id: `${id}_${col.id}`, column: col, value: col.getValue(data) })); - const subRowData = config.getRowSubRows?.(data) ?? []; - const canExpand = config.getRowCanExpand ? config.getRowCanExpand(data) : subRowData.length > 0; - const isExpanded = !!expanded[id]; - const subRows = - canExpand && isExpanded - ? subRowData.map((sub, subIdx) => - buildRow(sub, globalIdx + subIdx + 1, depth + 1, counter, id, subIdx) - ) - : []; return { id, index: rowIdx, original: data, - cells, - depth, - parentId, - subRows, - getCanExpand: canExpand, - getIsExpanded: isExpanded - } as TableRow; - } - - function flattenRows(rows: TableRow[]): TableRow[] { - const result: TableRow[] = []; - for (const row of rows) { - result.push(row); - if (row.subRows.length > 0) { - result.push(...flattenRows(row.subRows)); - } - } - return result; + cells + }; } - const rows = $derived.by(() => { - const counter = { - value: config.pagination?.enabled ? pagination.pageIndex * pagination.pageSize : 0 - }; - const built = paginatedRows.map((data, idx) => { + const rows = $derived.by(() => + paginatedRows.map((data, idx) => { const globalIdx = config.pagination?.enabled ? pagination.pageIndex * pagination.pageSize + idx : idx; - return buildRow(data, globalIdx, 0, counter); - }); - return flattenRows(built); - }); + return buildRow(data, globalIdx, idx); + }) + ); // ── Column visibility actions ──────────────────────────────────────── @@ -848,34 +810,32 @@ export function createTable(config: TableConfig): TableInstance r.id === rowId); - return row?.getCanExpand ?? false; + return row ? config.getRowCanShowDetail(row.original) : false; } - function toggleAllRowsExpanded() { - const allExpanded = rows.every((r) => !r.getCanExpand || expanded[r.id]); - if (allExpanded) { - expanded = {}; - } else { - const next: ExpandedState = {}; - rows.forEach((r) => { - if (r.getCanExpand) next[r.id] = true; - }); - expanded = next; - } - config.onExpandedChange?.(expanded); + function closeAllRowDetails() { + if (Object.keys(rowDetailOpen).length === 0) return; + rowDetailOpen = {}; + config.onRowDetailOpenChange?.(rowDetailOpen); } // ── Instance ───────────────────────────────────────────────────────── @@ -930,14 +890,15 @@ export function createTable(config: TableConfig): TableInstance` boundary. + */ +export function tableRowDetailDomId(rowId: string): string { + return `table-row-detail-${rowId}`; +} + function buildColumnStyle(table: TableInstance, colId: string): string | undefined { const pinned = table.getIsColumnPinned(colId); const hasSize = table.hasExplicitSize(colId); @@ -185,6 +215,10 @@ export class TableRowProvider extends Provider { readonly isSelected = $derived.by(() => this.table.getIsRowSelected(this.row.id)); + get rowId(): string { + return this.row.id; + } + readonly onclick = () => { if (this.table.selectionMode !== 'none') { this.table.toggleRowSelection(this.row.id); @@ -200,23 +234,18 @@ export class TableRowProvider extends Provider { } }; - readonly props = $derived.by(() => { - const canExpand = this.row.getCanExpand; - const isExpanded = this.row.getIsExpanded; - return this.assertProps({ + readonly props = $derived.by(() => + this.assertProps({ ...this.baseProps, role: 'row' as const, 'aria-rowindex': this.row.index + 1, 'aria-selected': this.table.selectionMode !== 'none' ? this.isSelected : undefined, - 'aria-expanded': canExpand ? isExpanded : undefined, tabindex: this.table.selectionMode !== 'none' ? 0 : undefined, 'data-selected': boolToEmptyStrOrUndef(this.isSelected), - 'data-expanded': canExpand ? boolToEmptyStrOrUndef(isExpanded) : undefined, - 'data-depth': this.row.depth > 0 ? String(this.row.depth) : undefined, onclick: this.table.selectionMode !== 'none' ? this.onclick : undefined, onkeydown: this.table.selectionMode !== 'none' ? this.onkeydown : undefined - } as const); - }); + } as const) + ); } // ── Cell ───────────────────────────────────────────────────────────────────── @@ -317,3 +346,126 @@ export class TableFooterProvider extends Provider { } as const) ); } + +// ── RowDetail (disclosure panel, full-width