refactor(table): promote sort affordance from eidos to soma part (audit 5.3 P3)

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 `<Table.SortTrigger>`.

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 `<Table.SortButton>` is unchanged — consumers continue
to write `<Table.SortButton {header}>{header.label}</Table.SortButton>`.

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent f13103e9a9
commit a15b785cc1

@ -1,63 +1,34 @@
<script lang="ts"> <script lang="ts">
/** /**
* Eidos `Table.SortButton` — ergonomic helper for the canonical sort UI. * Eidos `Table.SortButton` — ergonomic paint wrapper around the
* * canonical soma `<Table.SortTrigger>` part. Soma owns the canSort
* Soma exposes sort state imperatively (`table.toggleSort`, * guard, click/keyboard handlers, and `data-sortable`/`data-sorted`
* `table.getCanSort`, `table.getIsSorted`) but does NOT render a sort * attrs. Eidos only paints the chrome: label slot + tri-state chevron.
* button — that's left to the consumer. This helper renders a
* borderless button inside a `<Table.ColumnHeader>` that toggles
* sort on click and shift+click (multi-sort when enabled), shows a
* tri-state chevron, and hides itself when the column is not sortable.
* *
* <Table.ColumnHeader {header}> * <Table.ColumnHeader {header}>
* <Table.SortButton {header}>{header.label}</Table.SortButton> * <Table.SortButton {header}>{header.label}</Table.SortButton>
* </Table.ColumnHeader> * </Table.ColumnHeader>
* *
* Pure ergonomic shortcut — consumers can still hand-roll their own * Pure visual decoration of soma's SortTrigger.
* sort button if they need a non-standard shape.
*/ */
import { SvgChevron } from '$uix/eidos/components/svg'; import { SvgChevron } from '$uix/eidos/components/svg';
import { TableProvider } from '$soma/components/table/table-provider.svelte'; import * as Table from '$soma/components/table';
import type { TableSortButtonProps } from './types'; import type { TableSortButtonProps } from './types';
let { header, children, hideIndicator = false }: TableSortButtonProps = $props(); // Rename the consumer's `children` to `bodyContent` so the snippet
// identifier below (also named `children`, matching soma's snippet
const provider = TableProvider.require(); // prop name) doesn't shadow the outer scope.
const table = $derived(provider.table); let {
header,
const canSort = $derived(table.getCanSort(header.id)); children: bodyContent,
const sorted = $derived(table.getIsSorted(header.id)); hideIndicator = false
}: TableSortButtonProps = $props();
function onclick(e: MouseEvent) {
if (!canSort) return;
// Sort button lives inside Table.ColumnHeader (no row), but stop
// propagation anyway so consumers can wrap header content in
// click-handling parents without surprises.
e.stopPropagation();
table.toggleSort(header.id, e.shiftKey);
}
function onkeydown(e: KeyboardEvent) {
if (!canSort) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
table.toggleSort(header.id, e.shiftKey);
}
}
</script> </script>
{#if canSort} <Table.SortTrigger {header}>
<button {#snippet children({ sorted })}
type="button" {#if bodyContent}
data-table-sort-button="" {@render bodyContent()}
data-sortable=""
data-sorted={sorted || undefined}
{onclick}
{onkeydown}
>
{#if children}
{@render children()}
{:else} {:else}
{header.label} {header.label}
{/if} {/if}
@ -72,9 +43,5 @@
{/if} {/if}
</span> </span>
{/if} {/if}
</button> {/snippet}
{:else if children} </Table.SortTrigger>
{@render children()}
{:else}
{header.label}
{/if}

@ -308,7 +308,7 @@
} }
/* ── SortButton (eidos helper) ─ */ /* ── SortButton (eidos helper) ─ */
[data-table-sort-button] { [data-table-sort-trigger] {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
@ -323,12 +323,12 @@
border-radius: var(--radius-sm); 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: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 2px; outline-offset: 2px;
} }
[data-table-sort-button][data-sorted] { [data-table-sort-trigger][data-sorted] {
color: var(--_table-accent); color: var(--_table-accent);
} }
@ -342,6 +342,6 @@
transition: opacity var(--duration-fast) var(--ease-default); 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; opacity: 1;
} }

@ -145,6 +145,24 @@ export const tableMorfo = {
data: [{ attr: 'data-pinned', values: ['left', 'right'], severity: 'optional' }], data: [{ attr: 'data-pinned', values: ['left', 'right'], severity: 'optional' }],
aria: [{ attr: 'aria-colindex', value: v.propRef('colIndex') }] 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', name: 'RowDetail',
kebab: 'row-detail', kebab: 'row-detail',

@ -0,0 +1,52 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { TableSortTriggerProvider } from '../table-provider.svelte';
import type { TableSortTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'table-sort-trigger'),
header,
children,
child,
...restProps
}: TableSortTriggerProps = $props();
const provider = TableSortTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
header: readableActive(() => header)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({
props: mergedProps,
canSort: provider.canSort,
sorted: provider.sorted
})}
{:else if provider.canSort}
<button {...mergedProps}>
{#if children}
{@render children({ canSort: provider.canSort, sorted: provider.sorted })}
{:else}
{header.label}
{/if}
</button>
{:else if children}
{@render children({ canSort: provider.canSort, sorted: provider.sorted })}
{:else}
{header.label}
{/if}

@ -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 Cell } from './components/table-cell.svelte';
export { default as RowDetail } from './components/table-row-detail.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 RowDetailTrigger } from './components/table-row-detail-trigger.svelte';
export { default as SortTrigger } from './components/table-sort-trigger.svelte';
export type { export type {
TableProviderProps as ProviderProps, TableProviderProps as ProviderProps,
@ -17,5 +18,6 @@ export type {
TableRowProps as RowProps, TableRowProps as RowProps,
TableCellProps as CellProps, TableCellProps as CellProps,
TableRowDetailProps as RowDetailProps, TableRowDetailProps as RowDetailProps,
TableRowDetailTriggerProps as RowDetailTriggerProps TableRowDetailTriggerProps as RowDetailTriggerProps,
TableSortTriggerProps as SortTriggerProps
} from './types'; } from './types';

@ -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 ────────────────────────────────────────────────────────────────────── // ── Row ──────────────────────────────────────────────────────────────────────
interface TableRowOpts interface TableRowOpts

@ -71,6 +71,40 @@ export type TableCellProps = WithChild<{
}> & }> &
Without<PrimitiveTDAttributes, {}>; Without<PrimitiveTDAttributes, {}>;
/**
* 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<string, unknown>;
canSort: boolean;
sorted: 'asc' | 'desc' | false;
}
]
>;
} & Without<PrimitiveButtonAttributes, { type?: unknown }>;
/** /**
* Props for `Table.RowDetail` — full-width disclosure panel rendered as a * Props for `Table.RowDetail` — full-width disclosure panel rendered as a
* **sibling `<tr>`** below the row when its detail is open. * **sibling `<tr>`** below the row when its detail is open.

Loading…
Cancel
Save

Powered by TurnKey Linux.