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">
/**
* Eidos `Table.SortButton` — ergonomic helper for the canonical sort UI.
*
* Soma exposes sort state imperatively (`table.toggleSort`,
* `table.getCanSort`, `table.getIsSorted`) but does NOT render a sort
* 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.
* Eidos `Table.SortButton` — ergonomic paint wrapper around the
* canonical soma `<Table.SortTrigger>` part. Soma owns the canSort
* guard, click/keyboard handlers, and `data-sortable`/`data-sorted`
* attrs. Eidos only paints the chrome: label slot + tri-state chevron.
*
* <Table.ColumnHeader {header}>
* <Table.SortButton {header}>{header.label}</Table.SortButton>
* </Table.ColumnHeader>
*
* Pure ergonomic shortcut — consumers can still hand-roll their own
* sort button if they need a non-standard shape.
* Pure visual decoration of soma's SortTrigger.
*/
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';
let { header, children, hideIndicator = false }: TableSortButtonProps = $props();
const provider = TableProvider.require();
const table = $derived(provider.table);
const canSort = $derived(table.getCanSort(header.id));
const sorted = $derived(table.getIsSorted(header.id));
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);
}
}
// Rename the consumer's `children` to `bodyContent` so the snippet
// identifier below (also named `children`, matching soma's snippet
// prop name) doesn't shadow the outer scope.
let {
header,
children: bodyContent,
hideIndicator = false
}: TableSortButtonProps = $props();
</script>
{#if canSort}
<button
type="button"
data-table-sort-button=""
data-sortable=""
data-sorted={sorted || undefined}
{onclick}
{onkeydown}
>
{#if children}
{@render children()}
<Table.SortTrigger {header}>
{#snippet children({ sorted })}
{#if bodyContent}
{@render bodyContent()}
{:else}
{header.label}
{/if}
@ -72,9 +43,5 @@
{/if}
</span>
{/if}
</button>
{:else if children}
{@render children()}
{:else}
{header.label}
{/if}
{/snippet}
</Table.SortTrigger>

@ -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;
}

@ -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',

@ -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 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';

@ -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

@ -71,6 +71,40 @@ export type TableCellProps = WithChild<{
}> &
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
* **sibling `<tr>`** below the row when its detail is open.

Loading…
Cancel
Save

Powered by TurnKey Linux.