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.