@astryxdesign/core 0.1.1 → 0.1.2-canary.2581eb4

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 (189) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +119 -1
  3. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Banner/Banner.d.ts +7 -0
  7. package/dist/Banner/Banner.d.ts.map +1 -1
  8. package/dist/Banner/Banner.js +9 -2
  9. package/dist/Button/Button.d.ts +12 -2
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +51 -13
  12. package/dist/Calendar/Calendar.d.ts +5 -4
  13. package/dist/Calendar/Calendar.d.ts.map +1 -1
  14. package/dist/Calendar/Calendar.js +6 -1
  15. package/dist/Calendar/index.d.ts +1 -1
  16. package/dist/Calendar/index.d.ts.map +1 -1
  17. package/dist/DateTimeInput/DateTimeInput.d.ts +7 -2
  18. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  19. package/dist/DateTimeInput/DateTimeInput.js +5 -4
  20. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  21. package/dist/EmptyState/EmptyState.js +7 -1
  22. package/dist/HoverCard/HoverCard.d.ts +2 -2
  23. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/HoverCard.js +18 -6
  25. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  26. package/dist/HoverCard/useHoverCard.js +7 -3
  27. package/dist/Kbd/Kbd.d.ts.map +1 -1
  28. package/dist/Kbd/Kbd.js +1 -1
  29. package/dist/Layer/anchorName.d.ts +20 -0
  30. package/dist/Layer/anchorName.d.ts.map +1 -0
  31. package/dist/Layer/anchorName.js +36 -0
  32. package/dist/Layer/useLayer.d.ts +19 -0
  33. package/dist/Layer/useLayer.d.ts.map +1 -1
  34. package/dist/Layer/useLayer.js +15 -6
  35. package/dist/Link/Link.d.ts +1 -1
  36. package/dist/Link/Link.js +2 -2
  37. package/dist/Link/LinkProvider.d.ts +1 -0
  38. package/dist/Link/LinkProvider.d.ts.map +1 -1
  39. package/dist/Link/LinkProvider.js +1 -0
  40. package/dist/Markdown/Markdown.d.ts.map +1 -1
  41. package/dist/Markdown/Markdown.js +13 -3
  42. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  43. package/dist/MobileNav/MobileNav.js +13 -0
  44. package/dist/Pagination/Pagination.d.ts.map +1 -1
  45. package/dist/Pagination/Pagination.js +31 -27
  46. package/dist/Slider/Slider.js +2 -2
  47. package/dist/Table/BaseTable.d.ts.map +1 -1
  48. package/dist/Table/BaseTable.js +27 -8
  49. package/dist/Table/Table.d.ts.map +1 -1
  50. package/dist/Table/Table.js +30 -7
  51. package/dist/Table/TableRow.d.ts +6 -1
  52. package/dist/Table/TableRow.d.ts.map +1 -1
  53. package/dist/Table/TableRow.js +15 -7
  54. package/dist/Table/index.d.ts +3 -1
  55. package/dist/Table/index.d.ts.map +1 -1
  56. package/dist/Table/index.js +1 -0
  57. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  58. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  59. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  60. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  61. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  62. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +369 -0
  63. package/dist/Table/types.d.ts +95 -5
  64. package/dist/Table/types.d.ts.map +1 -1
  65. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  66. package/dist/Table/useBaseTablePlugins.js +1 -1
  67. package/dist/Text/text.stylex.d.ts +2 -2
  68. package/dist/Text/text.stylex.js +2 -2
  69. package/dist/Timestamp/Timestamp.d.ts +1 -1
  70. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  71. package/dist/Timestamp/Timestamp.js +28 -3
  72. package/dist/ToggleButton/ToggleButton.d.ts +7 -9
  73. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  74. package/dist/ToggleButton/ToggleButton.js +34 -57
  75. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  76. package/dist/Tokenizer/Tokenizer.js +1 -1
  77. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  78. package/dist/Tooltip/useTooltip.js +1 -0
  79. package/dist/astryx.css +30 -1
  80. package/dist/astryx.umd.js +147 -0
  81. package/dist/astryx.umd.js.map +7 -0
  82. package/dist/theme/generateThemeRules.js +1 -1
  83. package/dist/theme/syntax/SyntaxTheme.d.ts +1 -1
  84. package/dist/theme/syntax/SyntaxTheme.js +1 -1
  85. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  86. package/dist/theme/syntax/defineSyntaxTheme.js +2 -2
  87. package/dist/theme/syntax/presets.d.ts +1 -1
  88. package/dist/theme/syntax/presets.d.ts.map +1 -1
  89. package/dist/theme/syntax/presets.js +1 -1
  90. package/dist/theme/syntax/tokens.d.ts +1 -1
  91. package/dist/theme/syntax/tokens.js +1 -1
  92. package/dist/theme/types.d.ts +1 -1
  93. package/dist/utils/dateTypes.d.ts +12 -0
  94. package/dist/utils/dateTypes.d.ts.map +1 -1
  95. package/dist/utils/dateTypes.js +41 -1
  96. package/package.json +6 -2
  97. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  98. package/src/Banner/Banner.doc.mjs +8 -0
  99. package/src/Banner/Banner.test.tsx +16 -7
  100. package/src/Banner/Banner.tsx +9 -2
  101. package/src/Button/Button.doc.mjs +6 -0
  102. package/src/Button/Button.test.tsx +105 -11
  103. package/src/Button/Button.tsx +73 -5
  104. package/src/Calendar/Calendar.doc.mjs +4 -4
  105. package/src/Calendar/Calendar.test.tsx +21 -26
  106. package/src/Calendar/Calendar.tsx +21 -5
  107. package/src/Calendar/index.ts +1 -0
  108. package/src/Chat/ChatComposer.doc.mjs +3 -0
  109. package/src/Chat/ChatComposerDrawer.doc.mjs +12 -0
  110. package/src/CheckboxInput/CheckboxInput.doc.mjs +8 -0
  111. package/src/ClickableCard/ClickableCard.doc.mjs +15 -0
  112. package/src/DateTimeInput/DateTimeInput.doc.mjs +11 -3
  113. package/src/DateTimeInput/DateTimeInput.test.tsx +23 -3
  114. package/src/DateTimeInput/DateTimeInput.tsx +13 -10
  115. package/src/Dialog/Dialog.doc.mjs +2 -2
  116. package/src/EmptyState/EmptyState.test.tsx +4 -2
  117. package/src/EmptyState/EmptyState.tsx +6 -2
  118. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  119. package/src/HoverCard/HoverCard.test.tsx +189 -2
  120. package/src/HoverCard/HoverCard.tsx +20 -16
  121. package/src/HoverCard/useHoverCard.tsx +13 -10
  122. package/src/Icon/Icon.doc.mjs +8 -0
  123. package/src/Kbd/Kbd.tsx +2 -9
  124. package/src/Layer/anchorName.test.ts +146 -0
  125. package/src/Layer/anchorName.ts +46 -0
  126. package/src/Layer/useLayer.doc.mjs +7 -2
  127. package/src/Layer/useLayer.tsx +33 -9
  128. package/src/Layout/Layout.doc.mjs +1 -1
  129. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  130. package/src/Link/Link.test.tsx +2 -2
  131. package/src/Link/Link.tsx +3 -3
  132. package/src/Link/LinkProvider.tsx +1 -0
  133. package/src/List/List.doc.mjs +7 -0
  134. package/src/Markdown/Markdown.doc.mjs +2 -0
  135. package/src/Markdown/Markdown.test.tsx +17 -26
  136. package/src/Markdown/Markdown.tsx +16 -6
  137. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  138. package/src/MobileNav/MobileNav.tsx +13 -0
  139. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  140. package/src/OverflowList/OverflowList.doc.mjs +17 -0
  141. package/src/Pagination/Pagination.test.tsx +137 -13
  142. package/src/Pagination/Pagination.tsx +33 -28
  143. package/src/ProgressBar/ProgressBar.test.tsx +13 -3
  144. package/src/Resizable/Resizable.doc.mjs +1 -1
  145. package/src/Section/Section.doc.mjs +11 -0
  146. package/src/SelectableCard/SelectableCard.doc.mjs +15 -0
  147. package/src/Selector/Selector.doc.mjs +4 -0
  148. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  149. package/src/Slider/Slider.doc.mjs +4 -0
  150. package/src/Slider/Slider.test.tsx +3 -16
  151. package/src/Slider/Slider.tsx +1 -1
  152. package/src/TabList/TabList.doc.mjs +6 -0
  153. package/src/Table/BaseTable.tsx +51 -24
  154. package/src/Table/Table.doc.mjs +12 -0
  155. package/src/Table/Table.test.tsx +29 -30
  156. package/src/Table/Table.tsx +22 -1
  157. package/src/Table/TableRow.tsx +37 -7
  158. package/src/Table/index.ts +3 -0
  159. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  160. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  161. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +407 -0
  162. package/src/Table/types.ts +101 -4
  163. package/src/Table/useBaseTablePlugins.ts +1 -0
  164. package/src/Table/useTableColumnResize.doc.mjs +96 -0
  165. package/src/Table/useTableStickyColumns.doc.mjs +59 -0
  166. package/src/Text/Heading.doc.mjs +15 -2
  167. package/src/Text/Text.doc.mjs +7 -1
  168. package/src/Text/text.stylex.ts +2 -2
  169. package/src/Timestamp/Timestamp.doc.mjs +9 -0
  170. package/src/Timestamp/Timestamp.test.tsx +44 -18
  171. package/src/Timestamp/Timestamp.tsx +30 -10
  172. package/src/ToggleButton/ToggleButton.doc.mjs +9 -3
  173. package/src/ToggleButton/ToggleButton.test.tsx +39 -18
  174. package/src/ToggleButton/ToggleButton.tsx +46 -83
  175. package/src/Tokenizer/Tokenizer.tsx +4 -1
  176. package/src/Toolbar/Toolbar.doc.mjs +65 -5
  177. package/src/Tooltip/Tooltip.test.tsx +13 -0
  178. package/src/Tooltip/useTooltip.tsx +2 -3
  179. package/src/hooks/useImageMode.test.ts +133 -0
  180. package/src/theme/derivedVarRegistry.test.ts +4 -0
  181. package/src/theme/generateThemeRules.test.ts +7 -8
  182. package/src/theme/generateThemeRules.ts +1 -1
  183. package/src/theme/syntax/SyntaxTheme.tsx +1 -1
  184. package/src/theme/syntax/defineSyntaxTheme.ts +20 -11
  185. package/src/theme/syntax/presets.ts +5 -2
  186. package/src/theme/syntax/tokens.ts +1 -1
  187. package/src/theme/types.ts +1 -1
  188. package/src/utils/dateTypes.test.ts +40 -0
  189. package/src/utils/dateTypes.ts +40 -0
@@ -26,6 +26,7 @@ import type {
26
26
  HeaderCellRenderProps,
27
27
  BodyRowRenderProps,
28
28
  BodyCellRenderProps,
29
+ ScrollWrapperRenderProps,
29
30
  TableRowComponentProps,
30
31
  TableCellComponentProps,
31
32
  TableHeaderCellComponentProps,
@@ -152,22 +153,27 @@ function TableRowInner<T extends Record<string, unknown>>({
152
153
  CellComponent,
153
154
  }: TableRowProps<T>): ReactElement {
154
155
  // Build cells first
155
- const cells = columns.map(col => {
156
+ const cells = columns.map((col, columnIndex) => {
156
157
  // Apply column alignment to body cells
157
158
  const initialCellHtmlProps: Record<string, unknown> = {};
158
159
  if (col.align) {
159
160
  initialCellHtmlProps.style = {textAlign: col.align};
160
161
  }
161
162
 
163
+ const initialBodyCellRenderProps: BodyCellRenderProps = {
164
+ htmlProps: initialCellHtmlProps,
165
+ styles: [],
166
+ columnIndex,
167
+ columns: columns as ReadonlyArray<TableColumn<Record<string, unknown>>>,
168
+ };
162
169
  const cellRenderProps = applyPlugins(
163
170
  plugins,
164
171
  p => p.transformBodyCell,
165
- {
166
- htmlProps: initialCellHtmlProps,
167
- styles: [],
168
- } satisfies BodyCellRenderProps,
172
+ initialBodyCellRenderProps,
169
173
  col,
170
174
  item,
175
+ columnIndex,
176
+ columns,
171
177
  );
172
178
 
173
179
  const isDefaultRenderer = !col.renderCell;
@@ -313,8 +319,7 @@ function BaseTableInner<T extends Record<string, unknown>>({
313
319
  // Use stable empty array when no plugins provided
314
320
  const plugins = pluginsProp ?? (EMPTY_PLUGINS as TablePlugin<T>[]);
315
321
 
316
- const RowComponent =
317
- TableRow as React.ComponentType<TableRowComponentProps>;
322
+ const RowComponent = TableRow as React.ComponentType<TableRowComponentProps>;
318
323
  const CellComponent =
319
324
  TableCell as React.ComponentType<TableCellComponentProps>;
320
325
  const HeaderCellComponent =
@@ -354,7 +359,7 @@ function BaseTableInner<T extends Record<string, unknown>>({
354
359
  } satisfies TableRenderProps);
355
360
 
356
361
  // --- Plugin pipeline: header cells ---
357
- const headerCells = resolvedColumns.map(col => {
362
+ const headerCells = resolvedColumns.map((col, columnIndex) => {
358
363
  const headerContent = col.header ?? col.key;
359
364
 
360
365
  // Build initial htmlProps with column alignment if specified
@@ -365,15 +370,22 @@ function BaseTableInner<T extends Record<string, unknown>>({
365
370
  initialHeaderHtmlProps.style = {textAlign: col.align};
366
371
  }
367
372
 
373
+ const initialHeaderRenderProps: HeaderCellRenderProps = {
374
+ htmlProps: initialHeaderHtmlProps,
375
+ styles: [],
376
+ content: headerContent,
377
+ columnIndex,
378
+ columns: resolvedColumns as ReadonlyArray<
379
+ TableColumn<Record<string, unknown>>
380
+ >,
381
+ };
368
382
  const cellRenderProps = applyPlugins(
369
383
  plugins,
370
384
  p => p.transformHeaderCell,
371
- {
372
- htmlProps: initialHeaderHtmlProps,
373
- styles: [],
374
- content: headerContent,
375
- } satisfies HeaderCellRenderProps,
385
+ initialHeaderRenderProps,
376
386
  col,
387
+ columnIndex,
388
+ resolvedColumns,
377
389
  );
378
390
 
379
391
  // Apply pre-computed column width styles on the <th>.
@@ -465,6 +477,7 @@ function BaseTableInner<T extends Record<string, unknown>>({
465
477
  <TableHeader>
466
478
  <RowComponent
467
479
  {...headerRowRenderProps.htmlProps}
480
+ isHeaderRow
468
481
  xstyle={headerRowRenderProps.styles}>
469
482
  {headerRowRenderProps.children}
470
483
  </RowComponent>
@@ -497,9 +510,7 @@ function BaseTableInner<T extends Record<string, unknown>>({
497
510
  emptyState !== false && (
498
511
  <tr>
499
512
  <td colSpan={resolvedColumns.length}>
500
- {emptyState ?? (
501
- <EmptyState title="No data" isCompact />
502
- )}
513
+ {emptyState ?? <EmptyState title="No data" isCompact />}
503
514
  </td>
504
515
  </tr>
505
516
  )}
@@ -513,8 +524,29 @@ function BaseTableInner<T extends Record<string, unknown>>({
513
524
  // when columns exceed the container width. This wrapper sits between
514
525
  // the <table> and transformTableContext, so plugin chrome (pagination,
515
526
  // toolbars) renders outside the scroll area.
527
+ //
528
+ // Before rendering the wrapper, run the plugin `transformScrollWrapper`
529
+ // pipeline so plugins can attach a ref to the scroll container (scroll-aware
530
+ // sticky shadows, virtualization) and inject before/after chrome.
516
531
  if (ScrollWrapper) {
517
- tableElement = <ScrollWrapper>{tableElement}</ScrollWrapper>;
532
+ const scrollWrapperRenderProps = applyPlugins(
533
+ plugins,
534
+ p => p.transformScrollWrapper,
535
+ {
536
+ htmlProps: {},
537
+ styles: [],
538
+ } satisfies ScrollWrapperRenderProps,
539
+ );
540
+
541
+ tableElement = (
542
+ <ScrollWrapper
543
+ htmlProps={scrollWrapperRenderProps.htmlProps}
544
+ styles={scrollWrapperRenderProps.styles}
545
+ beforeTable={scrollWrapperRenderProps.beforeTable}
546
+ afterTable={scrollWrapperRenderProps.afterTable}>
547
+ {tableElement}
548
+ </ScrollWrapper>
549
+ );
518
550
  }
519
551
 
520
552
  // Apply transformTableContext from each plugin.
@@ -527,10 +559,7 @@ function BaseTableInner<T extends Record<string, unknown>>({
527
559
  try {
528
560
  tableElement = plugin.transformTableContext(tableElement);
529
561
  } catch (error) {
530
- console.error(
531
- '[Table] Plugin threw in transformTableContext:',
532
- error,
533
- );
562
+ console.error('[Table] Plugin threw in transformTableContext:', error);
534
563
  }
535
564
  }
536
565
  }
@@ -556,9 +585,7 @@ function BaseTableInner<T extends Record<string, unknown>>({
556
585
  * />
557
586
  * ```
558
587
  */
559
- export const BaseTable = BaseTableInner as <
560
- T extends Record<string, unknown>,
561
- >(
588
+ export const BaseTable = BaseTableInner as <T extends Record<string, unknown>>(
562
589
  props: BaseTableProps<T> & {ref?: Ref<HTMLTableElement>},
563
590
  ) => ReactElement;
564
591
 
@@ -71,6 +71,18 @@ export const docs = {
71
71
  description: 'Applies a hover highlight background to rows on pointer devices.',
72
72
  default: 'false',
73
73
  },
74
+ {
75
+ name: 'verticalAlign',
76
+ type: "'middle' | 'top' | 'bottom'",
77
+ description: 'Vertical alignment for body row cells. Controls `vertical-align` on the `<td>` elements.',
78
+ default: "'middle'",
79
+ },
80
+ {
81
+ name: 'textOverflow',
82
+ type: "'wrap' | 'truncate'",
83
+ description: "How body cell text behaves when it exceeds the column width. 'wrap' lets text wrap and the row grow taller; 'truncate' clips with an ellipsis (default-rendered cells show a tooltip on hover when truncated). Header cells always truncate.",
84
+ default: "'wrap'",
85
+ },
74
86
  {
75
87
  name: 'plugins',
76
88
  type: 'Record<string, TablePlugin<T>>',
@@ -245,6 +245,27 @@ describe('BaseTable', () => {
245
245
  expect(screen.getAllByRole('row')).toHaveLength(4);
246
246
  });
247
247
 
248
+ it('does not apply hover styling to the header row when hasHover is set', () => {
249
+ // Use the public Table (provides TableContext); BaseTable alone has no
250
+ // context, so rows wouldn't pick up hover styling at all.
251
+ const {container} = render(
252
+ <Table data={users} columns={columns} hasHover />,
253
+ );
254
+ const headerRow = container.querySelector('thead tr');
255
+ const bodyRow = container.querySelector('tbody tr');
256
+ expect(headerRow).not.toBeNull();
257
+ expect(bodyRow).not.toBeNull();
258
+
259
+ const headerClasses = new Set(
260
+ (headerRow?.className ?? '').split(/\s+/).filter(Boolean),
261
+ );
262
+ // The body row carries hover-styling class(es) that the header row does not.
263
+ const bodyOnlyClasses = (bodyRow?.className ?? '')
264
+ .split(/\s+/)
265
+ .filter(c => c && !headerClasses.has(c));
266
+ expect(bodyOnlyClasses.length).toBeGreaterThan(0);
267
+ });
268
+
248
269
  it('auto-generates columns from data keys when columns omitted', () => {
249
270
  render(<BaseTable data={users} />);
250
271
  const headers = screen.getAllByRole('columnheader');
@@ -276,11 +297,7 @@ describe('BaseTable', () => {
276
297
 
277
298
  it('uses idKey function to key rows', () => {
278
299
  render(
279
- <BaseTable
280
- data={users}
281
- columns={columns}
282
- idKey={item => item.email}
283
- />,
300
+ <BaseTable data={users} columns={columns} idKey={item => item.email} />,
284
301
  );
285
302
  expect(screen.getAllByRole('row')).toHaveLength(4);
286
303
  });
@@ -374,9 +391,7 @@ describe('BaseTable', () => {
374
391
  htmlProps: {...props.htmlProps, 'data-testid': 'plugin-table'},
375
392
  }),
376
393
  };
377
- render(
378
- <BaseTable data={users} columns={columns} plugins={[plugin]} />,
379
- );
394
+ render(<BaseTable data={users} columns={columns} plugins={[plugin]} />);
380
395
  expect(screen.getByTestId('plugin-table')).toBeInTheDocument();
381
396
  });
382
397
 
@@ -387,9 +402,7 @@ describe('BaseTable', () => {
387
402
  htmlProps: {...props.htmlProps, 'data-testid': 'plugin-header-row'},
388
403
  }),
389
404
  };
390
- render(
391
- <BaseTable data={users} columns={columns} plugins={[plugin]} />,
392
- );
405
+ render(<BaseTable data={users} columns={columns} plugins={[plugin]} />);
393
406
  expect(screen.getByTestId('plugin-header-row')).toBeInTheDocument();
394
407
  });
395
408
 
@@ -404,9 +417,7 @@ describe('BaseTable', () => {
404
417
  };
405
418
  },
406
419
  };
407
- render(
408
- <BaseTable data={users} columns={columns} plugins={[plugin]} />,
409
- );
420
+ render(<BaseTable data={users} columns={columns} plugins={[plugin]} />);
410
421
  expect(receivedKeys).toEqual(['name', 'age', 'email']);
411
422
  const headers = screen.getAllByRole('columnheader');
412
423
  expect(headers[0]).toHaveAttribute('data-column', 'name');
@@ -423,9 +434,7 @@ describe('BaseTable', () => {
423
434
  };
424
435
  },
425
436
  };
426
- render(
427
- <BaseTable data={users} columns={columns} plugins={[plugin]} />,
428
- );
437
+ render(<BaseTable data={users} columns={columns} plugins={[plugin]} />);
429
438
  expect(receivedItems).toEqual(['Alice', 'Bob', 'Charlie']);
430
439
  });
431
440
 
@@ -437,9 +446,7 @@ describe('BaseTable', () => {
437
446
  return props;
438
447
  },
439
448
  };
440
- render(
441
- <BaseTable data={users} columns={columns} plugins={[plugin]} />,
442
- );
449
+ render(<BaseTable data={users} columns={columns} plugins={[plugin]} />);
443
450
  // 3 rows * 3 columns = 9 calls
444
451
  expect(calls).toHaveLength(9);
445
452
  expect(calls[0]).toEqual({col: 'name', name: 'Alice'});
@@ -746,11 +753,7 @@ describe('Table', () => {
746
753
  }),
747
754
  };
748
755
  render(
749
- <Table
750
- data={users}
751
- columns={columns}
752
- plugins={{custom: userPlugin}}
753
- />,
756
+ <Table data={users} columns={columns} plugins={{custom: userPlugin}} />,
754
757
  );
755
758
  expect(screen.getByTestId('custom-plugin')).toBeInTheDocument();
756
759
  });
@@ -767,11 +770,7 @@ describe('Table', () => {
767
770
  },
768
771
  };
769
772
  render(
770
- <Table
771
- data={users}
772
- columns={columns}
773
- plugins={{custom: userPlugin}}
774
- />,
773
+ <Table data={users} columns={columns} plugins={{custom: userPlugin}} />,
775
774
  );
776
775
  expect(screen.getByTestId('after-xds')).toBeInTheDocument();
777
776
  });
@@ -30,6 +30,7 @@ import type {
30
30
  TablePlugin,
31
31
  TableRenderProps,
32
32
  } from './types';
33
+ import type {StyleXStyles} from '../theme/types';
33
34
 
34
35
  // =============================================================================
35
36
  // Table Types
@@ -131,17 +132,37 @@ const scrollWrapperStyles = stylex.create({
131
132
  },
132
133
  });
133
134
 
134
- function TableScrollWrapper({children}: {children: React.ReactNode}) {
135
+ function TableScrollWrapper({
136
+ children,
137
+ htmlProps,
138
+ styles: pluginStyles,
139
+ beforeTable,
140
+ afterTable,
141
+ }: {
142
+ children: React.ReactNode;
143
+ htmlProps?: React.HTMLAttributes<HTMLDivElement> & {
144
+ ref?: React.Ref<HTMLDivElement>;
145
+ };
146
+ styles?: StyleXStyles[];
147
+ beforeTable?: React.ReactNode;
148
+ afterTable?: React.ReactNode;
149
+ }) {
150
+ const {ref, ...restHtmlProps} = htmlProps ?? {};
135
151
  return (
136
152
  <div
153
+ ref={ref}
154
+ {...restHtmlProps}
137
155
  {...mergeProps(
138
156
  themeProps('table-scroll-wrapper'),
139
157
  stylex.props(
140
158
  scrollWrapperStyles.base,
141
159
  scrollWrapperStyles.containerBleed,
160
+ ...(pluginStyles ?? []),
142
161
  ),
143
162
  )}>
163
+ {beforeTable}
144
164
  {children}
165
+ {afterTable}
145
166
  </div>
146
167
  );
147
168
  }
@@ -34,6 +34,11 @@ export interface TableRowProps extends BaseProps<HTMLTableRowElement> {
34
34
  * Must be a `stylex.create()` value — not an inline style object.
35
35
  */
36
36
  xstyle?: StyleXStyles[];
37
+ /**
38
+ * Whether this row is the header row. Header rows skip the striped/hover
39
+ * row styling (which is only meant for body rows).
40
+ */
41
+ isHeaderRow?: boolean;
37
42
  }
38
43
 
39
44
  const stripedRowStyles = stylex.create({
@@ -42,6 +47,13 @@ const stripedRowStyles = stylex.create({
42
47
  default: null,
43
48
  ':nth-child(even)': colorVars['--color-background-muted'],
44
49
  },
50
+ // Publish the row's current overlay color as an inheritable variable so
51
+ // pinned/sticky cells (which paint an opaque background over the otherwise
52
+ // transparent row) can replay the exact same striping. Unset on odd rows.
53
+ '--table-row-overlay': {
54
+ default: null,
55
+ ':nth-child(even)': colorVars['--color-background-muted'],
56
+ },
45
57
  },
46
58
  });
47
59
 
@@ -53,6 +65,12 @@ const hoverRowStyles = stylex.create({
53
65
  '@media (hover: hover)': colorVars['--color-overlay-hover'],
54
66
  },
55
67
  },
68
+ '--table-row-overlay': {
69
+ default: null,
70
+ ':hover': {
71
+ '@media (hover: hover)': colorVars['--color-overlay-hover'],
72
+ },
73
+ },
56
74
  transitionProperty: 'background-color',
57
75
  transitionDuration: durationVars['--duration-fast'],
58
76
  transitionTimingFunction: easeVars['--ease-standard'],
@@ -68,6 +86,13 @@ const stripedHoverRowStyles = stylex.create({
68
86
  '@media (hover: hover)': colorVars['--color-overlay-hover'],
69
87
  },
70
88
  },
89
+ '--table-row-overlay': {
90
+ default: null,
91
+ ':nth-child(even)': colorVars['--color-background-muted'],
92
+ ':hover': {
93
+ '@media (hover: hover)': colorVars['--color-overlay-hover'],
94
+ },
95
+ },
71
96
  transitionProperty: 'background-color',
72
97
  transitionDuration: durationVars['--duration-fast'],
73
98
  transitionTimingFunction: easeVars['--ease-standard'],
@@ -94,6 +119,7 @@ export function TableRow({
94
119
  children,
95
120
  xstyle,
96
121
  ref,
122
+ isHeaderRow = false,
97
123
  ...props
98
124
  }: TableRowProps) {
99
125
  const ctx = use(TableContext);
@@ -114,13 +140,17 @@ export function TableRow({
114
140
 
115
141
  const rowStyles: StyleXStyles[] = [];
116
142
 
117
- // Handle striped + hover combination to avoid backgroundColor conflicts
118
- if (ctx.isStriped && ctx.hasHover) {
119
- rowStyles.push(stripedHoverRowStyles.row);
120
- } else if (ctx.isStriped) {
121
- rowStyles.push(stripedRowStyles.row);
122
- } else if (ctx.hasHover) {
123
- rowStyles.push(hoverRowStyles.row);
143
+ // Striped + hover styling is for body rows only — the header row must not
144
+ // pick up the hover highlight (or striping) even when hasHover/isStriped.
145
+ if (!isHeaderRow) {
146
+ // Handle striped + hover combination to avoid backgroundColor conflicts
147
+ if (ctx.isStriped && ctx.hasHover) {
148
+ rowStyles.push(stripedHoverRowStyles.row);
149
+ } else if (ctx.isStriped) {
150
+ rowStyles.push(stripedRowStyles.row);
151
+ } else if (ctx.hasHover) {
152
+ rowStyles.push(hoverRowStyles.row);
153
+ }
124
154
  }
125
155
 
126
156
  if (ctx.dividers === 'rows' || ctx.dividers === 'grid') {
@@ -27,6 +27,7 @@ export {useTablePagination, paginateData} from './plugins/pagination';
27
27
  export {useTableColumnSettings} from './plugins/columnSettings';
28
28
  export {useTableColumnSettingsState} from './plugins/columnSettings';
29
29
  export {useTableColumnResize} from './plugins/columnResize';
30
+ export {useTableStickyColumns} from './plugins/stickyColumns';
30
31
  export {
31
32
  useTableFiltering,
32
33
  useTableFilterState,
@@ -53,6 +54,7 @@ export type {
53
54
  HeaderCellRenderProps,
54
55
  BodyRowRenderProps,
55
56
  BodyCellRenderProps,
57
+ ScrollWrapperRenderProps,
56
58
  BaseTableProps,
57
59
  } from './types';
58
60
  export type {
@@ -93,6 +95,7 @@ export type {
93
95
  UseTableColumnSettingsStateReturn,
94
96
  } from './plugins/columnSettings';
95
97
  export type {UseTableColumnResizeConfig} from './plugins/columnResize';
98
+ export type {UseTableStickyColumnsConfig} from './plugins/stickyColumns';
96
99
  export type {
97
100
  UseTableFilteringConfig,
98
101
  TableFilterState,
@@ -0,0 +1,4 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ export {useTableStickyColumns} from './useTableStickyColumns';
4
+ export type {UseTableStickyColumnsConfig} from './useTableStickyColumns';
@@ -0,0 +1,163 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useTableStickyColumns.test.tsx
5
+ * @input useTableStickyColumns, Table, React testing utilities
6
+ * @output Functional tests for the sticky-columns plugin
7
+ * @position Test file; validates pinning, cumulative offsets, edges, no-op
8
+ *
9
+ * Note: `position: sticky` and the z-index/background are applied via StyleX
10
+ * (compiled to classNames), which jsdom does not resolve to `element.style`.
11
+ * The plugin sets the per-column *offset* (`insetInlineStart`/`insetInlineEnd`)
12
+ * as an inline style, so these tests assert on those inline offsets — the
13
+ * jsdom-visible, plugin-owned signal that a column is pinned and at what offset.
14
+ */
15
+
16
+ import {describe, it, expect} from 'vitest';
17
+ import {render, screen} from '@testing-library/react';
18
+ import {Table} from '../../Table';
19
+ import {useTableStickyColumns} from './useTableStickyColumns';
20
+ import {pixel} from '../../columnUtils';
21
+ import type {TableColumn} from '../../types';
22
+
23
+ // =============================================================================
24
+ // Test Data
25
+ // =============================================================================
26
+
27
+ interface Row extends Record<string, unknown> {
28
+ id: string;
29
+ name: string;
30
+ email: string;
31
+ team: string;
32
+ status: string;
33
+ }
34
+
35
+ const data: Row[] = [
36
+ {id: '1', name: 'Alice', email: 'a@x.com', team: 'DS', status: 'Active'},
37
+ {id: '2', name: 'Bob', email: 'b@x.com', team: 'Plat', status: 'Away'},
38
+ ];
39
+
40
+ const columns: TableColumn<Row>[] = [
41
+ {key: 'name', header: 'Name', width: pixel(180)},
42
+ {key: 'email', header: 'Email', width: pixel(220)},
43
+ {key: 'team', header: 'Team', width: pixel(160)},
44
+ {key: 'status', header: 'Status', width: pixel(140)},
45
+ ];
46
+
47
+ function getHeader(name: string): HTMLElement {
48
+ return screen.getByRole('columnheader', {name});
49
+ }
50
+
51
+ // =============================================================================
52
+ // Tests
53
+ // =============================================================================
54
+
55
+ describe('useTableStickyColumns', () => {
56
+ it('pins a start column at inset-inline-start: 0', () => {
57
+ function Harness() {
58
+ const sticky = useTableStickyColumns<Row>({startKeys: ['name']});
59
+ return (
60
+ <Table
61
+ data={data}
62
+ columns={columns}
63
+ idKey="id"
64
+ plugins={{stickyColumns: sticky}}
65
+ />
66
+ );
67
+ }
68
+ render(<Harness />);
69
+ expect(getHeader('Name').style.insetInlineStart).toBe('0px');
70
+ });
71
+
72
+ it('computes cumulative start offsets for contiguous pinned columns', () => {
73
+ function Harness() {
74
+ const sticky = useTableStickyColumns<Row>({
75
+ startKeys: ['name', 'email'],
76
+ });
77
+ return (
78
+ <Table
79
+ data={data}
80
+ columns={columns}
81
+ idKey="id"
82
+ plugins={{stickyColumns: sticky}}
83
+ />
84
+ );
85
+ }
86
+ render(<Harness />);
87
+ // name is first → offset 0; email follows → offset = name width (180px)
88
+ expect(getHeader('Name').style.insetInlineStart).toBe('0px');
89
+ expect(getHeader('Email').style.insetInlineStart).toBe('180px');
90
+ });
91
+
92
+ it('pins an end column at inset-inline-end: 0', () => {
93
+ function Harness() {
94
+ const sticky = useTableStickyColumns<Row>({endKeys: ['status']});
95
+ return (
96
+ <Table
97
+ data={data}
98
+ columns={columns}
99
+ idKey="id"
100
+ plugins={{stickyColumns: sticky}}
101
+ />
102
+ );
103
+ }
104
+ render(<Harness />);
105
+ expect(getHeader('Status').style.insetInlineEnd).toBe('0px');
106
+ });
107
+
108
+ it('pins body cells, not just headers', () => {
109
+ function Harness() {
110
+ const sticky = useTableStickyColumns<Row>({startKeys: ['name']});
111
+ return (
112
+ <Table
113
+ data={data}
114
+ columns={columns}
115
+ idKey="id"
116
+ plugins={{stickyColumns: sticky}}
117
+ />
118
+ );
119
+ }
120
+ render(<Harness />);
121
+ const firstBodyCell = screen.getByText('Alice').closest('td');
122
+ expect(firstBodyCell).not.toBeNull();
123
+ expect(firstBodyCell!.style.insetInlineStart).toBe('0px');
124
+ });
125
+
126
+ it('is a no-op with an empty config — no cell gets an offset', () => {
127
+ function Harness() {
128
+ const sticky = useTableStickyColumns<Row>({});
129
+ return (
130
+ <Table
131
+ data={data}
132
+ columns={columns}
133
+ idKey="id"
134
+ plugins={{stickyColumns: sticky}}
135
+ />
136
+ );
137
+ }
138
+ render(<Harness />);
139
+ for (const header of ['Name', 'Email', 'Team', 'Status']) {
140
+ const th = getHeader(header);
141
+ expect(th.style.insetInlineStart).toBe('');
142
+ expect(th.style.insetInlineEnd).toBe('');
143
+ }
144
+ });
145
+
146
+ it('only pins configured columns, leaving others unset', () => {
147
+ function Harness() {
148
+ const sticky = useTableStickyColumns<Row>({startKeys: ['name']});
149
+ return (
150
+ <Table
151
+ data={data}
152
+ columns={columns}
153
+ idKey="id"
154
+ plugins={{stickyColumns: sticky}}
155
+ />
156
+ );
157
+ }
158
+ render(<Harness />);
159
+ expect(getHeader('Name').style.insetInlineStart).toBe('0px');
160
+ expect(getHeader('Team').style.insetInlineStart).toBe('');
161
+ expect(getHeader('Team').style.insetInlineEnd).toBe('');
162
+ });
163
+ });