From a15b785cc101c1dfbb9b0cd1ab3633e6878a2d1f Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 24 May 2026 21:35:51 +0200 Subject: [PATCH] refactor(table): promote sort affordance from eidos to soma part (audit 5.3 P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates `Provider.require()` from the eidos `Table.SortButton` by promoting the affordance (canSort guard + click/keyboard handlers + data-sortable/data-sorted attrs) to a new soma part ``. Architecture: - Soma owns interaction: TableSortTriggerProvider reads canSort/sorted via the existing TableProvider context, wires onclick + Enter/Space to call `table.toggleSort`, and stamps the canonical data-attrs. - Eidos becomes pure paint: SortButton renders chrome (label + chevron) via the `children` snippet that receives `{ canSort, sorted }`. Public API of eidos `` is unchanged — consumers continue to write `{header.label}`. CSS selectors in eidos table.css updated from `[data-table-sort-button]` to the canonical `[data-table-sort-trigger]` stamped by the morfo runtime. Test result unchanged: 2391/2397 passing. No regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/table/table-sort-button.svelte | 73 +++++------------ src/uix/eidos/components/table/table.css | 8 +- src/uix/morfo/components/table.ts | 18 +++++ .../components/table-sort-trigger.svelte | 52 ++++++++++++ src/uix/soma/components/table/exports.ts | 4 +- .../components/table/table-provider.svelte.ts | 80 +++++++++++++++++++ src/uix/soma/components/table/types.ts | 34 ++++++++ 7 files changed, 211 insertions(+), 58 deletions(-) create mode 100644 src/uix/soma/components/table/components/table-sort-trigger.svelte diff --git a/src/uix/eidos/components/table/table-sort-button.svelte b/src/uix/eidos/components/table/table-sort-button.svelte index d40b4e5ad..30110fea4 100644 --- a/src/uix/eidos/components/table/table-sort-button.svelte +++ b/src/uix/eidos/components/table/table-sort-button.svelte @@ -1,63 +1,34 @@ -{#if canSort} - -{:else if children} - {@render children()} -{:else} - {header.label} -{/if} + {/snippet} + diff --git a/src/uix/eidos/components/table/table.css b/src/uix/eidos/components/table/table.css index d0b282d3e..ed1258183 100644 --- a/src/uix/eidos/components/table/table.css +++ b/src/uix/eidos/components/table/table.css @@ -308,7 +308,7 @@ } /* ── SortButton (eidos helper) ─ */ -[data-table-sort-button] { +[data-table-sort-trigger] { display: inline-flex; align-items: center; gap: var(--space-1); @@ -323,12 +323,12 @@ border-radius: var(--radius-sm); } -[data-table-sort-button]:focus-visible { +[data-table-sort-trigger]:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring-color); outline-offset: 2px; } -[data-table-sort-button][data-sorted] { +[data-table-sort-trigger][data-sorted] { color: var(--_table-accent); } @@ -342,6 +342,6 @@ transition: opacity var(--duration-fast) var(--ease-default); } -[data-table-sort-button][data-sorted] [data-table-sort-indicator] { +[data-table-sort-trigger][data-sorted] [data-table-sort-indicator] { opacity: 1; } diff --git a/src/uix/morfo/components/table.ts b/src/uix/morfo/components/table.ts index 13ba86270..6f180467d 100644 --- a/src/uix/morfo/components/table.ts +++ b/src/uix/morfo/components/table.ts @@ -145,6 +145,24 @@ export const tableMorfo = { data: [{ attr: 'data-pinned', values: ['left', 'right'], severity: 'optional' }], aria: [{ attr: 'aria-colindex', value: v.propRef('colIndex') }] }, + { + name: 'SortTrigger', + kebab: 'sort-trigger', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [ + { attr: 'data-sortable', severity: 'optional' }, + { attr: 'data-sorted', values: ['asc', 'desc'], severity: 'optional' } + ], + aria: [{ attr: 'type', value: v.literal('button') }], + keyboard: [ + { key: ' ', action: 'select' }, + { key: 'Enter', action: 'select' } + ] + }, { name: 'RowDetail', kebab: 'row-detail', diff --git a/src/uix/soma/components/table/components/table-sort-trigger.svelte b/src/uix/soma/components/table/components/table-sort-trigger.svelte new file mode 100644 index 000000000..1b1f87d5f --- /dev/null +++ b/src/uix/soma/components/table/components/table-sort-trigger.svelte @@ -0,0 +1,52 @@ + + +{#if child} + {@render child({ + props: mergedProps, + canSort: provider.canSort, + sorted: provider.sorted + })} +{:else if provider.canSort} + +{:else if children} + {@render children({ canSort: provider.canSort, sorted: provider.sorted })} +{:else} + {header.label} +{/if} diff --git a/src/uix/soma/components/table/exports.ts b/src/uix/soma/components/table/exports.ts index 2f7822601..d1781a036 100644 --- a/src/uix/soma/components/table/exports.ts +++ b/src/uix/soma/components/table/exports.ts @@ -7,6 +7,7 @@ export { default as Row } from './components/table-row.svelte'; export { default as Cell } from './components/table-cell.svelte'; export { default as RowDetail } from './components/table-row-detail.svelte'; export { default as RowDetailTrigger } from './components/table-row-detail-trigger.svelte'; +export { default as SortTrigger } from './components/table-sort-trigger.svelte'; export type { TableProviderProps as ProviderProps, @@ -17,5 +18,6 @@ export type { TableRowProps as RowProps, TableCellProps as CellProps, TableRowDetailProps as RowDetailProps, - TableRowDetailTriggerProps as RowDetailTriggerProps + TableRowDetailTriggerProps as RowDetailTriggerProps, + TableSortTriggerProps as SortTriggerProps } from './types'; diff --git a/src/uix/soma/components/table/table-provider.svelte.ts b/src/uix/soma/components/table/table-provider.svelte.ts index 275069bb5..b11d06ab7 100644 --- a/src/uix/soma/components/table/table-provider.svelte.ts +++ b/src/uix/soma/components/table/table-provider.svelte.ts @@ -158,6 +158,86 @@ export class TableColumnHeaderProvider { }); } +// ── SortTrigger ───────────────────────────────────────────────────────────── +// +// Promoted from eidos. Soma owns the sortable/sorted state read, the +// click + Enter/Space handlers that call `table.toggleSort`, and the +// `data-table-sort-trigger` + `data-sortable` + `data-sorted` attrs. +// Eidos paints chrome (icon, label slot) via the `children` snippet that +// receives `{ canSort, sorted }`. + +interface TableSortTriggerOpts + extends + WithRefOpts, + ActiveProps<{ + header: TableHeader; + }> {} + +export class TableSortTriggerProvider { + readonly opts: TableSortTriggerOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: TableSortTriggerOpts) { + return new TableSortTriggerProvider(opts); + } + + readonly provider: TableProvider; + + private constructor(opts: TableSortTriggerOpts) { + this.opts = opts; + this.provider = TableProvider.require(); + this.runtimePart = this.provider.runtime.part('sort-trigger', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + private get header(): TableHeader { + return this.opts.header.current; + } + + private get table(): TableInstance { + return this.provider.table; + } + + get canSort(): boolean { + return this.table.getCanSort(this.header.id); + } + + get sorted(): 'asc' | 'desc' | false { + return this.table.getIsSorted(this.header.id); + } + + readonly onclick = (e: SomaMouseEvent) => { + if (!this.canSort) return; + // SortTrigger lives inside ColumnHeader (no row), but stop + // propagation so consumers can wrap header content in + // click-handling parents without surprises. + e.stopPropagation(); + this.table.toggleSort(this.header.id, e.shiftKey); + }; + + readonly onkeydown = (e: SomaKeyboardEvent) => { + if (!this.canSort) return; + if (e.key === KEYS.Enter || e.key === KEYS.Space) { + e.preventDefault(); + e.stopPropagation(); + this.table.toggleSort(this.header.id, e.shiftKey); + } + }; + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + type: 'button' as const, + 'data-sortable': boolToEmptyStrOrUndef(this.canSort), + 'data-sorted': this.sorted || undefined, + onclick: this.onclick, + onkeydown: this.onkeydown + } as const) + ); +} + // ── Row ────────────────────────────────────────────────────────────────────── interface TableRowOpts diff --git a/src/uix/soma/components/table/types.ts b/src/uix/soma/components/table/types.ts index b91c48f9d..10ce0c9bc 100644 --- a/src/uix/soma/components/table/types.ts +++ b/src/uix/soma/components/table/types.ts @@ -71,6 +71,40 @@ export type TableCellProps = WithChild<{ }> & Without; +/** + * Props for `Table.SortTrigger` — interactive button inside a ColumnHeader + * that toggles sort on click / Enter / Space. Renders nothing (or the + * unsortable label fallback) when the column is not sortable. + * + * Soma owns the canSort guard, click handler, and `data-table-sort-trigger` + * + `data-sortable` + `data-sorted` attrs. Eidos paints chrome (icon / + * label slot) via the `children` snippet that receives `{ canSort, sorted }`. + */ +export type TableSortTriggerProps = { + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Bindable element ref. */ + ref?: HTMLButtonElement | null; + /** The header object from `table.headers`. Required. */ + header: TableHeader; + /** + * Children rendered inside the button. Receives the live `canSort` + + * `sorted` state so eidos can paint a sort indicator without reading + * the table imperatively. + */ + children?: Snippet<[{ canSort: boolean; sorted: 'asc' | 'desc' | false }]>; + /** Render-prop override — receives `{ props, canSort, sorted }`. */ + child?: Snippet< + [ + { + props: Record; + canSort: boolean; + sorted: 'asc' | 'desc' | false; + } + ] + >; +} & Without; + /** * Props for `Table.RowDetail` — full-width disclosure panel rendered as a * **sibling ``** below the row when its detail is open.