@human-kit/svelte-components 1.0.0-alpha.19 → 1.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,15 +26,25 @@
26
26
  let wrapperRef: HTMLElement | null = $state(null);
27
27
  let activeTrigger: HTMLElement | null = null;
28
28
 
29
+ function syncTriggerState(button: HTMLElement) {
30
+ button.setAttribute('aria-haspopup', 'dialog');
31
+ button.setAttribute('aria-expanded', String(popoverCtx.isOpen));
32
+ if (popoverCtx.isOpen) {
33
+ button.dataset.pressed = 'true';
34
+ } else {
35
+ delete button.dataset.pressed;
36
+ }
37
+ }
38
+
29
39
  function setActiveTrigger(button: HTMLElement) {
30
40
  if (activeTrigger && activeTrigger !== button) {
31
41
  activeTrigger.setAttribute('aria-expanded', 'false');
42
+ delete activeTrigger.dataset.pressed;
32
43
  }
33
44
 
34
45
  activeTrigger = button;
35
46
  popoverCtx.setTriggerRef(button);
36
- button.setAttribute('aria-haspopup', 'dialog');
37
- button.setAttribute('aria-expanded', String(popoverCtx.isOpen));
47
+ syncTriggerState(button);
38
48
  }
39
49
 
40
50
  function handleClick(event: MouseEvent) {
@@ -70,8 +80,7 @@
70
80
  if (activeTrigger !== popoverCtx.triggerRef) {
71
81
  activeTrigger = popoverCtx.triggerRef;
72
82
  }
73
- popoverCtx.triggerRef.setAttribute('aria-haspopup', 'dialog');
74
- popoverCtx.triggerRef.setAttribute('aria-expanded', String(popoverCtx.isOpen));
83
+ syncTriggerState(popoverCtx.triggerRef);
75
84
  }
76
85
  });
77
86
  </script>
@@ -4,9 +4,9 @@
4
4
 
5
5
  ## Description
6
6
 
7
- `Table` is a headless interactive table primitive with grid-style keyboard navigation, row selection, sortable column headers, and a composable part-based API.
7
+ `Table` is a headless interactive table primitive with grid-style keyboard navigation, row selection, explicit sortable header triggers, and a composable part-based API.
8
8
 
9
- All public Table part prop types are exported from the table barrel, including `TableRootProps`, `TableColumnProps`, `TableHeaderProps`, `TableBodyProps`, `TableFooterProps`, `TableRowProps`, `TableColumnHeaderCellProps`, `TableColumnResizerProps`, `TableCellProps`, `TableEmptyStateProps`, `TableCheckboxProps`, and `TableCheckboxIndicatorProps`.
9
+ All public Table part prop types are exported from the table barrel, including `TableRootProps`, `TableColumnProps`, `TableHeaderProps`, `TableBodyProps`, `TableFooterProps`, `TableRowProps`, `TableColumnHeaderCellProps`, `TableSortTriggerProps`, `TableColumnResizerProps`, `TableCellProps`, `TableEmptyStateProps`, `TableCheckboxProps`, and `TableCheckboxIndicatorProps`.
10
10
 
11
11
  ## Anatomy
12
12
 
@@ -26,8 +26,19 @@ All public Table part prop types are exported from the table barrel, including `
26
26
  <Table.Column id="email" isRowHeader>
27
27
  <Table.ColumnHeaderCell>Email</Table.ColumnHeaderCell>
28
28
  </Table.Column>
29
- <Table.Column id="group" allowsSorting>
30
- <Table.ColumnHeaderCell>Group</Table.ColumnHeaderCell>
29
+ <Table.Column id="group">
30
+ <Table.ColumnHeaderCell>
31
+ <Table.SortTrigger>
32
+ {#snippet children({ sortDirection })}
33
+ <button
34
+ type="button"
35
+ aria-label={`Group sort button. ${sortDirection ?? 'not sorted'}.`}
36
+ >
37
+ Sort group
38
+ </button>
39
+ {/snippet}
40
+ </Table.SortTrigger>
41
+ </Table.ColumnHeaderCell>
31
42
  </Table.Column>
32
43
  <Table.Column id="size" minWidth={120}>
33
44
  <Table.ColumnHeaderCell>
@@ -71,6 +82,7 @@ All public Table part prop types are exported from the table barrel, including `
71
82
  - `Table.Footer`
72
83
  - `Table.Row`
73
84
  - `Table.ColumnHeaderCell`
85
+ - `Table.SortTrigger`
74
86
  - `Table.ColumnResizer`
75
87
  - `Table.Checkbox`
76
88
  - `Table.CheckboxIndicator`
@@ -112,7 +124,8 @@ All public Table part prop types are exported from the table barrel, including `
112
124
 
113
125
  - DOM-rendering parts: `Table.Root`, `Table.Header`, `Table.Body`, `Table.Footer`, `Table.Row`, `Table.Cell`, `Table.ColumnHeaderCell`, `Table.ColumnResizer`, `Table.Checkbox`, `Table.CheckboxIndicator`, and `Table.EmptyState` all render DOM.
114
126
  - Metadata-only part: `Table.Column` does not render its own element. It only registers the public column input for the surrounding header composition.
115
- - Sorting: `Table.Column.allowsSorting` declares that a column participates in sorting, and `Table.ColumnHeaderCell` is the interactive header cell that toggles the sort state.
127
+ - Sorting: `Table.SortTrigger` is the public opt-in for sortable columns. Rendering it inside `Table.ColumnHeaderCell` makes the owning `Table.Column` sortable and toggles `Table.Root.sortDescriptor`.
128
+ - `Table.SortTrigger.children` can consume a `sortDirection` render state so the nested button can expose stateful labels or visuals without reading the root descriptor directly.
116
129
  - Resizing: `Table.ColumnResizer` is the only public opt-in for resizing. Rendering it inside a `Table.ColumnHeaderCell` enables resizing for the owning `Table.Column`.
117
130
  - Public input types: import the `Table*Props` types you need from `@human-kit/svelte-components/table` or the main package barrel instead of deriving contracts from component internals.
118
131
  - Internal normalized state: table context stores normalized column metadata internally as `TableColumnMetadata`. That metadata is not the public input contract for wrappers or consumers.
@@ -125,5 +138,6 @@ All public Table part prop types are exported from the table barrel, including `
125
138
  - `Table.Checkbox` can receive DOM focus directly while still participating in the table's roving-focus grid.
126
139
  - First-column body cells become `rowheader` when their associated column has `isRowHeader`.
127
140
  - Disabled rows remain rendered and non-selectable, but are skipped by focus navigation.
141
+ - `Table.SortTrigger` wires a nested trigger button, while the header cell remains the roving-focus target for arrow-key grid navigation.
128
142
  - Sort changes are mirrored into a polite live region so screen readers announce direction changes more reliably than `aria-sort` alone.
129
143
  - Column resize handles are keyboard accessible separators. Press `Enter` to enter resize mode, use the horizontal arrow keys to resize, `Home` to jump to the minimum width, `End` to auto-fit to content width, and press `Enter` again to exit resize mode while keeping focus on the handle.
@@ -7,21 +7,22 @@
7
7
  ### Table.Column
8
8
 
9
9
  Name: `Table.Column`
10
- Description: Logical metadata wrapper for a header column. It does not render DOM and is used to register stable column identity, sorting capability, row-header semantics, and width constraints.
10
+ Description: Logical metadata wrapper for a header column. It does not render DOM and is used to register stable column identity, row-header semantics, and width constraints.
11
11
 
12
12
  Public prop type: `TableColumnProps`
13
13
 
14
- | Prop | Type | Default | Description |
15
- | --------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------- |
16
- | `id` | `string` | `-` | Stable identifier for the column. |
17
- | `allowsSorting` | `boolean` | `false` | Enables sorting for the wrapped header cell. |
18
- | `isRowHeader` | `boolean` | `false` | Marks the associated body column as row-header cells. |
19
- | `textValue` | `string` | `undefined` | Optional spoken label used by `Table.Root` sort announcements when it should differ from `id`. |
20
- | `width` | `number \| \`${number}px\`` | `undefined` | Fixed column width. When set, the column keeps that width and user resize interactions are disabled. |
21
- | `defaultWidth` | `number \| \`${number}px\`` | `undefined` | Uncontrolled initial width hint for the column. The column remains user-resizable when a resizer is composed. |
22
- | `minWidth` | `number` | `undefined` | Minimum width in px enforced during resize interactions. |
23
- | `maxWidth` | `number` | `undefined` | Maximum width in px enforced during resize interactions. |
24
- | `children` | `Snippet` | `undefined` | Usually a single `Table.ColumnHeaderCell`. |
14
+ | Prop | Type | Default | Description |
15
+ | -------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------- |
16
+ | `id` | `string` | `-` | Stable identifier for the column. |
17
+ | `isRowHeader` | `boolean` | `false` | Marks the associated body column as row-header cells. |
18
+ | `textValue` | `string` | `undefined` | Optional spoken label used by `Table.Root` sort announcements when it should differ from `id`. |
19
+ | `width` | `number \| \`${number}px\`` | `undefined` | Fixed column width. When set, the column keeps that width and user resize interactions are disabled. |
20
+ | `defaultWidth` | `number \| \`${number}px\`` | `undefined` | Uncontrolled initial width hint for the column. The column remains user-resizable when a resizer is composed. |
21
+ | `minWidth` | `number` | `undefined` | Minimum width in px enforced during resize interactions. |
22
+ | `maxWidth` | `number` | `undefined` | Maximum width in px enforced during resize interactions. |
23
+ | `children` | `Snippet` | `undefined` | Usually a single `Table.ColumnHeaderCell`. |
24
+
25
+ `Table.SortTrigger` is the public sorting opt-in. Compose it inside `Table.ColumnHeaderCell` when the owning column should be sortable.
25
26
 
26
27
  `Table.ColumnResizer` is the public resize opt-in. Compose it inside `Table.ColumnHeaderCell` when the owning column should be resizable.
27
28
 
@@ -5,7 +5,6 @@
5
5
 
6
6
  let {
7
7
  id,
8
- allowsSorting = false,
9
8
  isRowHeader = false,
10
9
  textValue,
11
10
  width,
@@ -28,9 +27,6 @@
28
27
  get id() {
29
28
  return id;
30
29
  },
31
- get allowsSorting() {
32
- return allowsSorting;
33
- },
34
30
  get isHidden() {
35
31
  return table.isColumnHidden(id);
36
32
  },
@@ -58,7 +54,6 @@
58
54
  table.registerColumn({
59
55
  token,
60
56
  id,
61
- allowsSorting,
62
57
  isRowHeader,
63
58
  textValue,
64
59
  width,
@@ -7,7 +7,7 @@
7
7
  ### Table.ColumnHeaderCell
8
8
 
9
9
  Name: `Table.ColumnHeaderCell`
10
- Description: Focusable header cell for a column. It participates in roving focus and toggles sorting when the wrapping `Table.Column` allows it.
10
+ Description: Focusable header cell for a column. It participates in roving focus, exposes `aria-sort` when a nested `Table.SortTrigger` is present, and can host additional header actions.
11
11
 
12
12
  Public prop type: `TableColumnHeaderCellProps`
13
13
 
@@ -109,6 +109,11 @@
109
109
  void $layoutVersion;
110
110
  return table.getVisibleColumnIndexByToken(column.token);
111
111
  });
112
+ const isSortable = $derived.by(() => {
113
+ void $layoutVersion;
114
+ void $sortVersion;
115
+ return table.isColumnSortable(column.id);
116
+ });
112
117
  const headerTabIndex = $derived.by(() => {
113
118
  if (isHidden || focusDelegate) return undefined;
114
119
  return table.isCellTabStop(key) ? 0 : -1;
@@ -173,12 +178,7 @@
173
178
 
174
179
  function handleClick() {
175
180
  table.focusCellByKey(key);
176
- if (table.consumeHeaderClickSuppression()) {
177
- return;
178
- }
179
- if (column.allowsSorting) {
180
- table.toggleSort(column.id);
181
- }
181
+ table.consumeHeaderClickSuppression();
182
182
  }
183
183
 
184
184
  function handleMouseDown(event: MouseEvent) {
@@ -230,13 +230,6 @@
230
230
  event.preventDefault();
231
231
  table.moveToRowEnd();
232
232
  return;
233
- case 'Enter':
234
- case ' ':
235
- if (!column.allowsSorting) return;
236
- event.preventDefault();
237
- if (event.repeat) return;
238
- table.toggleSort(column.id);
239
- return;
240
233
  }
241
234
  }
242
235
  </script>
@@ -248,12 +241,12 @@
248
241
  tabindex={headerTabIndex}
249
242
  aria-colindex={!isHidden && visibleColumnIndex >= 0 ? visibleColumnIndex + 1 : undefined}
250
243
  aria-hidden={isHidden ? true : undefined}
251
- aria-sort={column.allowsSorting ? (sortDirection ?? 'none') : undefined}
244
+ aria-sort={isSortable ? (sortDirection ?? 'none') : undefined}
252
245
  data-focused={isFocused ? 'true' : undefined}
253
246
  data-focus-visible={isFocusVisible ? 'true' : undefined}
254
247
  data-focus-within={isFocusWithin ? 'true' : undefined}
255
248
  data-focus-visible-within={isFocusVisibleWithin ? 'true' : undefined}
256
- data-sortable={column.allowsSorting || undefined}
249
+ data-sortable={isSortable || undefined}
257
250
  data-sort-direction={sortDirection}
258
251
  data-column-index={visibleColumnIndex >= 0 ? visibleColumnIndex : undefined}
259
252
  style:box-sizing="border-box"
@@ -37,10 +37,12 @@
37
37
  </div>
38
38
  </Table.ColumnHeaderCell>
39
39
  </Table.Column>
40
- <Table.Column id="group" allowsSorting textValue="Group" minWidth={100} maxWidth={260}>
40
+ <Table.Column id="group" textValue="Group" minWidth={100} maxWidth={260}>
41
41
  <Table.ColumnHeaderCell data-testid="group-header-cell">
42
42
  <div class="flex items-center justify-between gap-3">
43
- <span>Group</span>
43
+ <Table.SortTrigger>
44
+ <button type="button" data-testid="group-sort-trigger">Group</button>
45
+ </Table.SortTrigger>
44
46
  <Table.ColumnResizer
45
47
  data-testid="group-resizer"
46
48
  class="inline-flex w-3 cursor-col-resize justify-center"
@@ -365,6 +365,12 @@
365
365
 
366
366
  const handlePointerCancel = (cancelEvent: PointerEvent) => {
367
367
  if (cancelEvent.pointerId !== pointerId) return;
368
+ cancelPointerResize();
369
+ };
370
+
371
+ function cancelPointerResize(event?: Event) {
372
+ event?.preventDefault();
373
+ event?.stopPropagation();
368
374
  clearRecentClickCandidate();
369
375
  if (animationFrameId !== null) {
370
376
  cancelAnimationFrame(animationFrameId);
@@ -376,21 +382,20 @@
376
382
  updateWidth(startWidth);
377
383
  }
378
384
  cleanupPointerListeners();
379
- };
385
+ }
380
386
 
381
387
  const handleWindowKeyDown = (keyEvent: KeyboardEvent) => {
382
388
  if (keyEvent.key !== 'Escape') return;
383
- keyEvent.preventDefault();
384
- keyEvent.stopPropagation();
385
- clearRecentClickCandidate();
386
- if (animationFrameId !== null) {
387
- cancelAnimationFrame(animationFrameId);
388
- animationFrameId = null;
389
- }
390
- if (didStartResize) {
391
- updateWidth(startWidth);
392
- }
393
- cleanupPointerListeners();
389
+ cancelPointerResize(keyEvent);
390
+ };
391
+
392
+ const handleContextMenu = (menuEvent: MouseEvent) => {
393
+ if (!didStartResize && !didDrag) return;
394
+ cancelPointerResize(menuEvent);
395
+ };
396
+
397
+ const handleWindowBlur = () => {
398
+ cancelPointerResize();
394
399
  };
395
400
 
396
401
  // With pointer capture active the browser routes all pointer events for
@@ -401,6 +406,8 @@
401
406
  window.addEventListener('pointerup', handlePointerUp);
402
407
  window.addEventListener('pointercancel', handlePointerCancel);
403
408
  window.addEventListener('keydown', handleWindowKeyDown, true);
409
+ window.addEventListener('contextmenu', handleContextMenu, true);
410
+ window.addEventListener('blur', handleWindowBlur);
404
411
  removeListeners = () => {
405
412
  if (animationFrameId !== null) {
406
413
  cancelAnimationFrame(animationFrameId);
@@ -415,6 +422,8 @@
415
422
  window.removeEventListener('pointerup', handlePointerUp);
416
423
  window.removeEventListener('pointercancel', handlePointerCancel);
417
424
  window.removeEventListener('keydown', handleWindowKeyDown, true);
425
+ window.removeEventListener('contextmenu', handleContextMenu, true);
426
+ window.removeEventListener('blur', handleWindowBlur);
418
427
  };
419
428
  }
420
429
 
@@ -1,5 +1,5 @@
1
1
  export * as Table from './index.parts.js';
2
- export type { TableBodyProps, TableCellProps, TableCheckboxIndicatorProps, TableCheckboxProps, TableColumnHeaderCellProps, TableColumnProps, TableColumnResizerProps, TableEmptyStateProps, TableFooterProps, TableHeaderProps, TableRowProps, TableRootProps } from './types.js';
2
+ export type { TableBodyProps, TableCellProps, TableCheckboxIndicatorProps, TableCheckboxProps, TableColumnHeaderCellProps, TableColumnProps, TableColumnResizerProps, TableSortTriggerRenderState, TableSortTriggerProps, TableEmptyStateProps, TableFooterProps, TableHeaderProps, TableRowProps, TableRootProps } from './types.js';
3
3
  export { default as TableRoot } from './root/table-root.svelte';
4
4
  export { default as TableColumn } from './column/table-column.svelte';
5
5
  export { default as TableHeader } from './header/table-header.svelte';
@@ -8,6 +8,7 @@ export { default as TableEmptyState } from './empty-state/table-empty-state.svel
8
8
  export { default as TableFooter } from './footer/table-footer.svelte';
9
9
  export { default as TableRow } from './row/table-row.svelte';
10
10
  export { default as TableColumnHeaderCell } from './column-header-cell/table-column-header-cell.svelte';
11
+ export { default as TableSortTrigger } from './sort-trigger/table-sort-trigger.svelte';
11
12
  export { default as TableColumnResizer } from './column-resizer/table-column-resizer.svelte';
12
13
  export { default as TableCheckbox } from './checkbox/table-checkbox.svelte';
13
14
  export { default as TableCheckboxIndicator } from './checkbox-indicator/table-checkbox-indicator.svelte';
@@ -7,6 +7,7 @@ export { default as TableEmptyState } from './empty-state/table-empty-state.svel
7
7
  export { default as TableFooter } from './footer/table-footer.svelte';
8
8
  export { default as TableRow } from './row/table-row.svelte';
9
9
  export { default as TableColumnHeaderCell } from './column-header-cell/table-column-header-cell.svelte';
10
+ export { default as TableSortTrigger } from './sort-trigger/table-sort-trigger.svelte';
10
11
  export { default as TableColumnResizer } from './column-resizer/table-column-resizer.svelte';
11
12
  export { default as TableCheckbox } from './checkbox/table-checkbox.svelte';
12
13
  export { default as TableCheckboxIndicator } from './checkbox-indicator/table-checkbox-indicator.svelte';
@@ -6,6 +6,7 @@ export { default as EmptyState } from './empty-state/table-empty-state.svelte';
6
6
  export { default as Footer } from './footer/table-footer.svelte';
7
7
  export { default as Row } from './row/table-row.svelte';
8
8
  export { default as ColumnHeaderCell } from './column-header-cell/table-column-header-cell.svelte';
9
+ export { default as SortTrigger } from './sort-trigger/table-sort-trigger.svelte';
9
10
  export { default as ColumnResizer } from './column-resizer/table-column-resizer.svelte';
10
11
  export { default as Checkbox } from './checkbox/table-checkbox.svelte';
11
12
  export { default as CheckboxIndicator } from './checkbox-indicator/table-checkbox-indicator.svelte';
@@ -6,6 +6,7 @@ export { default as EmptyState } from './empty-state/table-empty-state.svelte';
6
6
  export { default as Footer } from './footer/table-footer.svelte';
7
7
  export { default as Row } from './row/table-row.svelte';
8
8
  export { default as ColumnHeaderCell } from './column-header-cell/table-column-header-cell.svelte';
9
+ export { default as SortTrigger } from './sort-trigger/table-sort-trigger.svelte';
9
10
  export { default as ColumnResizer } from './column-resizer/table-column-resizer.svelte';
10
11
  export { default as Checkbox } from './checkbox/table-checkbox.svelte';
11
12
  export { default as CheckboxIndicator } from './checkbox-indicator/table-checkbox-indicator.svelte';
@@ -37,7 +37,6 @@ export type TableRowFocusEdge = 'start' | 'end';
37
37
  type TableColumnMetadata = {
38
38
  token: string;
39
39
  id: string;
40
- allowsSorting: boolean;
41
40
  isRowHeader: boolean;
42
41
  textValue?: string;
43
42
  width?: TableColumnWidth;
@@ -113,6 +112,8 @@ export type TableContext = {
113
112
  hasAuthoredColumnWidthSpec: (columnId: string) => boolean;
114
113
  getColumnMinWidth: (columnId: string) => number | undefined;
115
114
  getColumnMaxWidth: (columnId: string) => number | undefined;
115
+ registerColumnSortTrigger: (columnToken: string) => void;
116
+ unregisterColumnSortTrigger: (columnToken: string) => void;
116
117
  isColumnHidden: (columnId: string) => boolean;
117
118
  isColumnResizable: (columnId: string) => boolean;
118
119
  getColumnWidths: () => Map<string, TableColumnWidth>;
@@ -193,7 +194,6 @@ export type TableRowContext = {
193
194
  export type TableColumnContext = {
194
195
  token: string;
195
196
  id: string;
196
- allowsSorting: boolean;
197
197
  isHidden: boolean;
198
198
  isRowHeader: boolean;
199
199
  textValue?: string;
@@ -66,6 +66,7 @@ export function createTableContext(options = {}) {
66
66
  const columns = new Map();
67
67
  const columnIds = new Map();
68
68
  const columnOrder = [];
69
+ const columnsWithSortTriggers = new Set();
69
70
  const columnsWithResizers = new Set();
70
71
  let resizerLayoutReady = false;
71
72
  const columnWidths = new Map(options.initialColumnWidths ?? []);
@@ -95,6 +96,7 @@ export function createTableContext(options = {}) {
95
96
  const resizeVersion = writable(0);
96
97
  const instanceCounters = new Map();
97
98
  const selectionUnavailableDescriptionId = createInstanceToken('selection-unavailable');
99
+ setSelectedKeys(new Set(selectedKeys), selectionAnchorKey);
98
100
  function createInstanceToken(prefix) {
99
101
  const nextCount = (instanceCounters.get(prefix) ?? 0) + 1;
100
102
  instanceCounters.set(prefix, nextCount);
@@ -351,7 +353,6 @@ export function createTableContext(options = {}) {
351
353
  function sameColumnMetadata(left, right) {
352
354
  return (left.token === right.token &&
353
355
  left.id === right.id &&
354
- left.allowsSorting === right.allowsSorting &&
355
356
  left.isRowHeader === right.isRowHeader &&
356
357
  left.textValue === right.textValue &&
357
358
  left.width === right.width &&
@@ -396,6 +397,7 @@ export function createTableContext(options = {}) {
396
397
  columnIds.delete(column.id);
397
398
  }
398
399
  columns.delete(token);
400
+ columnsWithSortTriggers.delete(token);
399
401
  columnsWithResizers.delete(token);
400
402
  const index = columnOrder.indexOf(token);
401
403
  if (index >= 0) {
@@ -403,6 +405,17 @@ export function createTableContext(options = {}) {
403
405
  }
404
406
  notifyLayout();
405
407
  }
408
+ function registerColumnSortTrigger(columnToken) {
409
+ if (columnsWithSortTriggers.has(columnToken))
410
+ return;
411
+ columnsWithSortTriggers.add(columnToken);
412
+ notifyLayout();
413
+ }
414
+ function unregisterColumnSortTrigger(columnToken) {
415
+ if (!columnsWithSortTriggers.delete(columnToken))
416
+ return;
417
+ notifyLayout();
418
+ }
406
419
  function getOrderedColumnTokens() {
407
420
  if (orderedColumnTokensCache)
408
421
  return orderedColumnTokensCache;
@@ -1888,7 +1901,8 @@ export function createTableContext(options = {}) {
1888
1901
  notifySort();
1889
1902
  }
1890
1903
  function isColumnSortable(columnId) {
1891
- return getColumnRegistrationById(columnId)?.allowsSorting ?? false;
1904
+ const token = columnIds.get(columnId);
1905
+ return token ? columnsWithSortTriggers.has(token) : false;
1892
1906
  }
1893
1907
  function toggleSort(columnId) {
1894
1908
  if (!isColumnSortable(columnId))
@@ -1945,6 +1959,8 @@ export function createTableContext(options = {}) {
1945
1959
  },
1946
1960
  registerColumn,
1947
1961
  unregisterColumn,
1962
+ registerColumnSortTrigger,
1963
+ unregisterColumnSortTrigger,
1948
1964
  registerColumnResizer,
1949
1965
  unregisterColumnResizer,
1950
1966
  getColumnCount,
@@ -117,8 +117,20 @@
117
117
  <Table.Column id="email" isRowHeader textValue="Email">
118
118
  <Table.ColumnHeaderCell>Email</Table.ColumnHeaderCell>
119
119
  </Table.Column>
120
- <Table.Column id="group" allowsSorting textValue="Group">
121
- <Table.ColumnHeaderCell>Group</Table.ColumnHeaderCell>
120
+ <Table.Column id="group" textValue="Group">
121
+ <Table.ColumnHeaderCell>
122
+ <Table.SortTrigger>
123
+ {#snippet children({ sortDirection })}
124
+ <button
125
+ type="button"
126
+ data-testid="group-sort-trigger"
127
+ data-sort-direction-state={sortDirection ?? 'none'}
128
+ >
129
+ Group
130
+ </button>
131
+ {/snippet}
132
+ </Table.SortTrigger>
133
+ </Table.ColumnHeaderCell>
122
134
  </Table.Column>
123
135
  </Table.Row>
124
136
  </Table.Header>
@@ -0,0 +1,45 @@
1
+ <!-- markdownlint-disable MD024 -->
2
+
3
+ # Table.SortTrigger
4
+
5
+ ## API reference
6
+
7
+ ### Table.SortTrigger
8
+
9
+ Name: `Table.SortTrigger`
10
+ Description: Headless wrapper that makes the owning `Table.Column` sortable. It must be composed inside `Table.ColumnHeaderCell` and contain a `button` or `[role="button"]` child that acts as the actual trigger element.
11
+
12
+ Public prop type: `TableSortTriggerProps`
13
+
14
+ | Prop | Type | Default | Description |
15
+ | ---------- | --------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
16
+ | `children` | `Snippet<[TableSortTriggerRenderState]> \| Snippet` | `undefined` | Child content that includes the actual trigger button or role=button UI. The snippet receives the current `sortDirection`. |
17
+
18
+ ## Usage notes
19
+
20
+ - `Table.SortTrigger` must be used inside `Table.ColumnHeaderCell`.
21
+ - Rendering `Table.SortTrigger` inside `Table.ColumnHeaderCell` is enough to make the owning `Table.Column` sortable.
22
+ - The trigger resolves the active column from `Table.Column` context. It does not accept a separate `columnId` prop.
23
+ - The wrapper finds the first nested `button` or `[role="button"]` and wires sorting behavior plus sort-state data attributes onto that element.
24
+ - `children` can read the current `sortDirection` render state to adjust accessible labels or icons without reaching into `Table.Root.sortDescriptor`.
25
+ - The header cell remains the roving-focus target for arrow-key grid navigation; use `Tab` to move into the nested trigger button.
26
+ - Use separate controls for secondary header actions such as filter popovers or menus.
27
+
28
+ ```svelte
29
+ <Table.Column id="group" textValue="Group">
30
+ <Table.ColumnHeaderCell>
31
+ <Table.SortTrigger>
32
+ {#snippet children({ sortDirection })}
33
+ <button
34
+ type="button"
35
+ class="inline-flex items-center gap-2 rounded-sm"
36
+ aria-label={`Group sort button. ${sortDirection ?? 'not sorted'}.`}
37
+ >
38
+ <span>Sort group</span>
39
+ <SortIcon data-direction={sortDirection} />
40
+ </button>
41
+ {/snippet}
42
+ </Table.SortTrigger>
43
+ </Table.ColumnHeaderCell>
44
+ </Table.Column>
45
+ ```
@@ -0,0 +1,183 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { onDestroy, onMount } from 'svelte';
4
+ import { useTableCellContext, useTableColumnContext, useTableContext } from '../root/context';
5
+ import type { TableSortTriggerProps, TableSortTriggerRenderState } from '../types.js';
6
+ import {
7
+ shouldShowFocusVisible,
8
+ trackInteractionModality
9
+ } from '../../primitives/input-modality';
10
+
11
+ let { children }: TableSortTriggerProps = $props();
12
+
13
+ const table = useTableContext();
14
+ const column = useTableColumnContext();
15
+ const cell = useTableCellContext();
16
+ const sortVersion = table.sortVersion;
17
+
18
+ let wrapperRef = $state<HTMLElement | null>(null);
19
+ let activeTrigger = $state<HTMLElement | null>(null);
20
+
21
+ const sortDirection = $derived.by(() => {
22
+ void $sortVersion;
23
+ return table.getSortDirection(column.id);
24
+ });
25
+ const renderState = $derived.by<TableSortTriggerRenderState>(() => ({
26
+ sortDirection
27
+ }));
28
+
29
+ function getTriggerElement() {
30
+ if (!wrapperRef) return null;
31
+ return wrapperRef.querySelector<HTMLElement>('button, [role="button"]');
32
+ }
33
+
34
+ function syncTriggerMetadata(trigger: HTMLElement | null) {
35
+ if (!trigger) return;
36
+
37
+ if (trigger instanceof HTMLButtonElement && !trigger.hasAttribute('type')) {
38
+ trigger.type = 'button';
39
+ }
40
+
41
+ trigger.setAttribute('data-table-sort-trigger', 'true');
42
+ if (sortDirection) {
43
+ trigger.setAttribute('data-sorted', 'true');
44
+ trigger.setAttribute('data-sort-direction', sortDirection);
45
+ } else {
46
+ trigger.removeAttribute('data-sorted');
47
+ trigger.removeAttribute('data-sort-direction');
48
+ }
49
+ }
50
+
51
+ function refreshActiveTrigger() {
52
+ activeTrigger = getTriggerElement();
53
+ syncTriggerMetadata(activeTrigger);
54
+ }
55
+
56
+ $effect(() => {
57
+ table.registerColumnSortTrigger(column.token);
58
+ syncTriggerMetadata(activeTrigger);
59
+
60
+ return () => {
61
+ table.unregisterColumnSortTrigger(column.token);
62
+ };
63
+ });
64
+
65
+ function handleFocusIn(event: FocusEvent) {
66
+ const target = event.target instanceof HTMLElement ? event.target : activeTrigger;
67
+ table.setFocusedCell(cell.cellKey);
68
+ table.setFocusVisible(shouldShowFocusVisible(target ?? null));
69
+ }
70
+
71
+ function handleMouseDown(event: MouseEvent) {
72
+ const target = event.target as HTMLElement | null;
73
+ const trigger = target?.closest('button, [role="button"]') as HTMLElement | null;
74
+ if (!trigger || !wrapperRef?.contains(trigger)) return;
75
+
76
+ trackInteractionModality(event, trigger);
77
+ table.setFocusVisible(false);
78
+ event.stopPropagation();
79
+ }
80
+
81
+ function handleClick(event: MouseEvent) {
82
+ const target = event.target as HTMLElement | null;
83
+ const trigger = target?.closest('button, [role="button"]') as HTMLElement | null;
84
+ if (!trigger || !wrapperRef?.contains(trigger)) return;
85
+
86
+ activeTrigger = trigger;
87
+ syncTriggerMetadata(activeTrigger);
88
+ event.stopPropagation();
89
+ table.toggleSort(column.id);
90
+ }
91
+
92
+ function handleKeyDown(event: KeyboardEvent) {
93
+ const target = event.target as HTMLElement | null;
94
+ const trigger = target?.closest('button, [role="button"]') as HTMLElement | null;
95
+ if (!trigger || !wrapperRef?.contains(trigger)) return;
96
+
97
+ activeTrigger = trigger;
98
+ syncTriggerMetadata(activeTrigger);
99
+ trackInteractionModality(event, trigger);
100
+
101
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Home') {
102
+ event.preventDefault();
103
+ event.stopPropagation();
104
+ table.moveToGridStart();
105
+ return;
106
+ }
107
+
108
+ if ((event.ctrlKey || event.metaKey) && event.key === 'End') {
109
+ event.preventDefault();
110
+ event.stopPropagation();
111
+ table.moveToGridEnd();
112
+ return;
113
+ }
114
+
115
+ switch (event.key) {
116
+ case 'ArrowUp':
117
+ event.preventDefault();
118
+ event.stopPropagation();
119
+ table.moveFocus('up', {
120
+ shiftKey: event.shiftKey,
121
+ ctrlKey: event.ctrlKey,
122
+ metaKey: event.metaKey,
123
+ altKey: event.altKey
124
+ });
125
+ return;
126
+ case 'ArrowDown':
127
+ event.preventDefault();
128
+ event.stopPropagation();
129
+ table.moveFocus('down', {
130
+ shiftKey: event.shiftKey,
131
+ ctrlKey: event.ctrlKey,
132
+ metaKey: event.metaKey,
133
+ altKey: event.altKey
134
+ });
135
+ return;
136
+ case 'ArrowLeft':
137
+ event.preventDefault();
138
+ event.stopPropagation();
139
+ table.moveFocus('left');
140
+ return;
141
+ case 'ArrowRight':
142
+ event.preventDefault();
143
+ event.stopPropagation();
144
+ table.moveFocus('right');
145
+ return;
146
+ case 'Home':
147
+ event.preventDefault();
148
+ event.stopPropagation();
149
+ table.moveToRowStart();
150
+ return;
151
+ case 'End':
152
+ event.preventDefault();
153
+ event.stopPropagation();
154
+ table.moveToRowEnd();
155
+ return;
156
+ case 'Enter':
157
+ case ' ':
158
+ event.stopPropagation();
159
+ return;
160
+ }
161
+ }
162
+
163
+ onMount(() => {
164
+ refreshActiveTrigger();
165
+ wrapperRef?.addEventListener('focusin', handleFocusIn);
166
+ wrapperRef?.addEventListener('mousedown', handleMouseDown);
167
+ wrapperRef?.addEventListener('click', handleClick);
168
+ wrapperRef?.addEventListener('keydown', handleKeyDown);
169
+ });
170
+
171
+ onDestroy(() => {
172
+ wrapperRef?.removeEventListener('focusin', handleFocusIn);
173
+ wrapperRef?.removeEventListener('mousedown', handleMouseDown);
174
+ wrapperRef?.removeEventListener('click', handleClick);
175
+ wrapperRef?.removeEventListener('keydown', handleKeyDown);
176
+ });
177
+ </script>
178
+
179
+ <div bind:this={wrapperRef} style="display: contents;">
180
+ {#if children}
181
+ {@render (children as Snippet<[TableSortTriggerRenderState]>)(renderState)}
182
+ {/if}
183
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { TableSortTriggerProps } from '../types.js';
2
+ declare const TableSortTrigger: import("svelte").Component<TableSortTriggerProps, {}, "">;
3
+ type TableSortTrigger = ReturnType<typeof TableSortTrigger>;
4
+ export default TableSortTrigger;
@@ -1,9 +1,8 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- import type { TableColumnWidth, TableContext, TableDisabledBehavior, TableRowActionHandler, TableSelectionBehavior, TableSelectionKey, TableSelectionMode, TableSortDescriptor } from './root/context.js';
3
+ import type { TableColumnWidth, TableContext, TableDisabledBehavior, TableRowActionHandler, TableSelectionBehavior, TableSelectionKey, TableSelectionMode, TableSortDirection, TableSortDescriptor } from './root/context.js';
4
4
  export type TableColumnProps = {
5
5
  id: string;
6
- allowsSorting?: boolean;
7
6
  isRowHeader?: boolean;
8
7
  textValue?: string;
9
8
  width?: TableColumnWidth;
@@ -61,6 +60,12 @@ export type TableColumnHeaderCellProps = Omit<HTMLAttributes<HTMLTableCellElemen
61
60
  children?: Snippet;
62
61
  class?: string;
63
62
  };
63
+ export type TableSortTriggerRenderState = {
64
+ sortDirection: TableSortDirection | undefined;
65
+ };
66
+ export type TableSortTriggerProps = {
67
+ children?: Snippet<[TableSortTriggerRenderState]> | Snippet;
68
+ };
64
69
  export type TableColumnResizerProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
65
70
  step?: number;
66
71
  shiftStep?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-kit/svelte-components",
3
- "version": "1.0.0-alpha.19",
3
+ "version": "1.0.0-alpha.20",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",