@human-kit/svelte-components 1.0.0-alpha.15 → 1.0.0-alpha.17

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.
Files changed (29) hide show
  1. package/dist/table/PLAN.md +6 -6
  2. package/dist/table/README.md +4 -2
  3. package/dist/table/body/table-body.svelte +5 -0
  4. package/dist/table/cell/table-cell.svelte +17 -0
  5. package/dist/table/checkbox/README.md +1 -1
  6. package/dist/table/checkbox/table-checkbox.svelte +2 -15
  7. package/dist/table/checkbox-indicator/README.md +1 -1
  8. package/dist/table/column/README.md +11 -11
  9. package/dist/table/column-header-cell/table-column-header-cell.svelte +19 -16
  10. package/dist/table/column-resizer/table-column-resizer-fixed-width-test.svelte +57 -0
  11. package/dist/table/column-resizer/table-column-resizer-fixed-width-test.svelte.d.ts +3 -0
  12. package/dist/table/column-resizer/table-column-resizer-freeze-layout-test.svelte +2 -1
  13. package/dist/table/column-resizer/table-column-resizer-overflow-test.svelte +64 -0
  14. package/dist/table/column-resizer/table-column-resizer-overflow-test.svelte.d.ts +3 -0
  15. package/dist/table/column-resizer/table-column-resizer-padded-container-test.svelte +67 -0
  16. package/dist/table/column-resizer/table-column-resizer-padded-container-test.svelte.d.ts +3 -0
  17. package/dist/table/column-resizer/table-column-resizer-selection-column-test.svelte +2 -1
  18. package/dist/table/column-resizer/table-column-resizer-test.svelte +3 -3
  19. package/dist/table/column-resizer/table-column-resizer-three-column-relative-test.svelte +64 -0
  20. package/dist/table/column-resizer/table-column-resizer-three-column-relative-test.svelte.d.ts +3 -0
  21. package/dist/table/column-resizer/table-column-resizer.svelte +49 -56
  22. package/dist/table/root/README.md +12 -12
  23. package/dist/table/root/context.d.ts +13 -7
  24. package/dist/table/root/context.js +363 -38
  25. package/dist/table/root/table-root.svelte +116 -17
  26. package/dist/table/types.d.ts +4 -4
  27. package/dist/table/utils/visually-hidden-style.d.ts +1 -0
  28. package/dist/table/utils/visually-hidden-style.js +1 -0
  29. package/package.json +1 -1
@@ -1,7 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy } from 'svelte';
3
- import { getTableCellContext, useTableColumnContext, useTableContext } from '../root/context';
3
+ import {
4
+ DEFAULT_TABLE_COLUMN_MIN_WIDTH,
5
+ getTableCellContext,
6
+ useTableColumnContext,
7
+ useTableContext
8
+ } from '../root/context';
4
9
  import type { TableColumnResizerProps } from '../types.js';
10
+ import { visuallyHiddenStyle } from '../utils/visually-hidden-style';
5
11
  import {
6
12
  shouldShowFocusVisible,
7
13
  trackInteractionModality
@@ -59,7 +65,7 @@
59
65
  });
60
66
  const minWidth = $derived.by(() => {
61
67
  void $widthVersion;
62
- return table.getColumnMinWidth(column.id) ?? 75;
68
+ return table.getColumnMinWidth(column.id) ?? DEFAULT_TABLE_COLUMN_MIN_WIDTH;
63
69
  });
64
70
  const maxWidth = $derived.by(() => {
65
71
  void $widthVersion;
@@ -267,16 +273,16 @@
267
273
  suppressNextDoubleClickAutofit = false;
268
274
 
269
275
  stopKeyboardResizeMode();
270
- table.startColumnResize(column.id);
271
276
 
272
- const th = element?.closest('th') as HTMLElement | null;
273
- const tableEl = th?.closest('table') as HTMLTableElement | null;
274
- const startX = event.clientX;
275
277
  const startWidth = table.getColumnWidth(column.id) ?? getHeaderWidth();
276
278
  const pointerId = event.pointerId;
277
279
  const isRTL = isRightToLeft();
280
+ const pointerStartClientX = event.clientX;
278
281
  let didDrag = false;
279
- let latestClientX = startX;
282
+ let didStartResize = false;
283
+ let latestClientX = event.clientX;
284
+ let previousClientX = event.clientX;
285
+ let dragWidth = startWidth;
280
286
  let animationFrameId: number | null = null;
281
287
 
282
288
  // Capture the pointer so we receive move/up events even if the cursor
@@ -298,30 +304,27 @@
298
304
  return clamped;
299
305
  }
300
306
 
301
- function applyTemporaryWidthToDOM(width: number) {
302
- if (th) th.style.width = `${width}px`;
303
- if (tableEl) {
304
- const allThs = tableEl.querySelectorAll<HTMLElement>('thead th[style*="width"]');
305
- let total = 0;
306
- for (const cell of allThs) {
307
- total += parseFloat(cell.style.width) || 0;
308
- }
309
- if (total > 0) {
310
- tableEl.style.width = `${total}px`;
311
- tableEl.style.minWidth = '0';
312
- }
313
- }
314
- }
315
-
316
307
  function flushPendingPointerMove() {
317
308
  if (animationFrameId !== null) {
318
309
  cancelAnimationFrame(animationFrameId);
319
310
  animationFrameId = null;
320
311
  }
321
312
 
322
- const direction = isRTL ? -1 : 1;
323
- const delta = (latestClientX - startX) * positionScale * direction;
324
- const nextWidth = clampWidth(startWidth + delta);
313
+ if (!didStartResize && latestClientX === pointerStartClientX) {
314
+ return;
315
+ }
316
+
317
+ if (!didStartResize) {
318
+ table.startColumnResize(column.id);
319
+ didStartResize = true;
320
+ previousClientX = pointerStartClientX;
321
+ }
322
+
323
+ const pointerDelta = latestClientX - previousClientX;
324
+ previousClientX = latestClientX;
325
+ const widthDelta = isRTL ? -pointerDelta : pointerDelta;
326
+ dragWidth = clampWidth(dragWidth + widthDelta);
327
+ const nextWidth = dragWidth;
325
328
  updateWidth(nextWidth);
326
329
  }
327
330
 
@@ -333,30 +336,14 @@
333
336
  });
334
337
  }
335
338
 
336
- // Measure position compensation factor.
337
- // In centered/flex layouts, growing a column shifts the table's left edge,
338
- // so the handle moves less than the mouse delta. We detect this by applying
339
- // a 1px test change and measuring how much the <th> left edge drifts.
340
- // NOTE: applyTemporaryWidthToDOM intentionally mutates the DOM synchronously
341
- // outside Svelte's reactive cycle. The mutation is immediately reverted
342
- // within the same microtask, so no observer or $effect will see it.
343
- let positionScale = 1;
344
- if (th) {
345
- const leftBefore = th.getBoundingClientRect().left;
346
- applyTemporaryWidthToDOM(startWidth + 1);
347
- const leftAfter = th.getBoundingClientRect().left;
348
- applyTemporaryWidthToDOM(startWidth);
349
- const drift = leftBefore - leftAfter;
350
- if (drift > 0.01 && drift < 0.99) {
351
- positionScale = 1 / (1 - drift);
352
- }
353
- }
354
-
355
339
  const handlePointerMove = (moveEvent: PointerEvent) => {
356
340
  if (moveEvent.pointerId !== pointerId) return;
357
341
  moveEvent.preventDefault();
358
- didDrag = true;
359
342
  latestClientX = moveEvent.clientX;
343
+ if (!didStartResize && latestClientX === pointerStartClientX) {
344
+ return;
345
+ }
346
+ didDrag = true;
360
347
  schedulePointerMove();
361
348
  };
362
349
 
@@ -385,7 +372,9 @@
385
372
  }
386
373
  // Treat system-initiated cancellation the same as Escape:
387
374
  // restore the width the column had before the drag started.
388
- updateWidth(startWidth);
375
+ if (didStartResize) {
376
+ updateWidth(startWidth);
377
+ }
389
378
  cleanupPointerListeners();
390
379
  };
391
380
 
@@ -398,7 +387,9 @@
398
387
  cancelAnimationFrame(animationFrameId);
399
388
  animationFrameId = null;
400
389
  }
401
- updateWidth(startWidth);
390
+ if (didStartResize) {
391
+ updateWidth(startWidth);
392
+ }
402
393
  cleanupPointerListeners();
403
394
  };
404
395
 
@@ -428,6 +419,7 @@
428
419
  }
429
420
 
430
421
  function handleClick(event: MouseEvent) {
422
+ if (!isResizable) return;
431
423
  event.preventDefault();
432
424
  event.stopPropagation();
433
425
  }
@@ -554,18 +546,19 @@
554
546
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
555
547
  <div
556
548
  bind:this={element}
557
- role="separator"
549
+ role={isResizable ? 'separator' : undefined}
558
550
  tabindex={isResizable ? 0 : undefined}
559
551
  class={className}
560
- aria-label={accessibleLabel}
561
- aria-orientation="vertical"
562
- aria-valuenow={currentWidth ?? undefined}
563
- aria-valuemin={minWidth}
564
- aria-valuemax={maxWidth}
565
- aria-valuetext={accessibleValueText}
552
+ aria-label={isResizable ? accessibleLabel : undefined}
553
+ aria-orientation={isResizable ? 'vertical' : undefined}
554
+ aria-valuenow={isResizable ? (currentWidth ?? undefined) : undefined}
555
+ aria-valuemin={isResizable ? minWidth : undefined}
556
+ aria-valuemax={isResizable ? maxWidth : undefined}
557
+ aria-valuetext={isResizable ? accessibleValueText : undefined}
566
558
  data-focused={isFocused ? 'true' : undefined}
567
559
  data-focus-visible={isFocusVisible ? 'true' : undefined}
568
560
  data-resizing={isResizing ? 'true' : undefined}
561
+ data-resizable={isResizable ? 'true' : undefined}
569
562
  data-table-column-resizer="true"
570
563
  data-resizable-direction="right"
571
564
  style:position="absolute"
@@ -579,6 +572,7 @@
579
572
  style:align-items="center"
580
573
  style:justify-content="center"
581
574
  style:user-select="none"
575
+ style:pointer-events={isResizable ? 'auto' : 'none'}
582
576
  style:touch-action="none"
583
577
  onpointerdown={handlePointerDown}
584
578
  ondblclick={handleDoubleClick}
@@ -601,8 +595,7 @@
601
595
  role="status"
602
596
  aria-live="polite"
603
597
  aria-atomic="true"
604
- style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;"
605
- >{resizeAnnouncement}</span
598
+ style={visuallyHiddenStyle}>{resizeAnnouncement}</span
606
599
  >
607
600
  </div>
608
601
  {/if}
@@ -23,16 +23,16 @@ Public prop type: `TableRootProps`
23
23
  | `defaultSelectedKeys` | `Iterable<string \| number>` | `undefined` | Initial selected row ids for uncontrolled usage. When `selectionMode` later becomes `none`, the internal selection is cleared. |
24
24
  | `sortDescriptor` | `{ column: string; direction: 'ascending' \| 'descending' }` | `undefined` | Controlled sort state. Supports `bind:sortDescriptor`. Setting it back to `undefined` clears the sort. |
25
25
  | `defaultSortDescriptor` | `{ column: string; direction: 'ascending' \| 'descending' }` | `undefined` | Initial sort state for uncontrolled usage. |
26
- | `columnWidths` | `Map<string, number>` | `undefined` | Controlled column width state in px. Supports `bind:columnWidths`. |
27
- | `defaultColumnWidths` | `Iterable<[string, number]>` | `undefined` | Initial uncontrolled column widths in px. |
26
+ | `columnWidths` | `Map<string, TableColumnWidth>` | `undefined` | Controlled column width state. Supports px, `%`, and `fr` specs via `bind:columnWidths`. |
27
+ | `defaultColumnWidths` | `Iterable<[string, TableColumnWidth]>` | `undefined` | Initial uncontrolled column widths using px, `%`, or `fr` specs. |
28
28
  | `disabledKeys` | `Iterable<string \| number>` | `undefined` | Row ids that should be non-selectable. |
29
29
  | `onRowAction` | `(id: string \| number) => void` | `undefined` | Called when a row action is triggered for an actionable row. |
30
30
  | `onSelectionChange` | `(keys: Set<string \| number>) => void` | `undefined` | Called when row selection changes. |
31
31
  | `onSortChange` | `(descriptor) => void` | `undefined` | Called when sortable header state changes. |
32
- | `onColumnWidthsChange` | `(widths: Map<string, number>) => void` | `undefined` | Called when resize interactions update column widths. |
32
+ | `onColumnWidthsChange` | `(widths: Map<string, TableColumnWidth>) => void` | `undefined` | Called when resize interactions update managed column widths. |
33
33
  | `onHiddenColumnsChange` | `(columnIds: string[]) => void` | `undefined` | Called when hidden column state changes. |
34
34
  | `onColumnResizeStart` | `(columnId: string) => void` | `undefined` | Called when a column resize drag starts. |
35
- | `onColumnResizeEnd` | `(widths: Map<string, number>) => void` | `undefined` | Called when a column resize drag ends. |
35
+ | `onColumnResizeEnd` | `(widths: Map<string, TableColumnWidth>) => void` | `undefined` | Called when a column resize drag ends. |
36
36
  | `aria-label` | `string` | `undefined` | Accessible name when no external label is present. |
37
37
  | `aria-labelledby` | `string` | `undefined` | Id reference for an external label. |
38
38
  | `class` | `string` | `''` | Class names for the root table element. |
@@ -45,14 +45,14 @@ Public prop type: `TableRootProps`
45
45
  Name: Selection and sorting notes
46
46
  Description: Current v1 interaction constraints that affect consumer expectations.
47
47
 
48
- | Topic | Behavior |
49
- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
50
- | `selectionMode="none"` | Clears existing row selection internally and prevents further row selection until another mode is set. |
51
- | Text selection and copy | Browser-native text selection and `Ctrl+C` behavior are preserved; the component does not implement custom clipboard logic. |
52
- | `replace` mode blur | Clicking or tabbing outside the table clears focus state but does not clear row selection. |
53
- | Sort announcements | Sort changes are mirrored into a polite live region. Use `Table.Column.textValue` when the announced label should differ from the column `id`. |
54
- | Column resizing | Width state is normalized to px values and a `Table.ColumnResizer` only affects the `Table.Column` it is composed within. |
55
- | Row edge focus | In body rows, `ArrowLeft` before the first cell and `ArrowRight` after the last cell move focus onto the row itself; `ArrowUp` / `ArrowDown` keep that row-edge focus aligned across rows, repeating the same horizontal arrow loops back into the opposite edge cell, and `Home` / `End` jump to the first or last focusable body row while row focus is active. |
48
+ | Topic | Behavior |
49
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `selectionMode="none"` | Clears existing row selection internally and prevents further row selection until another mode is set. |
51
+ | Text selection and copy | Browser-native text selection and `Ctrl+C` behavior are preserved; the component does not implement custom clipboard logic. |
52
+ | `replace` mode blur | Clicking or tabbing outside the table clears focus state but does not clear row selection. |
53
+ | Sort announcements | Sort changes are mirrored into a polite live region. Use `Table.Column.textValue` when the announced label should differ from the column `id`. |
54
+ | Column resizing | Width state accepts px, `%`, and `fr` specs. Before interaction, unspecified columns behave like implicit flexible space. On the first real resize, visible columns are converted to px; the trailing column absorbs the delta until its minimum width, and additional growth overflows the table horizontally. A `Table.ColumnResizer` only affects the `Table.Column` it is composed within. |
55
+ | Row edge focus | In body rows, `ArrowLeft` before the first cell and `ArrowRight` after the last cell move focus onto the row itself; `ArrowUp` / `ArrowDown` keep that row-edge focus aligned across rows, repeating the same horizontal arrow loops back into the opposite edge cell, and `Home` / `End` jump to the first or last focusable body row while row focus is active. |
56
56
 
57
57
  ### Context utilities
58
58
 
@@ -17,7 +17,8 @@ export type TableSortDescriptor = {
17
17
  column: string;
18
18
  direction: TableSortDirection;
19
19
  };
20
- export type TableColumnWidth = number | `${number}px`;
20
+ export type TableColumnWidth = number | `${number}px` | `${number}%` | `${number}fr`;
21
+ export declare const DEFAULT_TABLE_COLUMN_MIN_WIDTH = 60;
21
22
  export type TableGridCoord = {
22
23
  row: number;
23
24
  col: number;
@@ -58,16 +59,16 @@ export type CreateTableContextOptions = {
58
59
  disallowEmptySelection?: boolean;
59
60
  initialSelectedKeys?: Iterable<TableSelectionKey>;
60
61
  initialSortDescriptor?: TableSortDescriptor;
61
- initialColumnWidths?: Iterable<readonly [string, number]>;
62
+ initialColumnWidths?: Iterable<readonly [string, TableColumnWidth]>;
62
63
  initialHiddenColumns?: Iterable<string>;
63
64
  disabledKeys?: Iterable<TableSelectionKey>;
64
65
  onRowAction?: TableRowActionHandler;
65
66
  onSelectionChange?: (keys: Set<TableSelectionKey>) => void;
66
67
  onSortChange?: (descriptor: TableSortDescriptor | undefined) => void;
67
- onColumnWidthsChange?: (widths: Map<string, number>) => void;
68
+ onColumnWidthsChange?: (widths: Map<string, TableColumnWidth>) => void;
68
69
  onHiddenColumnsChange?: (columnIds: string[]) => void;
69
70
  onColumnResizeStart?: (columnId: string) => void;
70
- onColumnResizeEnd?: (widths: Map<string, number>) => void;
71
+ onColumnResizeEnd?: (widths: Map<string, TableColumnWidth>) => void;
71
72
  };
72
73
  export type TableContext = {
73
74
  layoutVersion: Readable<number>;
@@ -98,13 +99,17 @@ export type TableContext = {
98
99
  getVisibleColumnIndexByToken: (token: string) => number;
99
100
  getColumnTextValue: (columnId: string) => string | undefined;
100
101
  getColumnWidth: (columnId: string) => number | undefined;
102
+ getColumnWidthStyle: (columnId: string) => string | undefined;
103
+ hasAuthoredColumnWidthSpec: (columnId: string) => boolean;
101
104
  getColumnMinWidth: (columnId: string) => number | undefined;
102
105
  getColumnMaxWidth: (columnId: string) => number | undefined;
103
106
  isColumnHidden: (columnId: string) => boolean;
104
107
  isColumnResizable: (columnId: string) => boolean;
105
- getColumnWidths: () => Map<string, number>;
106
- getVisibleColumnWidths: () => Map<string, number>;
107
- setColumnWidths: (widths?: Iterable<readonly [string, number]>) => void;
108
+ getColumnWidths: () => Map<string, TableColumnWidth>;
109
+ getVisibleColumnWidths: () => Map<string, TableColumnWidth>;
110
+ getResolvedVisibleColumnWidths: () => Map<string, number>;
111
+ hasRelativeVisibleColumnWidths: () => boolean;
112
+ setColumnWidths: (widths?: Iterable<readonly [string, TableColumnWidth]>) => void;
108
113
  setColumnWidth: (columnId: string, width: number) => void;
109
114
  setHiddenColumns: (columnIds?: Iterable<string>) => void;
110
115
  measureColumnContentWidth: (columnId: string) => number | undefined;
@@ -115,6 +120,7 @@ export type TableContext = {
115
120
  hasResizableColumns: () => boolean;
116
121
  registerRow: (row: TableRowRegistration) => void;
117
122
  unregisterRow: (token: string) => void;
123
+ markBodyRowsInitialized: () => void;
118
124
  getHeaderRowCount: () => number;
119
125
  getBodyRowCount: () => number;
120
126
  isRowSelected: (id: TableSelectionKey | undefined) => boolean;