- Rename root→provider in all 25 component providers (terra convention) - Fix translations: use translatePath (ling.t) instead of translate (ling.ts) for dot-path resolution with soma. prefix - Move onChange callbacks from provider Opts to writableActive setters in svelte wrappers (terra pattern, reduces prop duplication) - Add shared test layout with ling translator for all soma test pages - Add Table component with sorting, selection, pagination, filtering, column visibility, ARIA grid pattern - Add NumberField component with glob numr integration - Fix table headers reactivity (missing getter) - Fix table column filters (was passing row instead of cellValue) - Add AUDIT.md with F1-F15 findings (11 fixed, 4 pending) - Revert accidental terra modifications Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>semantuix
parent
dad3d26139
commit
1fd0b46b2e
@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Soma } from '$soma/components/internal';
|
||||
import { createLing, type LingNode } from '$lib/ling/lib';
|
||||
import { createSomaTranslator, extendSomaTranslationModule } from '$soma/core/translator.svelte';
|
||||
import type { SomaPresentationConfig } from '$soma/types';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
// ── Ling ─────────────────────────────────────────────────────────────────
|
||||
const ling = createLing(
|
||||
{
|
||||
app: {
|
||||
title: { es: 'Test Soma', en: 'Test Soma' },
|
||||
},
|
||||
} satisfies LingNode,
|
||||
'es',
|
||||
);
|
||||
|
||||
// ── Soma translations ────────────────────────────────────────────────────
|
||||
const somaModule = {
|
||||
dialog: {
|
||||
trigger: { es: 'Abrir diálogo', en: 'Open dialog' },
|
||||
close: { es: 'Cerrar', en: 'Close' },
|
||||
content: {
|
||||
roledescription: { es: 'ventana de diálogo', en: 'dialog window' },
|
||||
},
|
||||
},
|
||||
popover: {
|
||||
close: { es: 'Cerrar', en: 'Close' },
|
||||
},
|
||||
toast: {
|
||||
close: { es: 'Cerrar', en: 'Close' },
|
||||
},
|
||||
editable: {
|
||||
edit: { es: 'Editar', en: 'Edit' },
|
||||
submit: { es: 'Guardar', en: 'Save' },
|
||||
cancel: { es: 'Cancelar', en: 'Cancel' },
|
||||
},
|
||||
pagination: {
|
||||
label: { es: 'Paginación', en: 'Pagination' },
|
||||
prev: { es: 'Página anterior', en: 'Previous page' },
|
||||
next: { es: 'Página siguiente', en: 'Next page' },
|
||||
},
|
||||
table: {
|
||||
label: { es: 'Tabla de datos', en: 'Data table' },
|
||||
sort: { es: 'Ordenar', en: 'Sort' },
|
||||
sortAsc: { es: 'Ordenado ascendente', en: 'Sorted ascending' },
|
||||
sortDesc: { es: 'Ordenado descendente', en: 'Sorted descending' },
|
||||
perPage: { es: 'por página', en: 'per page' },
|
||||
noResults: { es: 'Sin resultados', en: 'No results' },
|
||||
globalFilter: { es: 'Buscar en todas las columnas...', en: 'Search all columns...' },
|
||||
clear: { es: 'Limpiar', en: 'Clear' },
|
||||
allRoles: { es: 'Todos los roles', en: 'All roles' },
|
||||
columns: { es: 'Columnas', en: 'Columns' },
|
||||
},
|
||||
'number-field': {
|
||||
role: { es: 'Campo numérico', en: 'Number field' },
|
||||
increment: { es: 'Incrementar', en: 'Increment' },
|
||||
decrement: { es: 'Decrementar', en: 'Decrement' },
|
||||
},
|
||||
'tags-input': {
|
||||
remove: { es: 'Eliminar', en: 'Remove' },
|
||||
clear: { es: 'Limpiar todo', en: 'Clear all' },
|
||||
},
|
||||
'tree-view': {
|
||||
label: { es: 'Árbol', en: 'Tree' },
|
||||
},
|
||||
} satisfies LingNode;
|
||||
|
||||
extendSomaTranslationModule(ling, somaModule);
|
||||
|
||||
// ── Translator adapter ───────────────────────────────────────────────────
|
||||
const translator = createSomaTranslator({
|
||||
locale: () => activeLocale,
|
||||
translate: () => ling.ts.bind(ling),
|
||||
translatePath: () => ling.t.bind(ling) as any,
|
||||
runtime: () => ling,
|
||||
});
|
||||
|
||||
// ── Presentation ─────────────────────────────────────────────────────────
|
||||
let activeDir = $state<'ltr' | 'rtl'>('ltr');
|
||||
let activeLocale = $state<'es' | 'en'>('es');
|
||||
|
||||
const presentation: SomaPresentationConfig = {
|
||||
get dir() { return activeDir; },
|
||||
};
|
||||
|
||||
// ── Locale switcher ──────────────────────────────────────────────────────
|
||||
function setLocale(locale: 'es' | 'en') {
|
||||
ling.setLocale(locale);
|
||||
activeLocale = locale;
|
||||
}
|
||||
|
||||
function setDir(dir: 'ltr' | 'rtl') {
|
||||
activeDir = dir;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Soma {translator} {presentation}>
|
||||
<div class="soma-test-controls">
|
||||
<span class="label">Locale:</span>
|
||||
<button class:active={activeLocale === 'es'} onclick={() => setLocale('es')}>ES</button>
|
||||
<button class:active={activeLocale === 'en'} onclick={() => setLocale('en')}>EN</button>
|
||||
<span class="sep">|</span>
|
||||
<span class="label">Dir:</span>
|
||||
<button class:active={activeDir === 'ltr'} onclick={() => setDir('ltr')}>LTR</button>
|
||||
<button class:active={activeDir === 'rtl'} onclick={() => setDir('rtl')}>RTL</button>
|
||||
<span class="sep">|</span>
|
||||
<span class="info">locale: {activeLocale} · dir: {activeDir}</span>
|
||||
</div>
|
||||
|
||||
{@render children()}
|
||||
</Soma>
|
||||
|
||||
<style>
|
||||
.soma-test-controls {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.soma-test-controls .label {
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.soma-test-controls .sep {
|
||||
color: #475569;
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.soma-test-controls .info {
|
||||
color: #64748b;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.soma-test-controls button {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.soma-test-controls button:hover {
|
||||
background: #334155;
|
||||
}
|
||||
.soma-test-controls button.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import * as NumberField from '$soma/components/number-field';
|
||||
|
||||
let quantity = $state<number | undefined>(5);
|
||||
let price = $state<number | undefined>(29.99);
|
||||
let percent = $state<number | undefined>(0.75);
|
||||
let scrubValue = $state<number | undefined>(50);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>NumberField · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Soma NumberField</h1>
|
||||
<p>Numeric input with increment/decrement, keyboard, scrubber, locale-aware formatting via createNumr.</p>
|
||||
|
||||
<!-- ── 1. Basic ───────────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>1. Basic (min 0, max 99, step 1)</h2>
|
||||
<p class="state">value: {quantity}</p>
|
||||
|
||||
<NumberField.Provider bind:value={quantity} min={0} max={99}>
|
||||
<div class="field">
|
||||
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
|
||||
<NumberField.Input />
|
||||
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
|
||||
</div>
|
||||
</NumberField.Provider>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. Currency ────────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>2. Currency (EUR, step 0.01)</h2>
|
||||
<p class="state">value: {price}</p>
|
||||
|
||||
<NumberField.Provider
|
||||
bind:value={price}
|
||||
min={0}
|
||||
step={0.01}
|
||||
largeStep={1}
|
||||
formatOptions={{ style: 'currency', currency: 'EUR', minDecimals: 2 }}
|
||||
>
|
||||
<div class="field">
|
||||
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
|
||||
<NumberField.Input />
|
||||
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
|
||||
</div>
|
||||
</NumberField.Provider>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. Percentage ──────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>3. Percentage (0-1 internal, displays as 0%-100%)</h2>
|
||||
<p class="desc">Internal value 0.75 is edited as 75 and displayed as 75%.</p>
|
||||
<p class="state">value: {percent}</p>
|
||||
|
||||
<NumberField.Provider
|
||||
bind:value={percent}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
formatOptions={{ style: 'percent' }}
|
||||
>
|
||||
<div class="field">
|
||||
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
|
||||
<NumberField.Input />
|
||||
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
|
||||
</div>
|
||||
</NumberField.Provider>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. Scrubber ────────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>4. With scrubber (drag to adjust)</h2>
|
||||
<p class="desc">Drag the label left/right to change the value.</p>
|
||||
<p class="state">value: {scrubValue}</p>
|
||||
|
||||
<NumberField.Provider bind:value={scrubValue} min={0} max={100} allowMouseWheel>
|
||||
<div class="field-with-scrubber">
|
||||
<NumberField.Scrubber>Opacity</NumberField.Scrubber>
|
||||
<div class="field">
|
||||
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
|
||||
<NumberField.Input />
|
||||
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</NumberField.Provider>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 2rem auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
h1 { margin-bottom: 0.25rem; }
|
||||
h2 { margin-top: 2rem; margin-bottom: 0.5rem; }
|
||||
p { color: #555; }
|
||||
.state { font-family: monospace; font-size: 0.85rem; color: #64748b; }
|
||||
.desc { font-size: 0.9rem; margin-bottom: 0.25rem; }
|
||||
section { margin-bottom: 2rem; }
|
||||
|
||||
.field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global([data-number-field-input]) {
|
||||
width: 120px;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
:global([data-number-field-input]:focus) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-number-field-increment-trigger]),
|
||||
:global([data-number-field-decrement-trigger]) {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
line-height: 1;
|
||||
}
|
||||
:global([data-number-field-increment-trigger]:hover),
|
||||
:global([data-number-field-decrement-trigger]:hover) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
:global([data-number-field-increment-trigger][disabled]),
|
||||
:global([data-number-field-decrement-trigger][disabled]) {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field-with-scrubber {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
:global([data-number-field-scrubber]) {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
:global([data-number-field-scrubber][data-scrubbing]) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,288 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$soma/components/table';
|
||||
import * as Pagination from '$soma/components/pagination';
|
||||
import { createTable, type ColumnDef } from '$soma/components/table';
|
||||
import { getSoma } from '$soma/core/soma.svelte';
|
||||
|
||||
const soma = getSoma();
|
||||
function tt(path: string, fallback: string): string {
|
||||
return soma.translator.current.translatePath(`soma.table.${path}`) || fallback;
|
||||
}
|
||||
|
||||
interface Person {
|
||||
name: string;
|
||||
age: number;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const data: Person[] = [
|
||||
{ name: 'Alice Johnson', age: 32, email: 'alice@example.com', role: 'Engineer' },
|
||||
{ name: 'Bob Smith', age: 45, email: 'bob@example.com', role: 'Manager' },
|
||||
{ name: 'Carol White', age: 28, email: 'carol@example.com', role: 'Designer' },
|
||||
{ name: 'David Brown', age: 38, email: 'david@example.com', role: 'Engineer' },
|
||||
{ name: 'Eve Davis', age: 29, email: 'eve@example.com', role: 'Designer' },
|
||||
{ name: 'Frank Miller', age: 52, email: 'frank@example.com', role: 'Director' },
|
||||
{ name: 'Grace Lee', age: 35, email: 'grace@example.com', role: 'Engineer' },
|
||||
{ name: 'Henry Wilson', age: 41, email: 'henry@example.com', role: 'Manager' },
|
||||
{ name: 'Ivy Chen', age: 26, email: 'ivy@example.com', role: 'Designer' },
|
||||
{ name: 'Jack Taylor', age: 33, email: 'jack@example.com', role: 'Engineer' },
|
||||
{ name: 'Kate Moore', age: 47, email: 'kate@example.com', role: 'Director' },
|
||||
{ name: 'Leo Garcia', age: 30, email: 'leo@example.com', role: 'Engineer' },
|
||||
];
|
||||
|
||||
const columns: ColumnDef<Person>[] = [
|
||||
{ accessorKey: 'name', header: 'Name', enableSorting: true, enableFiltering: true },
|
||||
{ accessorKey: 'age', header: 'Age', enableSorting: true, enableFiltering: true },
|
||||
{ accessorKey: 'email', header: 'Email', enableFiltering: true },
|
||||
{ accessorKey: 'role', header: 'Role', enableSorting: true, enableFiltering: true },
|
||||
];
|
||||
|
||||
const table1 = createTable({
|
||||
data: () => data,
|
||||
columns,
|
||||
sorting: { enabled: true, multi: true },
|
||||
selection: { mode: 'multiple' },
|
||||
pagination: { enabled: true, pageSize: 5 },
|
||||
filtering: { enabled: true },
|
||||
});
|
||||
|
||||
// Sync pagination: table state ↔ Pagination component
|
||||
let currentPage = $state(1);
|
||||
|
||||
$effect(() => {
|
||||
currentPage = table1.pagination.pageIndex + 1;
|
||||
});
|
||||
|
||||
function onPageChange(page: number) {
|
||||
table1.setPageIndex(page - 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Table · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Soma Table</h1>
|
||||
<p>Headless table with sorting, selection, pagination, filtering, column visibility, ARIA grid, Soma i18n/dir.</p>
|
||||
|
||||
<section>
|
||||
<h2>Table</h2>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<label for="global-filter">Global filter:</label>
|
||||
<input
|
||||
id="global-filter"
|
||||
type="text"
|
||||
placeholder={tt('globalFilter', 'Search all columns...')}
|
||||
value={table1.globalFilter}
|
||||
oninput={(e) => table1.setGlobalFilter((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
{#if table1.globalFilter}
|
||||
<button onclick={() => table1.setGlobalFilter('')}>{tt('clear', 'Clear')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="role-filter">Role filter:</label>
|
||||
<select id="role-filter" onchange={(e) => {
|
||||
const v = (e.currentTarget as HTMLSelectElement).value;
|
||||
if (v) table1.setColumnFilter('role', v);
|
||||
else table1.removeColumnFilter('role');
|
||||
}}>
|
||||
<option value="">{tt('allRoles', 'All roles')}</option>
|
||||
<option value="Engineer">Engineer</option>
|
||||
<option value="Manager">Manager</option>
|
||||
<option value="Designer">Designer</option>
|
||||
<option value="Director">Director</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span>{tt('columns', 'Columns')}:</span>
|
||||
{#each table1.columns as col}
|
||||
<label class="col-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={table1.getIsColumnVisible(col.id)}
|
||||
onchange={() => table1.toggleColumnVisibility(col.id)}
|
||||
/>
|
||||
{col.def.header ?? col.id}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State -->
|
||||
<p class="state">
|
||||
sorting: {JSON.stringify(table1.sorting)} |
|
||||
selected: {Object.keys(table1.selection).filter(k => table1.selection[k]).length} rows |
|
||||
page: {currentPage}/{table1.pageCount} |
|
||||
filters: {JSON.stringify(table1.columnFilters)} |
|
||||
global: "{table1.globalFilter}"
|
||||
</p>
|
||||
|
||||
<!-- Table -->
|
||||
<Table.Provider table={table1}>
|
||||
<table class="demo-table" role="grid" aria-label="Team members">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={table1.getIsAllSelected()}
|
||||
indeterminate={table1.getIsSomeSelected()}
|
||||
onchange={() => table1.toggleAllSelection()}
|
||||
/>
|
||||
</th>
|
||||
{#each table1.headers as header}
|
||||
<Table.ColumnHeader {header}>
|
||||
{header.label}
|
||||
{#if table1.getIsSorted(header.id) === 'asc'}
|
||||
▲
|
||||
{:else if table1.getIsSorted(header.id) === 'desc'}
|
||||
▼
|
||||
{/if}
|
||||
</Table.ColumnHeader>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each table1.rows as row (row.id)}
|
||||
<Table.Row {row}>
|
||||
<td class="checkbox-col" onclick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={table1.getIsRowSelected(row.id)}
|
||||
onchange={() => table1.toggleRowSelection(row.id)}
|
||||
/>
|
||||
</td>
|
||||
{#each row.cells as cell, colIdx}
|
||||
<Table.Cell {cell} colIndex={colIdx}>
|
||||
{cell.value}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{#if table1.rows.length === 0}
|
||||
<tr><td colspan={table1.headers.length + 1} class="empty">{tt('noResults', 'No results')}</td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</Table.Provider>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination.Provider
|
||||
count={table1.allRows.length}
|
||||
bind:page={currentPage}
|
||||
pageSize={table1.pagination.pageSize}
|
||||
siblingCount={1}
|
||||
onPageChange={onPageChange}
|
||||
>
|
||||
{#snippet child({ pages, totalPages })}
|
||||
<nav class="pagination-controls">
|
||||
<Pagination.PrevTrigger>
|
||||
<span aria-hidden="true">←</span>
|
||||
</Pagination.PrevTrigger>
|
||||
|
||||
{#each pages as pageItem}
|
||||
{#if pageItem.type === 'page'}
|
||||
<Pagination.Item value={pageItem.value}>
|
||||
{pageItem.value}
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Ellipsis position={pageItem.position}>
|
||||
…
|
||||
</Pagination.Ellipsis>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Pagination.NextTrigger>
|
||||
<span aria-hidden="true">→</span>
|
||||
</Pagination.NextTrigger>
|
||||
|
||||
<select class="page-size" onchange={(e) => table1.setPageSize(Number((e.currentTarget as HTMLSelectElement).value))}>
|
||||
<option value="5">5 {tt('perPage', 'per page')}</option>
|
||||
<option value="10">10 {tt('perPage', 'per page')}</option>
|
||||
<option value="25">25 {tt('perPage', 'per page')}</option>
|
||||
</select>
|
||||
</nav>
|
||||
{/snippet}
|
||||
</Pagination.Provider>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 860px;
|
||||
margin: 2rem auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
h1 { margin-bottom: 0.25rem; }
|
||||
h2 { margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
||||
p { color: #555; }
|
||||
.state { font-family: monospace; font-size: 0.75rem; color: #64748b; word-break: break-all; margin-bottom: 0.75rem; }
|
||||
section { margin-bottom: 2rem; }
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 0.75rem;
|
||||
padding: 0.75rem; border: 1px solid #e2e8f0; border-radius: 8px; background: #fafafa;
|
||||
}
|
||||
.filter-group { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; }
|
||||
.filter-group label, .filter-group span { font-weight: 500; color: #475569; }
|
||||
.filter-group input[type="text"] {
|
||||
padding: 0.3rem 0.5rem; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.85rem; width: 160px;
|
||||
}
|
||||
.filter-group select {
|
||||
padding: 0.3rem; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.85rem;
|
||||
}
|
||||
.filter-group button {
|
||||
padding: 0.2rem 0.5rem; border: 1px solid #e2e8f0; border-radius: 4px;
|
||||
background: white; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.col-toggle { display: flex; align-items: center; gap: 2px; font-weight: 400; cursor: pointer; }
|
||||
|
||||
/* Table */
|
||||
.demo-table {
|
||||
width: 100%; border-collapse: collapse; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.demo-table :global(th),
|
||||
.demo-table :global(td) {
|
||||
padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid #e2e8f0; font-size: 0.9rem;
|
||||
}
|
||||
.demo-table :global(th) { background: #f8fafc; font-weight: 600; color: #475569; }
|
||||
.demo-table :global([data-sortable]) { cursor: pointer; user-select: none; }
|
||||
.demo-table :global([data-sortable]:hover) { background: #e2e8f0; }
|
||||
.demo-table :global([data-sorted]) { color: #1e293b; }
|
||||
.demo-table :global([data-selected]) { background: #eff6ff; }
|
||||
.demo-table :global(tr:hover td) { background: #f8fafc; }
|
||||
.demo-table :global([data-selected]:hover td) { background: #dbeafe; }
|
||||
.checkbox-col { width: 2rem; text-align: center; }
|
||||
.empty { text-align: center; color: #94a3b8; padding: 2rem !important; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination-controls {
|
||||
display: flex; align-items: center; gap: 0.25rem; margin-top: 0.75rem; font-size: 0.85rem;
|
||||
}
|
||||
.pagination-controls :global([data-pagination-prev-trigger]),
|
||||
.pagination-controls :global([data-pagination-next-trigger]) {
|
||||
padding: 0.3rem 0.6rem; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer;
|
||||
}
|
||||
.pagination-controls :global([data-pagination-item]) {
|
||||
padding: 0.3rem 0.6rem; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer;
|
||||
min-width: 2rem; text-align: center;
|
||||
}
|
||||
.pagination-controls :global([data-pagination-item][data-selected]) {
|
||||
background: #3b82f6; color: white; border-color: #3b82f6;
|
||||
}
|
||||
.pagination-controls :global([data-pagination-ellipsis]) {
|
||||
padding: 0.3rem 0.4rem; color: #94a3b8;
|
||||
}
|
||||
.pagination-controls :global(button:hover:not(:disabled)) { background: #f8fafc; border-color: #94a3b8; }
|
||||
.pagination-controls :global(button:disabled) { opacity: 0.4; cursor: not-allowed; }
|
||||
.page-size { padding: 0.3rem; border: 1px solid #e2e8f0; border-radius: 4px; margin-left: auto; }
|
||||
</style>
|
||||
@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { readableActive } from '$soma/reactive';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import type { SomaProps } from '$soma/types';
|
||||
|
||||
let { children, ...props }: SomaProps & { children?: Snippet } = $props();
|
||||
let { translator, presentation, portalTo, logger, children }: SomaProps = $props();
|
||||
|
||||
Soma.create(props);
|
||||
Soma.create({
|
||||
portalTo: readableActive(() => portalTo),
|
||||
translator: readableActive(() => translator),
|
||||
presentation: readableActive(() => presentation),
|
||||
logger: readableActive(() => logger)
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
@ -0,0 +1,184 @@
|
||||
# NumberField
|
||||
|
||||
A numeric input with increment/decrement triggers, keyboard shortcuts, optional scrubber for drag-based adjustment, mouse wheel support, and locale-aware formatting via `createNumr` from glob. Implements the WAI-ARIA spinbutton pattern.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<NumberField.Provider bind:value min={0} max={100}>
|
||||
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
|
||||
<NumberField.Input />
|
||||
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
|
||||
</NumberField.Provider>
|
||||
```
|
||||
|
||||
Optional scrubber for pointer-drag adjustment:
|
||||
|
||||
```svelte
|
||||
<NumberField.Provider bind:value>
|
||||
<NumberField.Scrubber>Drag to adjust</NumberField.Scrubber>
|
||||
<NumberField.Input />
|
||||
</NumberField.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
|------|---------|-------------|
|
||||
| `Provider` | `<div>` | Root container. Manages value, formatting, constraints. |
|
||||
| `Input` | `<input>` | Text input with `role="spinbutton"`. Handles keyboard, formatting. |
|
||||
| `IncrementTrigger` | `<button>` | Increment button. Hold to repeat. Auto-disabled at max. |
|
||||
| `DecrementTrigger` | `<button>` | Decrement button. Hold to repeat. Auto-disabled at min. |
|
||||
| `Scrubber` | `<div>` | Optional drag area. Pointer drag adjusts value by step. |
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
|------|-----------|-------|
|
||||
| Input | `role` | `spinbutton` |
|
||||
| Input | `aria-valuenow` | Current numeric value |
|
||||
| Input | `aria-valuemin` | Minimum (if set) |
|
||||
| Input | `aria-valuemax` | Maximum (if set) |
|
||||
| Input | `aria-valuetext` | String representation of value |
|
||||
| Input | `aria-disabled` | `true` (when disabled) |
|
||||
| Input | `aria-invalid` | `true` (when invalid) |
|
||||
| Input | `aria-required` | `true` (when required) |
|
||||
| Input | `aria-readonly` | `true` (when readonly) |
|
||||
| IncrementTrigger | `aria-label` | Translated "Increment" |
|
||||
| DecrementTrigger | `aria-label` | Translated "Decrement" |
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
|------|-----------|--------|
|
||||
| Provider | `data-number-field` | Always present |
|
||||
| Provider | `data-disabled` | Present when disabled |
|
||||
| Provider | `data-readonly` | Present when readonly |
|
||||
| Provider | `data-required` | Present when required |
|
||||
| Provider | `data-invalid` | Present when invalid |
|
||||
| Provider | `data-focused` | Present when input is focused |
|
||||
| Provider | `data-scrubbing` | Present during scrub interaction |
|
||||
| Input | `data-number-field-input` | Always present |
|
||||
| Input | `data-disabled` | Present when disabled |
|
||||
| Input | `data-readonly` | Present when readonly |
|
||||
| Input | `data-invalid` | Present when invalid |
|
||||
| IncrementTrigger | `data-number-field-increment-trigger` | Always present |
|
||||
| IncrementTrigger | `data-disabled` | Present when disabled or at max |
|
||||
| DecrementTrigger | `data-number-field-decrement-trigger` | Always present |
|
||||
| DecrementTrigger | `data-disabled` | Present when disabled or at min |
|
||||
| Scrubber | `data-number-field-scrubber` | Always present |
|
||||
| Scrubber | `data-scrubbing` | Present during drag |
|
||||
| Scrubber | `data-disabled` | Present when disabled |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `ArrowUp` | Increment by step |
|
||||
| `ArrowDown` | Decrement by step |
|
||||
| `Shift+ArrowUp` | Increment by largeStep |
|
||||
| `Shift+ArrowDown` | Decrement by largeStep |
|
||||
| `PageUp` | Increment by largeStep |
|
||||
| `PageDown` | Decrement by largeStep |
|
||||
| `Home` | Set to minimum |
|
||||
| `End` | Set to maximum |
|
||||
|
||||
## Behavior
|
||||
|
||||
### Formatting
|
||||
Uses `createNumr` from glob with `NumrFormat` for locale-aware formatting and parsing. Two formatters:
|
||||
- **Display formatter** (`numr`): uses the full consumer format (style, currency, percent, etc.)
|
||||
- **Edit formatter** (`editNumr`): decimal only, no style decoration. For percent, edits 0.75 as 75.
|
||||
|
||||
### Locale resolution
|
||||
Locale follows: prop → `Soma.translator.getLocale()` → `'en'`.
|
||||
|
||||
### Direction resolution
|
||||
Dir follows: prop → `Soma.opts.presentation.dir` → `'ltr'`.
|
||||
|
||||
### Decimal precision
|
||||
Uses `getDecimalPrecision(step)` to avoid floating point drift on increment/decrement (same approach as terra).
|
||||
|
||||
### Hold to repeat
|
||||
Pressing and holding increment/decrement triggers repeats the action every 100ms.
|
||||
|
||||
### Scrubber
|
||||
Pointer drag on the Scrubber area adjusts the value proportionally. `sensitivity` controls pixels per step (default 5). Supports horizontal and vertical directions.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { NumberField } from '$soma/components';
|
||||
let quantity = $state<number | undefined>(5);
|
||||
</script>
|
||||
|
||||
<NumberField.Provider bind:value={quantity} min={0} max={99}>
|
||||
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
|
||||
<NumberField.Input />
|
||||
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
|
||||
</NumberField.Provider>
|
||||
```
|
||||
|
||||
### Currency
|
||||
|
||||
```svelte
|
||||
<NumberField.Provider
|
||||
bind:value={price}
|
||||
min={0}
|
||||
step={0.01}
|
||||
largeStep={1}
|
||||
formatOptions={{ style: 'currency', currency: 'EUR', minDecimals: 2 }}
|
||||
>
|
||||
<NumberField.Input />
|
||||
</NumberField.Provider>
|
||||
```
|
||||
|
||||
### Percentage
|
||||
|
||||
```svelte
|
||||
<NumberField.Provider
|
||||
bind:value={percent}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
formatOptions={{ style: 'percent' }}
|
||||
>
|
||||
<!-- Internal 0.75 edits as 75, displays as 75% -->
|
||||
<NumberField.Input />
|
||||
</NumberField.Provider>
|
||||
```
|
||||
|
||||
### With scrubber
|
||||
|
||||
```svelte
|
||||
<NumberField.Provider bind:value={opacity} min={0} max={100}>
|
||||
<NumberField.Scrubber>Opacity</NumberField.Scrubber>
|
||||
<NumberField.Input />
|
||||
</NumberField.Provider>
|
||||
```
|
||||
|
||||
## Comparison with reference libraries
|
||||
|
||||
| Feature | Ark UI | React Aria | Radix Vue | Base UI | Mantine | Soma |
|
||||
|---------|--------|------------|-----------|---------|---------|------|
|
||||
| Parts | Root, Input, Increment, Decrement, Scrubber, Label, ValueText | Hook-based | Root, Input, Increment, Decrement | Root, Input, Increment, Decrement, ScrubArea | Monolithic | Provider, Input, IncrementTrigger, DecrementTrigger, Scrubber |
|
||||
| `role="spinbutton"` | Yes | Yes | Yes | Yes | No | Yes |
|
||||
| `aria-valuenow/min/max` | Yes | Yes | Yes | Yes | No | Yes |
|
||||
| Format via glob (NumrFormat) | No (Intl direct) | No (Intl direct) | No (Intl direct) | No (Intl direct) | No (custom) | Yes (createNumr) |
|
||||
| Dual formatter (display/edit) | No | No | No | No | No | Yes |
|
||||
| Percent scaling (0.75 → 75) | No | No | No | No | No | Yes |
|
||||
| ArrowUp/Down | Yes | Yes | Yes | Yes | No | Yes |
|
||||
| Shift+Arrow (large step) | No | Yes | No | Yes | No | Yes |
|
||||
| PageUp/PageDown | Yes | Yes | Yes | No | No | Yes |
|
||||
| Home/End | Yes | Yes | Yes | No | No | Yes |
|
||||
| Mouse wheel | Yes | Yes | No | Yes | No | Future |
|
||||
| Scrubber (drag) | Yes | No | No | Yes | No | Yes |
|
||||
| Hold to repeat | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Decimal precision fix | Yes | Yes | No | No | No | Yes |
|
||||
| onValueCommit | Yes | No | No | Yes | No | Yes |
|
||||
| disabled/readonly/required/invalid | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| data-scrubbing | Yes | No | No | Yes | No | Yes |
|
||||
| data-focused | Yes | No | No | Yes | No | Yes |
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { NumberFieldDecrementTriggerProvider } from '../number-field-provider.svelte';
|
||||
import type { NumberFieldDecrementTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'number-field-decrement'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: NumberFieldDecrementTriggerProps = $props();
|
||||
|
||||
const state = NumberFieldDecrementTriggerProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>{@render children?.()}</button>
|
||||
{/if}
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { NumberFieldIncrementTriggerProvider } from '../number-field-provider.svelte';
|
||||
import type { NumberFieldIncrementTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'number-field-increment'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: NumberFieldIncrementTriggerProps = $props();
|
||||
|
||||
const state = NumberFieldIncrementTriggerProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>{@render children?.()}</button>
|
||||
{/if}
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { NumberFieldInputProvider } from '../number-field-provider.svelte';
|
||||
import type { NumberFieldInputProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'number-field-input'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: NumberFieldInputProps = $props();
|
||||
|
||||
const state = NumberFieldInputProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<input {...mergedProps} />
|
||||
{/if}
|
||||
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { NumberFieldScrubberProvider } from '../number-field-provider.svelte';
|
||||
import type { NumberFieldScrubberProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'number-field-scrubber'),
|
||||
direction = 'horizontal',
|
||||
sensitivity = 5,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: NumberFieldScrubberProps = $props();
|
||||
|
||||
const state = NumberFieldScrubberProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
direction: readableActive(() => direction),
|
||||
sensitivity: readableActive(() => sensitivity),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>{@render children?.()}</div>
|
||||
{/if}
|
||||
@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { NumberFieldProvider } from '../number-field-provider.svelte';
|
||||
import type { NumberFieldProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'number-field'),
|
||||
value = $bindable(),
|
||||
formatOptions,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
largeStep,
|
||||
locale,
|
||||
dir,
|
||||
allowMouseWheel = false,
|
||||
clampOnBlur = true,
|
||||
disabled = false,
|
||||
readonly: readonlyProp = false,
|
||||
required = false,
|
||||
invalid = false,
|
||||
name,
|
||||
onValueChange,
|
||||
onValueCommit,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: NumberFieldProps = $props();
|
||||
|
||||
const state = NumberFieldProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange?.(v);
|
||||
},
|
||||
),
|
||||
formatOptions: readableActive(() => formatOptions),
|
||||
min: readableActive(() => min),
|
||||
max: readableActive(() => max),
|
||||
step: readableActive(() => step),
|
||||
largeStep: readableActive(() => largeStep),
|
||||
locale: readableActive(() => locale),
|
||||
dir: readableActive(() => dir),
|
||||
allowMouseWheel: readableActive(() => allowMouseWheel),
|
||||
clampOnBlur: readableActive(() => clampOnBlur),
|
||||
disabled: readableActive(() => disabled),
|
||||
readonly: readableActive(() => readonlyProp),
|
||||
required: readableActive(() => required),
|
||||
invalid: readableActive(() => invalid),
|
||||
name: readableActive(() => name),
|
||||
onValueCommit: readableActive(() => onValueCommit),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>{@render children?.()}</div>
|
||||
{/if}
|
||||
@ -0,0 +1,15 @@
|
||||
export { default as Provider } from './components/number-field.svelte';
|
||||
export { default as Input } from './components/number-field-input.svelte';
|
||||
export { default as IncrementTrigger } from './components/number-field-increment-trigger.svelte';
|
||||
export { default as DecrementTrigger } from './components/number-field-decrement-trigger.svelte';
|
||||
export { default as Scrubber } from './components/number-field-scrubber.svelte';
|
||||
|
||||
export type { NumrFormat } from './number-field-provider.svelte';
|
||||
|
||||
export type {
|
||||
NumberFieldProps as ProviderProps,
|
||||
NumberFieldInputProps as InputProps,
|
||||
NumberFieldIncrementTriggerProps as IncrementTriggerProps,
|
||||
NumberFieldDecrementTriggerProps as DecrementTriggerProps,
|
||||
NumberFieldScrubberProps as ScrubberProps,
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,492 @@
|
||||
import { watch } from 'runed';
|
||||
import { Provider, context, type WithRefOpts } from '$soma/provider';
|
||||
import { createAttrs, boolToEmptyStrOrUndef, boolToStr } from '$soma/attrs';
|
||||
import { state, readableActive, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
|
||||
import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaFocusEvent, SomaInputEvent } from '$soma/types';
|
||||
import { KEYS } from '$soma/keyboard';
|
||||
import { getSoma } from '$soma/core/soma.svelte';
|
||||
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
|
||||
import { createNumr, type NumrFormat } from '@/lib';
|
||||
|
||||
export type { NumrFormat };
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'number-field',
|
||||
parts: ['root', 'input', 'increment-trigger', 'decrement-trigger', 'scrubber'] as const,
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getDecimalPrecision(n: number): number {
|
||||
const str = String(n);
|
||||
const dot = str.indexOf('.');
|
||||
return dot >= 0 ? str.length - dot - 1 : 0;
|
||||
}
|
||||
|
||||
// ── Root ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NumberFieldOpts
|
||||
extends WithRefOpts,
|
||||
StateProps<{ value: number | undefined }>,
|
||||
ActiveProps<{
|
||||
min: number | undefined;
|
||||
max: number | undefined;
|
||||
step: number;
|
||||
largeStep: number | undefined;
|
||||
locale: string | undefined;
|
||||
dir: Direction | undefined;
|
||||
formatOptions: NumrFormat | undefined;
|
||||
clampOnBlur: boolean;
|
||||
allowMouseWheel: boolean;
|
||||
disabled: boolean;
|
||||
readonly: boolean;
|
||||
required: boolean;
|
||||
invalid: boolean;
|
||||
name: string | undefined;
|
||||
onValueCommit: OnChangeFn<number | undefined> | undefined;
|
||||
}> {}
|
||||
|
||||
export class NumberFieldProvider extends Provider<NumberFieldOpts> {
|
||||
static readonly ctx = context<NumberFieldProvider>('NumberField');
|
||||
|
||||
static from(opts: NumberFieldOpts) {
|
||||
return new NumberFieldProvider(opts);
|
||||
}
|
||||
|
||||
readonly config = getSoma();
|
||||
inputRef = state<HTMLInputElement | null>(null);
|
||||
|
||||
// Input display value
|
||||
inputValue = $state('');
|
||||
focused = $state(false);
|
||||
|
||||
// Scrub state
|
||||
scrubbing = $state(false);
|
||||
|
||||
/** Resolved locale: prop → Soma.format.locale → Soma.translator.getLocale() → 'en'. */
|
||||
readonly locale: Active<string>;
|
||||
/** Resolved direction: prop → Soma.dir → 'ltr'. */
|
||||
readonly resolvedDir: Active<Direction>;
|
||||
|
||||
private constructor(opts: NumberFieldOpts) {
|
||||
super(opts, 'NumberField', 'root', attrs.root, NumberFieldProvider.ctx);
|
||||
|
||||
this.locale = readableActive(() =>
|
||||
opts.locale.current
|
||||
?? this.config.translator.current.getLocale()
|
||||
?? 'en',
|
||||
);
|
||||
|
||||
this.resolvedDir = readableActive(() =>
|
||||
opts.dir.current
|
||||
?? this.config.presentation.current?.dir
|
||||
?? 'ltr',
|
||||
);
|
||||
|
||||
// Sync inputValue with external value when not focused.
|
||||
// Must be after locale/resolvedDir assignment since numr depends on locale.
|
||||
watch.pre(
|
||||
() => [opts.value.current, this.locale.current, opts.formatOptions.current] as const,
|
||||
([value]) => {
|
||||
this.inputValue = this.focused
|
||||
? this.formatEditValue(value)
|
||||
: this.formatValue(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────
|
||||
|
||||
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
|
||||
readonly isReadonly = $derived.by(() => this.opts.readonly.current);
|
||||
readonly isRequired = $derived.by(() => this.opts.required.current);
|
||||
readonly isInvalid = $derived.by(() => this.opts.invalid.current);
|
||||
|
||||
readonly step = $derived.by(() => this.opts.step.current);
|
||||
readonly largeStep = $derived.by(() => this.opts.largeStep.current ?? this.step * 10);
|
||||
readonly min = $derived.by(() => this.opts.min.current);
|
||||
readonly max = $derived.by(() => this.opts.max.current);
|
||||
|
||||
readonly dir = $derived.by(() => this.resolvedDir.current);
|
||||
|
||||
/**
|
||||
* Display formatter: uses the full consumer format (style, currency, percent...).
|
||||
* Recreated when formatOptions or locale change.
|
||||
*/
|
||||
readonly numr = $derived.by(() => {
|
||||
const fmt: NumrFormat = { ...(this.opts.formatOptions.current ?? {}) };
|
||||
return createNumr(fmt, this.locale.current);
|
||||
});
|
||||
|
||||
/**
|
||||
* Edit formatter: always decimal, no style decoration.
|
||||
* Allows the user to edit "75" instead of "75%" or "$1,234.56".
|
||||
* For `percent`, edit value is `value * 100`.
|
||||
*/
|
||||
readonly editNumr = $derived.by(() => {
|
||||
const fmt = this.opts.formatOptions.current ?? {};
|
||||
const editFmt: NumrFormat = {
|
||||
minDecimals: fmt.minDecimals,
|
||||
maxDecimals: fmt.maxDecimals,
|
||||
grouping: fmt.grouping,
|
||||
};
|
||||
return createNumr(editFmt, this.locale.current);
|
||||
});
|
||||
|
||||
readonly atMin = $derived.by(
|
||||
() => this.min !== undefined
|
||||
&& this.opts.value.current !== undefined
|
||||
&& this.opts.value.current <= this.min,
|
||||
);
|
||||
readonly atMax = $derived.by(
|
||||
() => this.max !== undefined
|
||||
&& this.opts.value.current !== undefined
|
||||
&& this.opts.value.current >= this.max,
|
||||
);
|
||||
|
||||
// ── Format / Parse ───────────────────────────────────────────────────────
|
||||
|
||||
/** For percent, internal 0.75 is edited as 75. */
|
||||
toEditValue(value: number): number {
|
||||
return this.opts.formatOptions.current?.style === 'percent' ? value * 100 : value;
|
||||
}
|
||||
|
||||
fromEditValue(editValue: number): number {
|
||||
return this.opts.formatOptions.current?.style === 'percent' ? editValue / 100 : editValue;
|
||||
}
|
||||
|
||||
/** Format for display: applies style, currency, %, etc. */
|
||||
formatValue(value: number | undefined): string {
|
||||
if (value === undefined || Number.isNaN(value)) return '';
|
||||
return this.numr.f(value);
|
||||
}
|
||||
|
||||
/** Format for editing: pure decimal, value scaled by style. */
|
||||
formatEditValue(value: number | undefined): string {
|
||||
if (value === undefined || Number.isNaN(value)) return '';
|
||||
return this.editNumr.f(this.toEditValue(value));
|
||||
}
|
||||
|
||||
/** Parse edit string and convert to internal value. */
|
||||
parseValue(raw: string): number | undefined {
|
||||
if (!raw.trim()) return undefined;
|
||||
const n = this.editNumr.parse(raw);
|
||||
if (Number.isNaN(n)) return undefined;
|
||||
return this.fromEditValue(n);
|
||||
}
|
||||
|
||||
clamp(value: number): number {
|
||||
let v = value;
|
||||
if (this.min !== undefined) v = Math.max(this.min, v);
|
||||
if (this.max !== undefined) v = Math.min(this.max, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
round(value: number, step: number): number {
|
||||
const precision = getDecimalPrecision(step);
|
||||
const factor = Math.pow(10, precision);
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
commit(value: number | undefined): void {
|
||||
if (value === undefined) {
|
||||
this.opts.value.current = undefined;
|
||||
this.inputValue = '';
|
||||
return;
|
||||
}
|
||||
const clamped = this.clamp(this.round(value, this.step));
|
||||
this.opts.value.current = clamped;
|
||||
this.opts.onValueCommit.current?.(clamped);
|
||||
this.inputValue = this.focused ? this.formatEditValue(clamped) : this.formatValue(clamped);
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
increment(by: number = this.step): void {
|
||||
if (this.isDisabled || this.isReadonly) return;
|
||||
const current = this.opts.value.current ?? 0;
|
||||
this.commit(current + by);
|
||||
}
|
||||
|
||||
decrement(by: number = this.step): void {
|
||||
if (this.isDisabled || this.isReadonly) return;
|
||||
const current = this.opts.value.current ?? 0;
|
||||
this.commit(current - by);
|
||||
}
|
||||
|
||||
// ── Event handlers ───────────────────────────────────────────────────────
|
||||
|
||||
readonly handleFocus = (_e: SomaFocusEvent<HTMLInputElement>): void => {
|
||||
this.focused = true;
|
||||
const raw = this.opts.value.current;
|
||||
this.inputValue = raw !== undefined ? this.formatEditValue(raw) : '';
|
||||
};
|
||||
|
||||
readonly handleBlur = (_e: SomaFocusEvent<HTMLInputElement>): void => {
|
||||
this.focused = false;
|
||||
const parsed = this.parseValue(this.inputValue);
|
||||
this.commit(parsed);
|
||||
};
|
||||
|
||||
readonly handleInput = (e: SomaInputEvent<HTMLInputElement>): void => {
|
||||
this.inputValue = e.currentTarget.value;
|
||||
};
|
||||
|
||||
readonly handleKeydown = (e: SomaKeyboardEvent<HTMLInputElement>): void => {
|
||||
if (this.isDisabled || this.isReadonly) return;
|
||||
|
||||
switch (e.key) {
|
||||
case KEYS.ARROW_UP:
|
||||
e.preventDefault();
|
||||
this.increment(e.shiftKey ? this.largeStep : this.step);
|
||||
break;
|
||||
case KEYS.ARROW_DOWN:
|
||||
e.preventDefault();
|
||||
this.decrement(e.shiftKey ? this.largeStep : this.step);
|
||||
break;
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
this.increment(this.largeStep);
|
||||
break;
|
||||
case 'PageDown':
|
||||
e.preventDefault();
|
||||
this.decrement(this.largeStep);
|
||||
break;
|
||||
case KEYS.HOME:
|
||||
if (this.min !== undefined) {
|
||||
e.preventDefault();
|
||||
this.commit(this.min);
|
||||
}
|
||||
break;
|
||||
case KEYS.END:
|
||||
if (this.max !== undefined) {
|
||||
e.preventDefault();
|
||||
this.commit(this.max);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
||||
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
|
||||
'data-required': boolToEmptyStrOrUndef(this.isRequired),
|
||||
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
|
||||
'data-focused': boolToEmptyStrOrUndef(this.focused),
|
||||
'data-scrubbing': boolToEmptyStrOrUndef(this.scrubbing),
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NumberFieldInputOpts extends WithRefOpts {}
|
||||
|
||||
export class NumberFieldInputProvider extends Provider<NumberFieldInputOpts> {
|
||||
static from(opts: NumberFieldInputOpts) {
|
||||
return new NumberFieldInputProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: NumberFieldProvider;
|
||||
|
||||
private constructor(opts: NumberFieldInputOpts) {
|
||||
super(opts, 'NumberField', 'input', attrs.input);
|
||||
this.provider = NumberFieldProvider.ctx.get();
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'spinbutton' as const,
|
||||
inputmode: 'decimal' as const,
|
||||
autocomplete: 'off' as const,
|
||||
autocorrect: 'off' as const,
|
||||
spellcheck: false,
|
||||
dir: this.provider.dir,
|
||||
value: this.provider.inputValue,
|
||||
'aria-valuenow': this.provider.opts.value.current,
|
||||
'aria-valuemin': this.provider.opts.min.current,
|
||||
'aria-valuemax': this.provider.opts.max.current,
|
||||
'aria-valuetext': this.provider.opts.value.current !== undefined
|
||||
? String(this.provider.opts.value.current)
|
||||
: undefined,
|
||||
'aria-required': boolToStr(this.provider.isRequired),
|
||||
'aria-disabled': boolToStr(this.provider.isDisabled),
|
||||
'aria-readonly': boolToStr(this.provider.isReadonly),
|
||||
'aria-invalid': boolToStr(this.provider.isInvalid),
|
||||
disabled: this.provider.isDisabled || undefined,
|
||||
readonly: this.provider.isReadonly || undefined,
|
||||
required: this.provider.isRequired || undefined,
|
||||
name: this.provider.opts.name.current || undefined,
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
|
||||
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
|
||||
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
|
||||
onfocus: this.provider.handleFocus,
|
||||
onblur: this.provider.handleBlur,
|
||||
oninput: this.provider.handleInput,
|
||||
onkeydown: this.provider.handleKeydown,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── IncrementTrigger ─────────────────────────────────────────────────────────
|
||||
|
||||
interface NumberFieldIncrementTriggerOpts extends WithRefOpts {}
|
||||
|
||||
export class NumberFieldIncrementTriggerProvider extends Provider<NumberFieldIncrementTriggerOpts> {
|
||||
static from(opts: NumberFieldIncrementTriggerOpts) {
|
||||
return new NumberFieldIncrementTriggerProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: NumberFieldProvider;
|
||||
|
||||
private constructor(opts: NumberFieldIncrementTriggerOpts) {
|
||||
super(opts, 'NumberField', 'increment-trigger', attrs['increment-trigger']);
|
||||
this.provider = NumberFieldProvider.ctx.get();
|
||||
}
|
||||
|
||||
readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMax);
|
||||
|
||||
readonly handleClick = () => { this.provider.increment(); };
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
type: 'button' as const,
|
||||
tabindex: -1,
|
||||
disabled: this.isDisabled || undefined,
|
||||
'aria-label':
|
||||
resolveSomaTranslationPath('soma.number-field.increment', this.provider.config.translator.current, 'Increment'),
|
||||
'aria-disabled': boolToStr(this.isDisabled),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
||||
onclick: this.handleClick,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── DecrementTrigger ─────────────────────────────────────────────────────────
|
||||
|
||||
interface NumberFieldDecrementTriggerOpts extends WithRefOpts {}
|
||||
|
||||
export class NumberFieldDecrementTriggerProvider extends Provider<NumberFieldDecrementTriggerOpts> {
|
||||
static from(opts: NumberFieldDecrementTriggerOpts) {
|
||||
return new NumberFieldDecrementTriggerProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: NumberFieldProvider;
|
||||
|
||||
private constructor(opts: NumberFieldDecrementTriggerOpts) {
|
||||
super(opts, 'NumberField', 'decrement-trigger', attrs['decrement-trigger']);
|
||||
this.provider = NumberFieldProvider.ctx.get();
|
||||
}
|
||||
|
||||
readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMin);
|
||||
|
||||
readonly handleClick = () => { this.provider.decrement(); };
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
type: 'button' as const,
|
||||
tabindex: -1,
|
||||
disabled: this.isDisabled || undefined,
|
||||
'aria-label':
|
||||
resolveSomaTranslationPath('soma.number-field.decrement', this.provider.config.translator.current, 'Decrement'),
|
||||
'aria-disabled': boolToStr(this.isDisabled),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
||||
onclick: this.handleClick,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scrubber ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NumberFieldScrubberOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
direction: 'horizontal' | 'vertical';
|
||||
sensitivity: number;
|
||||
}> {}
|
||||
|
||||
export class NumberFieldScrubberProvider extends Provider<NumberFieldScrubberOpts> {
|
||||
static from(opts: NumberFieldScrubberOpts) {
|
||||
return new NumberFieldScrubberProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: NumberFieldProvider;
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private startValue = 0;
|
||||
|
||||
private constructor(opts: NumberFieldScrubberOpts) {
|
||||
super(opts, 'NumberField', 'scrubber', attrs.scrubber);
|
||||
this.provider = NumberFieldProvider.ctx.get();
|
||||
}
|
||||
|
||||
private get isDisabled(): boolean {
|
||||
return this.provider.isDisabled || this.provider.isReadonly;
|
||||
}
|
||||
|
||||
readonly onpointerdown = (e: PointerEvent) => {
|
||||
if (this.isDisabled || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
this.provider.scrubbing = true;
|
||||
this.startX = e.clientX;
|
||||
this.startY = e.clientY;
|
||||
this.startValue = this.provider.opts.value.current ?? 0;
|
||||
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
readonly onpointermove = (e: PointerEvent) => {
|
||||
if (!this.provider.scrubbing) return;
|
||||
e.preventDefault();
|
||||
|
||||
const sensitivity = this.opts.sensitivity.current;
|
||||
const step = this.provider.step;
|
||||
const isHorizontal = this.opts.direction.current === 'horizontal';
|
||||
|
||||
const delta = isHorizontal
|
||||
? e.clientX - this.startX
|
||||
: -(e.clientY - this.startY);
|
||||
|
||||
const steps = Math.round(delta / sensitivity);
|
||||
const newValue = this.startValue + steps * step;
|
||||
this.provider.commit(newValue);
|
||||
};
|
||||
|
||||
readonly onpointerup = () => {
|
||||
if (!this.provider.scrubbing) return;
|
||||
this.provider.scrubbing = false;
|
||||
};
|
||||
|
||||
readonly onlostpointercapture = () => {
|
||||
if (this.provider.scrubbing) {
|
||||
this.provider.scrubbing = false;
|
||||
}
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
||||
style: {
|
||||
cursor: this.isDisabled
|
||||
? 'default'
|
||||
: this.opts.direction.current === 'horizontal'
|
||||
? 'ew-resize'
|
||||
: 'ns-resize',
|
||||
'user-select': this.provider.scrubbing ? 'none' : undefined,
|
||||
'touch-action': 'none',
|
||||
},
|
||||
onpointerdown: this.onpointerdown,
|
||||
onpointermove: this.onpointermove,
|
||||
onpointerup: this.onpointerup,
|
||||
onlostpointercapture: this.onlostpointercapture,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import type { WithChild, Without, OnChangeFn, Direction } from '$soma/types';
|
||||
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes, PrimitiveInputAttributes } from '$soma/types';
|
||||
import type { NumrFormat } from './number-field-provider.svelte';
|
||||
|
||||
export type NumberFieldProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Current numeric value. Bindable. @default undefined */
|
||||
value?: number;
|
||||
/**
|
||||
* Number format definition (style, currency, unit, decimals, grouping, etc.).
|
||||
* Uses NumrFormat from glob. Determines display formatting and edit parsing.
|
||||
* @see NumrFormat
|
||||
*/
|
||||
formatOptions?: NumrFormat;
|
||||
/** Minimum allowed value. */
|
||||
min?: number;
|
||||
/** Maximum allowed value. */
|
||||
max?: number;
|
||||
/** Increment/decrement amount. @default 1 */
|
||||
step?: number;
|
||||
/** Large step for Shift+Arrow or PageUp/PageDown. @default step * 10 */
|
||||
largeStep?: number;
|
||||
/** BCP 47 locale. Resolved via: prop → Soma.format.locale → 'en'. */
|
||||
locale?: string;
|
||||
/** Text direction. Resolved via: prop → Soma.dir → 'ltr'. */
|
||||
dir?: Direction;
|
||||
/** Allow mouse wheel to increment/decrement when focused. @default false */
|
||||
allowMouseWheel?: boolean;
|
||||
/** Clamp value to min/max on blur. @default true */
|
||||
clampOnBlur?: boolean;
|
||||
/** Disables all interaction. @default false */
|
||||
disabled?: boolean;
|
||||
/** Prevents value changes but keeps focusable. @default false */
|
||||
readonly?: boolean;
|
||||
/** Marks as required for form validation. @default false */
|
||||
required?: boolean;
|
||||
/** Marks as invalid for form validation. @default false */
|
||||
invalid?: boolean;
|
||||
/** Form submission field name. */
|
||||
name?: string;
|
||||
/** Callback fired when value changes. */
|
||||
onValueChange?: OnChangeFn<number | undefined>;
|
||||
/** Callback fired when value is committed (on blur or Enter). */
|
||||
onValueCommit?: OnChangeFn<number | undefined>;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type NumberFieldInputProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveInputAttributes, {}>;
|
||||
|
||||
export type NumberFieldIncrementTriggerProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type NumberFieldDecrementTriggerProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type NumberFieldScrubberProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Drag direction. @default 'horizontal' */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
/** Pixels per step during scrub. @default 5 */
|
||||
sensitivity?: number;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
@ -0,0 +1,192 @@
|
||||
# Table
|
||||
|
||||
Headless table with sorting, row selection, and pagination. Provides a reactive state manager (`createTable`) and Svelte components with ARIA grid pattern, keyboard support, and data-* attributes for styling.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Table, createTable, type ColumnDef } from '$soma/components/table';
|
||||
|
||||
const columns: ColumnDef<Person>[] = [
|
||||
{ accessorKey: 'name', header: 'Name', enableSorting: true },
|
||||
{ accessorKey: 'age', header: 'Age', enableSorting: true },
|
||||
];
|
||||
|
||||
const table = createTable({
|
||||
data: () => people,
|
||||
columns,
|
||||
sorting: { enabled: true },
|
||||
selection: { mode: 'multiple' },
|
||||
pagination: { enabled: true, pageSize: 10 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<Table.Provider {table}>
|
||||
<table role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each table.headers as header}
|
||||
<Table.ColumnHeader {header}>{header.label}</Table.ColumnHeader>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each table.rows as row (row.id)}
|
||||
<Table.Row {row}>
|
||||
{#each row.cells as cell, i}
|
||||
<Table.Cell {cell} colIndex={i}>{cell.value}</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Table.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
|------|---------|-------------|
|
||||
| `Provider` | none | Root context. Receives the table instance. No DOM. |
|
||||
| `ColumnHeader` | `<th>` | Column header. Click to sort. ARIA sort indicators. |
|
||||
| `Row` | `<tr>` | Table row. Click to select. ARIA selected/rowindex. |
|
||||
| `Cell` | `<td>` | Table cell. ARIA colindex. |
|
||||
|
||||
## State Manager (`createTable`)
|
||||
|
||||
```ts
|
||||
const table = createTable({
|
||||
data: () => dataArray, // reactive data source
|
||||
columns: columnDefs, // column definitions
|
||||
getRowId?: (row, idx) => id, // custom row ID (default: index)
|
||||
|
||||
sorting?: {
|
||||
enabled: true,
|
||||
multi: false, // allow multi-column sort (Shift+click)
|
||||
manual: false, // server-side sort (skip client sort)
|
||||
},
|
||||
selection?: {
|
||||
mode: 'none' | 'single' | 'multiple',
|
||||
},
|
||||
pagination?: {
|
||||
enabled: true,
|
||||
manual: false, // server-side pagination
|
||||
pageSize: 10,
|
||||
},
|
||||
rowCount?: number, // total rows for server-side pagination
|
||||
|
||||
// Callbacks
|
||||
onSortingChange?: (sorting) => void,
|
||||
onSelectionChange?: (selection) => void,
|
||||
onPaginationChange?: (pagination) => void,
|
||||
});
|
||||
```
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
|------|-----------|-------|
|
||||
| Root (`<table>`) | `role` | `grid` (consumer adds) |
|
||||
| Root | `aria-label` | Consumer provides |
|
||||
| Root | `aria-rowcount` | Consumer provides if server-side |
|
||||
| ColumnHeader | `role` | `columnheader` |
|
||||
| ColumnHeader | `aria-sort` | `ascending` \| `descending` \| `none` |
|
||||
| ColumnHeader | `aria-label` | Translated sort state |
|
||||
| Row | `role` | `row` |
|
||||
| Row | `aria-rowindex` | Row index (1-based) |
|
||||
| Row | `aria-selected` | `true` \| `false` (when selection enabled) |
|
||||
| Cell | `role` | `gridcell` |
|
||||
| Cell | `aria-colindex` | Column index (1-based) |
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
|------|-----------|--------|
|
||||
| ColumnHeader | `data-table-column-header` | Always present |
|
||||
| ColumnHeader | `data-sortable` | Present when sortable |
|
||||
| ColumnHeader | `data-sorted` | `asc` \| `desc` (when sorted) |
|
||||
| Row | `data-table-row` | Always present |
|
||||
| Row | `data-selected` | Present when selected |
|
||||
| Cell | `data-table-cell` | Always present |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` / `Space` on ColumnHeader | Toggle sort (Shift for multi-sort) |
|
||||
| `Enter` / `Space` on Row | Toggle row selection |
|
||||
|
||||
## Sorting
|
||||
|
||||
- **Click** a header: replaces current sort with that column (asc → desc → none)
|
||||
- **Shift+click** a header: adds column to multi-sort (when `multi: true`)
|
||||
- **Manual mode**: set `manual: true`, handle `onSortingChange`, sort data externally
|
||||
|
||||
## Selection
|
||||
|
||||
- **None**: no row selection
|
||||
- **Single**: one row at a time, click to select/deselect
|
||||
- **Multiple**: any number, click to toggle, checkbox integration via `getIsRowSelected` / `toggleAllSelection`
|
||||
|
||||
## Pagination
|
||||
|
||||
- **Client-side**: `createTable` slices data internally
|
||||
- **Server-side**: set `manual: true` + `rowCount`, load data per page externally
|
||||
|
||||
## Usage
|
||||
|
||||
### Sortable + Selectable + Paginated
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
const table = createTable({
|
||||
data: () => people,
|
||||
columns,
|
||||
sorting: { enabled: true, multi: true },
|
||||
selection: { mode: 'multiple' },
|
||||
pagination: { enabled: true, pageSize: 10 },
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Server-side pagination
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
const table = createTable({
|
||||
data: () => currentPageData,
|
||||
columns,
|
||||
pagination: { enabled: true, manual: true, pageSize: 20 },
|
||||
rowCount: totalRowsFromServer,
|
||||
onPaginationChange: (p) => fetchPage(p.pageIndex),
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Comparison with reference libraries
|
||||
|
||||
| Feature | TanStack Table | React Aria | Soma Table |
|
||||
|---------|---------------|------------|------------|
|
||||
| Framework | Agnostic | React only | Svelte (soma) |
|
||||
| Sorting (single) | Yes | Yes | Yes |
|
||||
| Sorting (multi) | Yes | No | Yes (Shift+click) |
|
||||
| Custom sort fn | Yes | No | Yes |
|
||||
| Server-side sort | Yes | Manual | Yes (manual mode) |
|
||||
| Selection none/single/multi | Yes | Yes | Yes |
|
||||
| Select all / checkbox | Yes | Yes | Yes |
|
||||
| Pagination (client) | Yes | No | Yes |
|
||||
| Pagination (server) | Yes | No | Yes (manual mode) |
|
||||
| ARIA grid pattern | No (manual) | Yes (auto) | Yes (auto) |
|
||||
| Keyboard sort | No (manual) | Yes | Yes (Enter/Space) |
|
||||
| Keyboard select | No (manual) | Yes | Yes (Enter/Space) |
|
||||
| `aria-sort` on headers | No (manual) | Yes | Yes |
|
||||
| `data-*` attributes | No | No | Yes |
|
||||
| Svelte components | Via adapter | No | Native |
|
||||
| Column filtering | Yes | No | Planned |
|
||||
| Column visibility | Yes | No | Planned |
|
||||
| Column resizing | Yes | Partial | Planned |
|
||||
| Column pinning | Yes | No | Planned |
|
||||
| Row expansion | Yes | No | Planned |
|
||||
| Grouping / aggregation | Yes | No | Planned |
|
||||
| Virtual scrolling | Via TanStack Virtual | No | Planned |
|
||||
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { TableCellProvider } from '../table-provider.svelte';
|
||||
import type { TableCellProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'table-cell'),
|
||||
cell,
|
||||
colIndex,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: TableCellProps = $props();
|
||||
|
||||
const state = TableCellProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
cell: readableActive(() => cell),
|
||||
colIndex: readableActive(() => colIndex),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<td {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
{/if}
|
||||
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { TableColumnHeaderProvider } from '../table-provider.svelte';
|
||||
import type { TableColumnHeaderProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'table-column-header'),
|
||||
header,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: TableColumnHeaderProps = $props();
|
||||
|
||||
const state = TableColumnHeaderProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
header: readableActive(() => header),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<th {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
{/if}
|
||||
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { TableRowProvider } from '../table-provider.svelte';
|
||||
import type { TableRowProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'table-row'),
|
||||
row,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: TableRowProps = $props();
|
||||
|
||||
const state = TableRowProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(() => ref, (v) => (ref = v)),
|
||||
row: readableActive(() => row),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<tr {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
{/if}
|
||||
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { readableActive } from '$soma/reactive';
|
||||
import { createId } from '$soma/id';
|
||||
import { TableProvider } from '../table-provider.svelte';
|
||||
import type { TableProviderProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
table,
|
||||
label = 'Data table',
|
||||
children,
|
||||
}: TableProviderProps = $props();
|
||||
|
||||
TableProvider.from({
|
||||
id: readableActive(() => createId(uid, 'table')),
|
||||
table: readableActive(() => table),
|
||||
label: readableActive(() => label),
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,34 @@
|
||||
export { default as Provider } from './components/table.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 { createTable, filterFns } from './table-core.svelte';
|
||||
export type { BuiltInFilterFn } from './table-core.svelte';
|
||||
|
||||
export type {
|
||||
ColumnDef,
|
||||
SortingEntry,
|
||||
SortingState,
|
||||
PaginationState,
|
||||
SelectionMode,
|
||||
RowSelectionState,
|
||||
ColumnVisibilityState,
|
||||
ColumnPinningState,
|
||||
ColumnSizingState,
|
||||
ExpandedState,
|
||||
ColumnFilterEntry,
|
||||
ColumnFiltersState,
|
||||
TableConfig,
|
||||
TableInstance,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from './table-core.svelte';
|
||||
|
||||
export type {
|
||||
TableProviderProps as ProviderProps,
|
||||
TableColumnHeaderProps as ColumnHeaderProps,
|
||||
TableRowProps as RowProps,
|
||||
TableCellProps as CellProps,
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,699 @@
|
||||
|
||||
import { Provider, context, type WithRefOpts, type ProviderOpts } from '$soma/provider';
|
||||
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
|
||||
import { state, readableActive, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
|
||||
import type { OnChangeFn, SomaKeyboardEvent } from '$soma/types';
|
||||
import { KEYS } from '$soma/keyboard';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'table',
|
||||
parts: ['root', 'header', 'body', 'footer', 'column-header', 'row', 'cell'] as const,
|
||||
});
|
||||
|
||||
// ── Column definition ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ColumnDef<TData = any> {
|
||||
/** Unique column identifier. Derived from accessorKey if omitted. */
|
||||
id?: string;
|
||||
/** Key in data object to access cell value. */
|
||||
accessorKey?: string;
|
||||
/** Function to access cell value from row data. */
|
||||
accessorFn?: (row: TData, index: number) => any;
|
||||
|
||||
/** Header label or render function. */
|
||||
header?: string;
|
||||
/** Footer label. */
|
||||
footer?: string;
|
||||
|
||||
/** Enable sorting for this column. @default false */
|
||||
enableSorting?: boolean;
|
||||
/** Custom sort comparator. Receives two cell values. */
|
||||
sortingFn?: (a: any, b: any) => number;
|
||||
/** Sort descending first when toggling. @default false */
|
||||
sortDescFirst?: boolean;
|
||||
|
||||
/** Enable filtering for this column. @default false */
|
||||
enableFiltering?: boolean;
|
||||
/** Custom filter function. */
|
||||
filterFn?: (row: TData, columnId: string, filterValue: any) => boolean;
|
||||
|
||||
/** Enable hiding for this column. @default true */
|
||||
enableHiding?: boolean;
|
||||
/** Enable resizing for this column. @default false */
|
||||
enableResizing?: boolean;
|
||||
/** Enable pinning for this column. @default false */
|
||||
enablePinning?: boolean;
|
||||
|
||||
/** Initial column width in px. */
|
||||
size?: number;
|
||||
/** Minimum column width. @default 40 */
|
||||
minSize?: number;
|
||||
/** Maximum column width. */
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
// ── Sort state ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SortingEntry {
|
||||
/** Column ID. */
|
||||
id: string;
|
||||
/** Sort descending. */
|
||||
desc: boolean;
|
||||
}
|
||||
|
||||
export type SortingState = SortingEntry[];
|
||||
|
||||
// ── Pagination state ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaginationState {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// ── Selection state ──────────────────────────────────────────────────────────
|
||||
|
||||
export type SelectionMode = 'none' | 'single' | 'multiple';
|
||||
export type RowSelectionState = Record<string, boolean>;
|
||||
|
||||
// ── Column visibility ────────────────────────────────────────────────────────
|
||||
|
||||
export type ColumnVisibilityState = Record<string, boolean>;
|
||||
|
||||
// ── Column pinning ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ColumnPinningState {
|
||||
left?: string[];
|
||||
right?: string[];
|
||||
}
|
||||
|
||||
// ── Column sizing ────────────────────────────────────────────────────────────
|
||||
|
||||
export type ColumnSizingState = Record<string, number>;
|
||||
|
||||
// ── Expansion ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ExpandedState = Record<string, boolean>;
|
||||
|
||||
// ── Filter state ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ColumnFilterEntry {
|
||||
id: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export type ColumnFiltersState = ColumnFilterEntry[];
|
||||
|
||||
// ── Resolved column (internal) ───────────────────────────────────────────────
|
||||
|
||||
export interface ResolvedColumn<TData = any> {
|
||||
id: string;
|
||||
def: ColumnDef<TData>;
|
||||
getvalue(row: TData): any;
|
||||
}
|
||||
|
||||
function resolveColumns<TData>(defs: ColumnDef<TData>[]): ResolvedColumn<TData>[] {
|
||||
return defs.map((def, idx) => {
|
||||
const id = def.id ?? def.accessorKey ?? `col_${idx}`;
|
||||
const getvalue = def.accessorFn
|
||||
? (row: TData) => def.accessorFn!(row, 0)
|
||||
: def.accessorKey
|
||||
? (row: TData) => (row as any)[def.accessorKey!]
|
||||
: () => undefined;
|
||||
return { id, def, getvalue };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
function defaultSortFn(a: any, b: any): number {
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return -1;
|
||||
if (b == null) return 1;
|
||||
if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b);
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
return String(a).localeCompare(String(b));
|
||||
}
|
||||
|
||||
function applySorting<TData>(
|
||||
rows: TData[],
|
||||
sorting: SortingState,
|
||||
columns: ResolvedColumn<TData>[],
|
||||
): TData[] {
|
||||
if (sorting.length === 0) return rows;
|
||||
|
||||
const sorted = [...rows];
|
||||
sorted.sort((a, b) => {
|
||||
for (const entry of sorting) {
|
||||
const col = columns.find((c) => c.id === entry.id);
|
||||
if (!col) continue;
|
||||
const sortFn = col.def.sortingFn ?? defaultSortFn;
|
||||
const result = sortFn(col.getvalue(a), col.getvalue(b));
|
||||
if (result !== 0) return entry.desc ? -result : result;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
// ── Built-in filter functions ─────────────────────────────────────────────
|
||||
|
||||
export const filterFns = {
|
||||
includesString: (cellValue: any, filterValue: string) =>
|
||||
String(cellValue).toLowerCase().includes(String(filterValue).toLowerCase()),
|
||||
equals: (cellValue: any, filterValue: any) => cellValue === filterValue,
|
||||
weakEquals: (cellValue: any, filterValue: any) => cellValue == filterValue,
|
||||
inNumberRange: (cellValue: any, filterValue: [number, number]) => {
|
||||
const num = Number(cellValue);
|
||||
return num >= filterValue[0] && num <= filterValue[1];
|
||||
},
|
||||
arrIncludes: (cellValue: any, filterValue: any) =>
|
||||
Array.isArray(cellValue) && cellValue.includes(filterValue),
|
||||
} as const;
|
||||
|
||||
export type BuiltInFilterFn = keyof typeof filterFns;
|
||||
|
||||
// ── Filter logic ─────────────────────────────────────────────────────────
|
||||
|
||||
function applyColumnFilters<TData>(
|
||||
rows: TData[],
|
||||
filters: ColumnFiltersState,
|
||||
columns: ResolvedColumn<TData>[],
|
||||
): TData[] {
|
||||
if (filters.length === 0) return rows;
|
||||
|
||||
return rows.filter((row) =>
|
||||
filters.every((filter) => {
|
||||
const col = columns.find((c) => c.id === filter.id);
|
||||
if (!col) return true;
|
||||
const cellValue = col.getvalue(row);
|
||||
if (col.def.filterFn) return col.def.filterFn(row, col.id, filter.value);
|
||||
return filterFns.includesString(cellValue, filter.value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function applyGlobalFilter<TData>(
|
||||
rows: TData[],
|
||||
globalFilter: string,
|
||||
columns: ResolvedColumn<TData>[],
|
||||
globalFilterFn?: (row: TData, columnId: string, filterValue: any) => boolean,
|
||||
): TData[] {
|
||||
if (!globalFilter) return rows;
|
||||
|
||||
return rows.filter((row) =>
|
||||
columns.some((col) => {
|
||||
if (col.def.enableFiltering === false) return false;
|
||||
const cellValue = col.getvalue(row);
|
||||
if (globalFilterFn) return globalFilterFn(row, col.id, globalFilter);
|
||||
return filterFns.includesString(cellValue, globalFilter);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pagination logic ─────────────────────────────────────────────────────────
|
||||
|
||||
function applyPagination<TData>(
|
||||
rows: TData[],
|
||||
pagination: PaginationState | undefined,
|
||||
): TData[] {
|
||||
if (!pagination) return rows;
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return rows.slice(start, start + pagination.pageSize);
|
||||
}
|
||||
|
||||
// ── Table row/cell models ────────────────────────────────────────────────────
|
||||
|
||||
export interface TableHeader<TData = any> {
|
||||
id: string;
|
||||
column: ResolvedColumn<TData>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TableCell<TData = any> {
|
||||
id: string;
|
||||
column: ResolvedColumn<TData>;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface TableRow<TData = any> {
|
||||
id: string;
|
||||
index: number;
|
||||
original: TData;
|
||||
cells: TableCell<TData>[];
|
||||
}
|
||||
|
||||
// ── Table instance ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface TableConfig<TData> {
|
||||
/** Row data. Function for reactivity. */
|
||||
data: () => TData[];
|
||||
/** Column definitions. */
|
||||
columns: ColumnDef<TData>[];
|
||||
/** Get unique row ID. Default: uses index. */
|
||||
getRowId?: (row: TData, index: number) => string;
|
||||
|
||||
// ── Sorting ──────────────────────────────────────────────────────────
|
||||
sorting?: {
|
||||
enabled?: boolean;
|
||||
/** Allow multi-column sort. @default false */
|
||||
multi?: boolean;
|
||||
/** Server-side sort (skip client sort). @default false */
|
||||
manual?: boolean;
|
||||
};
|
||||
/** Initial or controlled sorting state. */
|
||||
initialSorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
|
||||
// ── Filtering ────────────────────────────────────────────────────────
|
||||
filtering?: {
|
||||
enabled?: boolean;
|
||||
/** Server-side filtering (skip client filter). @default false */
|
||||
manual?: boolean;
|
||||
/** Custom global filter function. Default: includesString across all columns. */
|
||||
globalFilterFn?: (row: TData, columnId: string, filterValue: any) => boolean;
|
||||
};
|
||||
initialColumnFilters?: ColumnFiltersState;
|
||||
initialGlobalFilter?: string;
|
||||
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
|
||||
onGlobalFilterChange?: OnChangeFn<string>;
|
||||
|
||||
// ── Selection ────────────────────────────────────────────────────────
|
||||
selection?: {
|
||||
mode?: SelectionMode;
|
||||
};
|
||||
initialSelection?: RowSelectionState;
|
||||
onSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
|
||||
// ── Pagination ───────────────────────────────────────────────────────
|
||||
pagination?: {
|
||||
enabled?: boolean;
|
||||
/** Server-side pagination (skip client slice). @default false */
|
||||
manual?: boolean;
|
||||
pageSize?: number;
|
||||
};
|
||||
initialPagination?: PaginationState;
|
||||
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||
|
||||
// ── Column visibility ────────────────────────────────────────────────
|
||||
initialColumnVisibility?: ColumnVisibilityState;
|
||||
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>;
|
||||
|
||||
/** Total row count (for server-side pagination). */
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
export interface TableInstance<TData = any> {
|
||||
// ── Computed ──────────────────────────────────────────────────────────
|
||||
readonly columns: ResolvedColumn<TData>[];
|
||||
readonly headers: TableHeader<TData>[];
|
||||
readonly rows: TableRow<TData>[];
|
||||
readonly allRows: TData[];
|
||||
|
||||
// ── Sorting ──────────────────────────────────────────────────────────
|
||||
readonly sorting: SortingState;
|
||||
toggleSort(columnId: string, multi?: boolean): void;
|
||||
setSorting(sorting: SortingState): void;
|
||||
clearSorting(): void;
|
||||
getIsSorted(columnId: string): false | 'asc' | 'desc';
|
||||
getCanSort(columnId: string): boolean;
|
||||
|
||||
// ── Filtering ────────────────────────────────────────────────────────
|
||||
readonly columnFilters: ColumnFiltersState;
|
||||
readonly globalFilter: string;
|
||||
setColumnFilter(columnId: string, value: any): void;
|
||||
removeColumnFilter(columnId: string): void;
|
||||
setGlobalFilter(value: string): void;
|
||||
clearFilters(): void;
|
||||
getColumnFilterValue(columnId: string): any;
|
||||
getCanFilter(columnId: string): boolean;
|
||||
|
||||
// ── Selection ────────────────────────────────────────────────────────
|
||||
readonly selection: RowSelectionState;
|
||||
readonly selectionMode: SelectionMode;
|
||||
toggleRowSelection(rowId: string): void;
|
||||
setRowSelection(selection: RowSelectionState): void;
|
||||
toggleAllSelection(): void;
|
||||
getIsRowSelected(rowId: string): boolean;
|
||||
getIsAllSelected(): boolean;
|
||||
getIsSomeSelected(): boolean;
|
||||
|
||||
// ── Column visibility ────────────────────────────────────────────────
|
||||
readonly columnVisibility: ColumnVisibilityState;
|
||||
toggleColumnVisibility(columnId: string): void;
|
||||
setColumnVisibility(visibility: ColumnVisibilityState): void;
|
||||
getIsColumnVisible(columnId: string): boolean;
|
||||
getVisibleColumns(): ResolvedColumn<TData>[];
|
||||
|
||||
// ── Pagination ───────────────────────────────────────────────────────
|
||||
readonly pagination: PaginationState;
|
||||
readonly pageCount: number;
|
||||
readonly canPreviousPage: boolean;
|
||||
readonly canNextPage: boolean;
|
||||
setPageIndex(index: number): void;
|
||||
setPageSize(size: number): void;
|
||||
previousPage(): void;
|
||||
nextPage(): void;
|
||||
firstPage(): void;
|
||||
lastPage(): void;
|
||||
}
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createTable<TData>(config: TableConfig<TData>): TableInstance<TData> {
|
||||
const resolvedColumns = resolveColumns(config.columns);
|
||||
const getRowId = config.getRowId ?? ((_row: TData, index: number) => String(index));
|
||||
|
||||
// ── Mutable state ────────────────────────────────────────────────────
|
||||
|
||||
let sorting = $state<SortingState>(config.initialSorting ?? []);
|
||||
let columnFilters = $state<ColumnFiltersState>(config.initialColumnFilters ?? []);
|
||||
let globalFilter = $state<string>(config.initialGlobalFilter ?? '');
|
||||
let columnVisibility = $state<ColumnVisibilityState>(config.initialColumnVisibility ?? {});
|
||||
let selection = $state<RowSelectionState>(config.initialSelection ?? {});
|
||||
let pagination = $state<PaginationState>(
|
||||
config.initialPagination ?? {
|
||||
pageIndex: 0,
|
||||
pageSize: config.pagination?.pageSize ?? 10,
|
||||
},
|
||||
);
|
||||
|
||||
// ── Derived: sorted + paginated rows ─────────────────────────────────
|
||||
|
||||
const allRows = $derived.by(() => config.data());
|
||||
|
||||
const filteredRows = $derived.by(() => {
|
||||
if (!config.filtering?.enabled || config.filtering?.manual) return allRows;
|
||||
let rows = applyColumnFilters(allRows, columnFilters, resolvedColumns);
|
||||
rows = applyGlobalFilter(rows, globalFilter, resolvedColumns, config.filtering?.globalFilterFn);
|
||||
return rows;
|
||||
});
|
||||
|
||||
const sortedRows = $derived.by(() => {
|
||||
if (!config.sorting?.enabled || config.sorting?.manual) return filteredRows;
|
||||
return applySorting(filteredRows, sorting, resolvedColumns);
|
||||
});
|
||||
|
||||
const totalRowCount = $derived.by(() => config.rowCount ?? sortedRows.length);
|
||||
|
||||
const pageCount = $derived.by(() => {
|
||||
if (!config.pagination?.enabled) return 1;
|
||||
return Math.max(1, Math.ceil(totalRowCount / pagination.pageSize));
|
||||
});
|
||||
|
||||
const paginatedRows = $derived.by(() => {
|
||||
if (!config.pagination?.enabled || config.pagination?.manual) return sortedRows;
|
||||
return applyPagination(sortedRows, pagination);
|
||||
});
|
||||
|
||||
// ── Derived: table model ─────────────────────────────────────────────
|
||||
|
||||
const headers = $derived.by(() =>
|
||||
getVisibleColumns().map((col) => ({
|
||||
id: col.id,
|
||||
column: col,
|
||||
label: col.def.header ?? col.id,
|
||||
} as TableHeader<TData>)),
|
||||
);
|
||||
|
||||
const rows = $derived.by(() =>
|
||||
paginatedRows.map((data, idx) => {
|
||||
const globalIdx = config.pagination?.enabled
|
||||
? pagination.pageIndex * pagination.pageSize + idx
|
||||
: idx;
|
||||
const id = getRowId(data, globalIdx);
|
||||
const visibleCols = getVisibleColumns();
|
||||
const cells: TableCell<TData>[] = visibleCols.map((col) => ({
|
||||
id: `${id}_${col.id}`,
|
||||
column: col,
|
||||
value: col.getvalue(data),
|
||||
}));
|
||||
return { id, index: globalIdx, original: data, cells } as TableRow<TData>;
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Column visibility actions ────────────────────────────────────────
|
||||
|
||||
function getIsColumnVisible(columnId: string): boolean {
|
||||
return columnVisibility[columnId] !== false;
|
||||
}
|
||||
|
||||
function getVisibleColumns(): ResolvedColumn<TData>[] {
|
||||
return resolvedColumns.filter((col) => getIsColumnVisible(col.id));
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(columnId: string) {
|
||||
columnVisibility = { ...columnVisibility, [columnId]: !getIsColumnVisible(columnId) };
|
||||
config.onColumnVisibilityChange?.(columnVisibility);
|
||||
}
|
||||
|
||||
function setColumnVisibility(v: ColumnVisibilityState) {
|
||||
columnVisibility = v;
|
||||
config.onColumnVisibilityChange?.(v);
|
||||
}
|
||||
|
||||
// ── Filtering actions ────────────────────────────────────────────────
|
||||
|
||||
function setColumnFilter(columnId: string, value: any) {
|
||||
const existing = columnFilters.findIndex((f) => f.id === columnId);
|
||||
if (existing >= 0) {
|
||||
columnFilters = columnFilters.map((f) => (f.id === columnId ? { ...f, value } : f));
|
||||
} else {
|
||||
columnFilters = [...columnFilters, { id: columnId, value }];
|
||||
}
|
||||
config.onColumnFiltersChange?.(columnFilters);
|
||||
// Reset to first page when filters change
|
||||
if (config.pagination?.enabled) {
|
||||
pagination = { ...pagination, pageIndex: 0 };
|
||||
config.onPaginationChange?.(pagination);
|
||||
}
|
||||
}
|
||||
|
||||
function removeColumnFilter(columnId: string) {
|
||||
columnFilters = columnFilters.filter((f) => f.id !== columnId);
|
||||
config.onColumnFiltersChange?.(columnFilters);
|
||||
}
|
||||
|
||||
function setGlobalFilter(value: string) {
|
||||
globalFilter = value;
|
||||
config.onGlobalFilterChange?.(value);
|
||||
if (config.pagination?.enabled) {
|
||||
pagination = { ...pagination, pageIndex: 0 };
|
||||
config.onPaginationChange?.(pagination);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
columnFilters = [];
|
||||
globalFilter = '';
|
||||
config.onColumnFiltersChange?.([]);
|
||||
config.onGlobalFilterChange?.('');
|
||||
}
|
||||
|
||||
function getColumnFilterValue(columnId: string): any {
|
||||
return columnFilters.find((f) => f.id === columnId)?.value;
|
||||
}
|
||||
|
||||
function getCanFilter(columnId: string): boolean {
|
||||
if (!config.filtering?.enabled) return false;
|
||||
const col = resolvedColumns.find((c) => c.id === columnId);
|
||||
return col?.def.enableFiltering !== false;
|
||||
}
|
||||
|
||||
// ── Sorting actions ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Toggle sort on a column.
|
||||
* @param columnId Column to sort.
|
||||
* @param multi If true, adds to multi-sort. If false, replaces.
|
||||
* Typically bound to Shift key by the component.
|
||||
*/
|
||||
function toggleSort(columnId: string, multi: boolean = false) {
|
||||
if (!config.sorting?.enabled) return;
|
||||
const col = resolvedColumns.find((c) => c.id === columnId);
|
||||
if (!col || col.def.enableSorting === false) return;
|
||||
|
||||
const useMulti = multi && config.sorting.multi === true;
|
||||
const existing = sorting.find((s) => s.id === columnId);
|
||||
let next: SortingState;
|
||||
|
||||
if (!existing) {
|
||||
// Add new sort entry
|
||||
const desc = col.def.sortDescFirst ?? false;
|
||||
const entry: SortingEntry = { id: columnId, desc };
|
||||
next = useMulti ? [...sorting, entry] : [entry];
|
||||
} else if (!existing.desc) {
|
||||
// asc → desc
|
||||
if (useMulti) {
|
||||
next = sorting.map((s) => (s.id === columnId ? { ...s, desc: true } : s));
|
||||
} else {
|
||||
next = [{ id: columnId, desc: true }];
|
||||
}
|
||||
} else {
|
||||
// desc → remove
|
||||
next = sorting.filter((s) => s.id !== columnId);
|
||||
}
|
||||
|
||||
sorting = next;
|
||||
config.onSortingChange?.(next);
|
||||
}
|
||||
|
||||
function setSorting(s: SortingState) {
|
||||
sorting = s;
|
||||
config.onSortingChange?.(s);
|
||||
}
|
||||
|
||||
function clearSorting() {
|
||||
sorting = [];
|
||||
config.onSortingChange?.([]);
|
||||
}
|
||||
|
||||
function getIsSorted(columnId: string): false | 'asc' | 'desc' {
|
||||
const entry = sorting.find((s) => s.id === columnId);
|
||||
if (!entry) return false;
|
||||
return entry.desc ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function getCanSort(columnId: string): boolean {
|
||||
if (!config.sorting?.enabled) return false;
|
||||
const col = resolvedColumns.find((c) => c.id === columnId);
|
||||
return col?.def.enableSorting !== false;
|
||||
}
|
||||
|
||||
// ── Selection actions ────────────────────────────────────────────────
|
||||
|
||||
const selectionMode = config.selection?.mode ?? 'none';
|
||||
|
||||
function toggleRowSelection(rowId: string) {
|
||||
if (selectionMode === 'none') return;
|
||||
const next = { ...selection };
|
||||
if (selectionMode === 'single') {
|
||||
if (next[rowId]) {
|
||||
delete next[rowId];
|
||||
} else {
|
||||
for (const key of Object.keys(next)) delete next[key];
|
||||
next[rowId] = true;
|
||||
}
|
||||
} else {
|
||||
if (next[rowId]) {
|
||||
delete next[rowId];
|
||||
} else {
|
||||
next[rowId] = true;
|
||||
}
|
||||
}
|
||||
selection = next;
|
||||
config.onSelectionChange?.(next);
|
||||
}
|
||||
|
||||
function setRowSelection(s: RowSelectionState) {
|
||||
selection = s;
|
||||
config.onSelectionChange?.(s);
|
||||
}
|
||||
|
||||
function toggleAllSelection() {
|
||||
if (selectionMode !== 'multiple') return;
|
||||
const allSelected = getIsAllSelected();
|
||||
if (allSelected) {
|
||||
selection = {};
|
||||
} else {
|
||||
const next: RowSelectionState = {};
|
||||
sortedRows.forEach((row, idx) => {
|
||||
next[getRowId(row, idx)] = true;
|
||||
});
|
||||
selection = next;
|
||||
}
|
||||
config.onSelectionChange?.(selection);
|
||||
}
|
||||
|
||||
function getIsRowSelected(rowId: string): boolean {
|
||||
return !!selection[rowId];
|
||||
}
|
||||
|
||||
function getIsAllSelected(): boolean {
|
||||
if (sortedRows.length === 0) return false;
|
||||
return sortedRows.every((row, idx) => selection[getRowId(row, idx)]);
|
||||
}
|
||||
|
||||
function getIsSomeSelected(): boolean {
|
||||
const selectedCount = Object.keys(selection).filter((k) => selection[k]).length;
|
||||
return selectedCount > 0 && selectedCount < sortedRows.length;
|
||||
}
|
||||
|
||||
// ── Pagination actions ───────────────────────────────────────────────
|
||||
|
||||
const canPreviousPage = $derived.by(() => pagination.pageIndex > 0);
|
||||
const canNextPage = $derived.by(() => pagination.pageIndex < pageCount - 1);
|
||||
|
||||
function setPageIndex(index: number) {
|
||||
const clamped = Math.max(0, Math.min(index, pageCount - 1));
|
||||
pagination = { ...pagination, pageIndex: clamped };
|
||||
config.onPaginationChange?.(pagination);
|
||||
}
|
||||
|
||||
function setPageSize(size: number) {
|
||||
pagination = { pageIndex: 0, pageSize: size };
|
||||
config.onPaginationChange?.(pagination);
|
||||
}
|
||||
|
||||
function previousPage() { setPageIndex(pagination.pageIndex - 1); }
|
||||
function nextPage() { setPageIndex(pagination.pageIndex + 1); }
|
||||
function firstPage() { setPageIndex(0); }
|
||||
function lastPage() { setPageIndex(pageCount - 1); }
|
||||
|
||||
// ── Instance ─────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
get columns() { return resolvedColumns; },
|
||||
get headers() { return headers; },
|
||||
get rows() { return rows; },
|
||||
get allRows() { return allRows; },
|
||||
|
||||
// Column visibility
|
||||
get columnVisibility() { return columnVisibility; },
|
||||
toggleColumnVisibility,
|
||||
setColumnVisibility,
|
||||
getIsColumnVisible,
|
||||
getVisibleColumns,
|
||||
|
||||
// Filtering
|
||||
get columnFilters() { return columnFilters; },
|
||||
get globalFilter() { return globalFilter; },
|
||||
setColumnFilter,
|
||||
removeColumnFilter,
|
||||
setGlobalFilter,
|
||||
clearFilters,
|
||||
getColumnFilterValue,
|
||||
getCanFilter,
|
||||
|
||||
// Sorting
|
||||
get sorting() { return sorting; },
|
||||
toggleSort,
|
||||
setSorting,
|
||||
clearSorting,
|
||||
getIsSorted,
|
||||
getCanSort,
|
||||
|
||||
// Selection
|
||||
get selection() { return selection; },
|
||||
selectionMode,
|
||||
toggleRowSelection,
|
||||
setRowSelection,
|
||||
toggleAllSelection,
|
||||
getIsRowSelected,
|
||||
getIsAllSelected,
|
||||
getIsSomeSelected,
|
||||
|
||||
// Pagination
|
||||
get pagination() { return pagination; },
|
||||
get pageCount() { return pageCount; },
|
||||
get canPreviousPage() { return canPreviousPage; },
|
||||
get canNextPage() { return canNextPage; },
|
||||
setPageIndex,
|
||||
setPageSize,
|
||||
previousPage,
|
||||
nextPage,
|
||||
firstPage,
|
||||
lastPage,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,206 @@
|
||||
import { Provider, context, type ProviderOpts, type WithRefOpts } from '$soma/provider';
|
||||
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
|
||||
import { type ActiveProps } from '$soma/reactive';
|
||||
import type { SomaKeyboardEvent } from '$soma/types';
|
||||
import { KEYS } from '$soma/keyboard';
|
||||
import { getSoma } from '$soma/core/soma.svelte';
|
||||
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
|
||||
import type { TableInstance, TableHeader, TableRow, TableCell } from './table-core.svelte';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'table',
|
||||
parts: ['root', 'header', 'body', 'footer', 'column-header', 'row', 'cell'] as const,
|
||||
});
|
||||
|
||||
// ── Provider (root, no DOM) ─────────────────────────────────────────────────
|
||||
|
||||
interface TableProviderOpts
|
||||
extends ProviderOpts,
|
||||
ActiveProps<{
|
||||
table: TableInstance;
|
||||
label: string;
|
||||
}> {}
|
||||
|
||||
export class TableProvider extends Provider<TableProviderOpts> {
|
||||
static readonly ctx = context<TableProvider>('Table');
|
||||
|
||||
static from(opts: TableProviderOpts) {
|
||||
return new TableProvider(opts);
|
||||
}
|
||||
|
||||
readonly config = getSoma();
|
||||
|
||||
private constructor(opts: TableProviderOpts) {
|
||||
super(opts, 'Table', 'root', attrs.root, TableProvider.ctx);
|
||||
}
|
||||
|
||||
get table(): TableInstance {
|
||||
return this.opts.table.current;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ColumnHeader ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface TableColumnHeaderOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
header: TableHeader;
|
||||
}> {}
|
||||
|
||||
export class TableColumnHeaderProvider extends Provider<TableColumnHeaderOpts> {
|
||||
static from(opts: TableColumnHeaderOpts) {
|
||||
return new TableColumnHeaderProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: TableProvider;
|
||||
|
||||
private constructor(opts: TableColumnHeaderOpts) {
|
||||
super(opts, 'Table', 'column-header', attrs['column-header']);
|
||||
this.provider = TableProvider.ctx.get();
|
||||
}
|
||||
|
||||
private get header(): TableHeader {
|
||||
return this.opts.header.current;
|
||||
}
|
||||
|
||||
private get table(): TableInstance {
|
||||
return this.provider.table;
|
||||
}
|
||||
|
||||
readonly onclick = (e: MouseEvent) => {
|
||||
if (this.table.getCanSort(this.header.id)) {
|
||||
this.table.toggleSort(this.header.id, e.shiftKey);
|
||||
}
|
||||
};
|
||||
|
||||
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
|
||||
if (this.table.getCanSort(this.header.id)) {
|
||||
e.preventDefault();
|
||||
this.table.toggleSort(this.header.id, e.shiftKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private get sortLabel(): string {
|
||||
const sorted = this.table.getIsSorted(this.header.id);
|
||||
const translator = this.provider.config.translator.current;
|
||||
if (!sorted) {
|
||||
return resolveSomaTranslationPath('soma.table.sort', translator, 'Sort');
|
||||
}
|
||||
return sorted === 'asc'
|
||||
? resolveSomaTranslationPath('soma.table.sortAsc', translator, 'Sorted ascending')
|
||||
: resolveSomaTranslationPath('soma.table.sortDesc', translator, 'Sorted descending');
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
const sorted = this.table.getIsSorted(this.header.id);
|
||||
const canSort = this.table.getCanSort(this.header.id);
|
||||
const ariaSort: 'ascending' | 'descending' | 'none' = sorted
|
||||
? sorted === 'asc' ? 'ascending' : 'descending'
|
||||
: 'none';
|
||||
|
||||
return this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'columnheader' as const,
|
||||
'aria-sort': ariaSort,
|
||||
'aria-label': canSort ? this.sortLabel : undefined,
|
||||
title: canSort ? this.sortLabel : undefined,
|
||||
tabindex: canSort ? 0 : undefined,
|
||||
'data-sortable': boolToEmptyStrOrUndef(canSort),
|
||||
'data-sorted': sorted || undefined,
|
||||
onclick: canSort ? this.onclick : undefined,
|
||||
onkeydown: canSort ? this.onkeydown : undefined,
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Row ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TableRowOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
row: TableRow;
|
||||
}> {}
|
||||
|
||||
export class TableRowProvider extends Provider<TableRowOpts> {
|
||||
static readonly ctx = context<TableRowProvider>('TableRow');
|
||||
|
||||
static from(opts: TableRowOpts) {
|
||||
return new TableRowProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: TableProvider;
|
||||
|
||||
private constructor(opts: TableRowOpts) {
|
||||
super(opts, 'Table', 'row', attrs.row, TableRowProvider.ctx);
|
||||
this.provider = TableProvider.ctx.get();
|
||||
}
|
||||
|
||||
private get row(): TableRow {
|
||||
return this.opts.row.current;
|
||||
}
|
||||
|
||||
private get table(): TableInstance {
|
||||
return this.provider.table;
|
||||
}
|
||||
|
||||
readonly isSelected = $derived.by(
|
||||
() => this.table.getIsRowSelected(this.row.id),
|
||||
);
|
||||
|
||||
readonly onclick = () => {
|
||||
if (this.table.selectionMode !== 'none') {
|
||||
this.table.toggleRowSelection(this.row.id);
|
||||
}
|
||||
};
|
||||
|
||||
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === KEYS.SPACE || e.key === KEYS.ENTER) {
|
||||
if (this.table.selectionMode !== 'none') {
|
||||
e.preventDefault();
|
||||
this.table.toggleRowSelection(this.row.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
tabindex: this.table.selectionMode !== 'none' ? 0 : undefined,
|
||||
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
|
||||
onclick: this.table.selectionMode !== 'none' ? this.onclick : undefined,
|
||||
onkeydown: this.table.selectionMode !== 'none' ? this.onkeydown : undefined,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cell ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TableCellOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
cell: TableCell;
|
||||
colIndex: number;
|
||||
}> {}
|
||||
|
||||
export class TableCellProvider extends Provider<TableCellOpts> {
|
||||
static from(opts: TableCellOpts) {
|
||||
return new TableCellProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: TableCellOpts) {
|
||||
super(opts, 'Table', 'cell', attrs.cell);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'gridcell' as const,
|
||||
'aria-colindex': this.opts.colIndex.current + 1,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without } from '$soma/types';
|
||||
import type { PrimitiveDivAttributes } from '$soma/types';
|
||||
import type { TableInstance, TableHeader, TableRow, TableCell } from './table-core.svelte';
|
||||
|
||||
export type TableProviderProps = {
|
||||
/** The table instance. Created via `createTable()`. Required. */
|
||||
table: TableInstance;
|
||||
/**
|
||||
* Accessible label for the table.
|
||||
* @default 'Data table'
|
||||
*/
|
||||
label?: string;
|
||||
/** Content. */
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type TableColumnHeaderProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** The header object from `table.headers`. Required. */
|
||||
header: TableHeader;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type TableRowProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** The row object from `table.rows`. Required. */
|
||||
row: TableRow;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type TableCellProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** The cell object from `row.cells`. Required. */
|
||||
cell: TableCell;
|
||||
/** Column index (0-based). Required. */
|
||||
colIndex: number;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
@ -0,0 +1,10 @@
|
||||
import type { SomaCurrencyFormatter } from '$soma/types';
|
||||
import { createSomaFormatter, type SomaFormatterSource } from './formatter.svelte';
|
||||
|
||||
export type SomaCurrencyFormatterSource = SomaFormatterSource;
|
||||
|
||||
export function createSomaCurrencyFormatter(
|
||||
source: SomaCurrencyFormatterSource,
|
||||
): SomaCurrencyFormatter {
|
||||
return createSomaFormatter(source);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import type { Getter } from '$soma/reactive';
|
||||
import type { SomaDateTimeFormatter } from '$soma/types';
|
||||
import type { DateOrder, HourCycle } from '$soma/external/dates';
|
||||
|
||||
export type SomaDateTimeFormatterSource = {
|
||||
dateOrder?: Getter<DateOrder | undefined>;
|
||||
hourCycle?: Getter<HourCycle | undefined>;
|
||||
};
|
||||
|
||||
export function createSomaDateTimeFormatter(
|
||||
source: SomaDateTimeFormatterSource,
|
||||
): SomaDateTimeFormatter {
|
||||
return {
|
||||
getDateOrder() {
|
||||
return source.dateOrder?.();
|
||||
},
|
||||
getHourCycle() {
|
||||
return source.hourCycle?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import type { Getter } from '$soma/reactive';
|
||||
import type { SomaFormatter } from '$soma/types';
|
||||
|
||||
export type SomaFormatterSource = {
|
||||
format: Getter<(...args: unknown[]) => string>;
|
||||
};
|
||||
|
||||
export function createSomaFormatter(source: SomaFormatterSource): SomaFormatter {
|
||||
return {
|
||||
format(...args: unknown[]) {
|
||||
return source.format()(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import type { SomaNumberFormatter } from '$soma/types';
|
||||
import { createSomaFormatter, type SomaFormatterSource } from './formatter.svelte';
|
||||
|
||||
export type SomaNumberFormatterSource = SomaFormatterSource;
|
||||
|
||||
export function createSomaNumberFormatter(
|
||||
source: SomaNumberFormatterSource,
|
||||
): SomaNumberFormatter {
|
||||
return createSomaFormatter(source);
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { readableActive, type Active, type Getter } from '$soma/reactive';
|
||||
import type { Direction } from '$soma/types';
|
||||
import {
|
||||
DEFAULT_DATE_ORDER,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
timeFormatToHourCycle,
|
||||
type HourCycle
|
||||
} from '$soma/external/dates';
|
||||
import { getSoma } from './soma.svelte';
|
||||
|
||||
type SomaOpts = ReturnType<typeof getSoma>;
|
||||
|
||||
function createPropResolver<T>(configOption: (config: SomaOpts) => T | undefined, fallback: T) {
|
||||
return (getProp: Getter<T | undefined>): Active<T> => {
|
||||
const config = getSoma();
|
||||
return readableActive(() => {
|
||||
const propValue = getProp();
|
||||
if (propValue !== undefined) return propValue;
|
||||
|
||||
const option = configOption(config);
|
||||
if (option !== undefined) return option;
|
||||
|
||||
return fallback;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const resolveLocaleProp = createPropResolver((config) => {
|
||||
const locale = config.translator.current.getLocale();
|
||||
return locale || undefined;
|
||||
}, '');
|
||||
|
||||
export const resolveDirectionProp = createPropResolver(
|
||||
(config) => config.presentation.current?.dir,
|
||||
'ltr' as Direction
|
||||
);
|
||||
|
||||
export const resolvePortalToProp = createPropResolver(
|
||||
(config) => config.portalTo.current as string | undefined,
|
||||
'body'
|
||||
);
|
||||
|
||||
export const resolveDateOrderProp = createPropResolver(
|
||||
(config) => config.presentation.current?.dateTimeFormatter?.getDateOrder(),
|
||||
DEFAULT_DATE_ORDER
|
||||
);
|
||||
|
||||
export function resolveHourCycleProp(
|
||||
getTimeFormat: Getter<HourCycle | undefined>,
|
||||
getHourCycle: Getter<HourCycle | undefined>
|
||||
): Active<HourCycle> {
|
||||
const config = getSoma();
|
||||
return readableActive(() => {
|
||||
const timeFormat = getTimeFormat();
|
||||
if (timeFormat !== undefined) return timeFormatToHourCycle(timeFormat);
|
||||
|
||||
const hourCycle = getHourCycle();
|
||||
if (hourCycle !== undefined) return hourCycle;
|
||||
|
||||
const configTimeFormat = config.presentation.current?.dateTimeFormatter?.getHourCycle();
|
||||
if (configTimeFormat !== undefined) return configTimeFormat;
|
||||
|
||||
return timeFormatToHourCycle(DEFAULT_TIME_FORMAT);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue