@astryxdesign/core 0.1.0 → 0.1.1-canary.129bf0e

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 (155) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +68 -0
  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.map +1 -1
  10. package/dist/Button/Button.js +2 -0
  11. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  12. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  13. package/dist/ContextMenu/ContextMenu.js +2 -2
  14. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  15. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  16. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  17. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  18. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  19. package/dist/EmptyState/EmptyState.js +7 -1
  20. package/dist/HoverCard/HoverCard.d.ts +2 -2
  21. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  22. package/dist/HoverCard/HoverCard.js +18 -6
  23. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/useHoverCard.js +6 -3
  25. package/dist/Layer/useLayer.d.ts +13 -0
  26. package/dist/Layer/useLayer.d.ts.map +1 -1
  27. package/dist/Layer/useLayer.js +7 -2
  28. package/dist/Layout/Layout.d.ts +10 -1
  29. package/dist/Layout/Layout.d.ts.map +1 -1
  30. package/dist/Layout/Layout.js +5 -1
  31. package/dist/Markdown/Markdown.d.ts.map +1 -1
  32. package/dist/Markdown/Markdown.js +13 -3
  33. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  34. package/dist/MobileNav/MobileNav.js +13 -0
  35. package/dist/Outline/Outline.d.ts +3 -2
  36. package/dist/Outline/Outline.d.ts.map +1 -1
  37. package/dist/Outline/Outline.js +23 -4
  38. package/dist/Outline/useScrollSpy.d.ts +14 -1
  39. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  40. package/dist/Outline/useScrollSpy.js +161 -50
  41. package/dist/Pagination/Pagination.d.ts.map +1 -1
  42. package/dist/Pagination/Pagination.js +31 -27
  43. package/dist/Resizable/useResizable.d.ts.map +1 -1
  44. package/dist/Resizable/useResizable.js +1 -5
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +1 -1
  47. package/dist/Table/BaseTable.d.ts.map +1 -1
  48. package/dist/Table/BaseTable.js +26 -8
  49. package/dist/Table/Table.d.ts.map +1 -1
  50. package/dist/Table/Table.js +30 -7
  51. package/dist/Table/index.d.ts +3 -1
  52. package/dist/Table/index.d.ts.map +1 -1
  53. package/dist/Table/index.js +1 -0
  54. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  55. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  56. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  57. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  58. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  59. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
  60. package/dist/Table/types.d.ts +90 -5
  61. package/dist/Table/types.d.ts.map +1 -1
  62. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  63. package/dist/Table/useBaseTablePlugins.js +1 -1
  64. package/dist/ToggleButton/ToggleButton.d.ts +10 -3
  65. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  66. package/dist/ToggleButton/ToggleButton.js +64 -18
  67. package/dist/astryx.css +11 -0
  68. package/dist/astryx.umd.js +147 -0
  69. package/dist/astryx.umd.js.map +7 -0
  70. package/dist/theme/Theme.js +1 -1
  71. package/dist/theme/defineTheme.d.ts +1 -1
  72. package/dist/theme/defineTheme.d.ts.map +1 -1
  73. package/dist/theme/defineTheme.js +1 -1
  74. package/dist/theme/index.d.ts +1 -1
  75. package/dist/theme/index.d.ts.map +1 -1
  76. package/dist/theme/index.js +1 -1
  77. package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
  78. package/dist/theme/tokens.d.ts +1 -1
  79. package/dist/theme/tokens.js +4 -4
  80. package/dist/theme/useTheme.d.ts +2 -2
  81. package/dist/utils/dateParser.d.ts.map +1 -1
  82. package/dist/utils/dateParser.js +15 -2
  83. package/package.json +7 -3
  84. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  85. package/src/Banner/Banner.test.tsx +16 -7
  86. package/src/Banner/Banner.tsx +9 -2
  87. package/src/Button/Button.test.tsx +26 -11
  88. package/src/Button/Button.tsx +2 -0
  89. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  90. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  91. package/src/ContextMenu/ContextMenu.tsx +2 -2
  92. package/src/DateInput/DateInput.test.tsx +68 -20
  93. package/src/Divider/Divider.doc.mjs +1 -1
  94. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  95. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  96. package/src/EmptyState/EmptyState.test.tsx +4 -2
  97. package/src/EmptyState/EmptyState.tsx +6 -2
  98. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  99. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  100. package/src/HoverCard/HoverCard.test.tsx +178 -2
  101. package/src/HoverCard/HoverCard.tsx +20 -16
  102. package/src/HoverCard/useHoverCard.tsx +12 -10
  103. package/src/Icon/Icon.doc.mjs +4 -4
  104. package/src/Item/Item.doc.mjs +2 -2
  105. package/src/Layer/useLayer.doc.mjs +7 -2
  106. package/src/Layer/useLayer.tsx +19 -2
  107. package/src/Layout/Layout.doc.mjs +2 -1
  108. package/src/Layout/Layout.tsx +15 -1
  109. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  110. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  111. package/src/Link/Link.doc.mjs +3 -3
  112. package/src/Link/LinkProvider.doc.mjs +3 -3
  113. package/src/Markdown/Markdown.doc.mjs +6 -4
  114. package/src/Markdown/Markdown.test.tsx +17 -26
  115. package/src/Markdown/Markdown.tsx +16 -6
  116. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  117. package/src/MobileNav/MobileNav.tsx +13 -0
  118. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  119. package/src/Outline/Outline.doc.mjs +1 -1
  120. package/src/Outline/Outline.test.tsx +76 -38
  121. package/src/Outline/Outline.tsx +23 -4
  122. package/src/Outline/useScrollSpy.ts +196 -63
  123. package/src/Pagination/Pagination.test.tsx +137 -13
  124. package/src/Pagination/Pagination.tsx +33 -28
  125. package/src/Resizable/Resizable.doc.mjs +3 -3
  126. package/src/Resizable/useResizable.ts +1 -7
  127. package/src/Selector/Selector.doc.mjs +4 -0
  128. package/src/Selector/Selector.tsx +5 -6
  129. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  130. package/src/Table/BaseTable.tsx +50 -24
  131. package/src/Table/Table.doc.mjs +3 -3
  132. package/src/Table/Table.tsx +22 -1
  133. package/src/Table/index.ts +3 -0
  134. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  135. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  136. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
  137. package/src/Table/types.ts +96 -4
  138. package/src/Table/useBaseTablePlugins.ts +1 -0
  139. package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
  140. package/src/ToggleButton/ToggleButton.test.tsx +148 -6
  141. package/src/ToggleButton/ToggleButton.tsx +83 -20
  142. package/src/Toolbar/Toolbar.doc.mjs +1 -1
  143. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  144. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  145. package/src/hooks/useStreamingText.doc.mjs +3 -3
  146. package/src/theme/Theme.doc.mjs +2 -2
  147. package/src/theme/Theme.tsx +1 -1
  148. package/src/theme/defineTheme.ts +1 -1
  149. package/src/theme/index.ts +1 -1
  150. package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
  151. package/src/theme/tokens.ts +4 -4
  152. package/src/theme/useTheme.ts +2 -2
  153. package/src/utils/dateParser.test.ts +26 -0
  154. package/src/utils/dateParser.ts +16 -2
  155. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -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
+ });
@@ -0,0 +1,414 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useTableStickyColumns.tsx
7
+ * @input React, StyleX, theme tokens, Table types
8
+ * @output Exports useTableStickyColumns hook and UseTableStickyColumnsConfig type
9
+ * @position Sticky-columns plugin; consumed by Table via plugins prop
10
+ *
11
+ * SYNC: When modified, update these files to stay in sync:
12
+ * - /packages/core/src/Table/Table.doc.mjs (sticky-columns documentation)
13
+ * - /packages/core/src/Table/index.ts (exports)
14
+ */
15
+
16
+ import {useCallback, useMemo, useRef, type CSSProperties} from 'react';
17
+ import * as stylex from '@stylexjs/stylex';
18
+ import {colorVars} from '../../../theme/tokens.stylex';
19
+ import type {
20
+ TableColumn,
21
+ TablePlugin,
22
+ HeaderCellRenderProps,
23
+ BodyCellRenderProps,
24
+ ScrollWrapperRenderProps,
25
+ } from '../../types';
26
+ import {DEFAULT_MIN_COLUMN_WIDTH} from '../../columnUtils';
27
+
28
+ // =============================================================================
29
+ // Config
30
+ // =============================================================================
31
+
32
+ /**
33
+ * Config for {@link useTableStickyColumns}. Provide at least one of
34
+ * `startKeys` / `endKeys` to pin columns.
35
+ *
36
+ * @remarks Every field is optional by design, so `useTableStickyColumns({})`
37
+ * compiles and is an intentional no-op that pins nothing — the hook returns a
38
+ * plugin that passes every cell through untouched. This lets callers compute
39
+ * the config conditionally (e.g. `endKeys: enabled ? ['notes'] : undefined`)
40
+ * without branching on whether to install the plugin at all.
41
+ */
42
+ export interface UseTableStickyColumnsConfig {
43
+ /**
44
+ * Column keys pinned to the START (inline-start / left in LTR) edge — the
45
+ * contiguous run from the first column through the last listed key.
46
+ */
47
+ startKeys?: string[];
48
+ /**
49
+ * Column keys pinned to the END (inline-end / right in LTR) edge — the
50
+ * contiguous run from the first listed key through the last column.
51
+ */
52
+ endKeys?: string[];
53
+ }
54
+
55
+ // =============================================================================
56
+ // Width helpers
57
+ // =============================================================================
58
+
59
+ /**
60
+ * Resolve a column's pixel width for cumulative offset math. Mirrors the
61
+ * resize plugin's fallback so offsets line up with rendered widths:
62
+ * pixel columns use their value; proportional columns use their declared
63
+ * minWidth (or the default); unknown widths use the default.
64
+ */
65
+ function getColumnWidth(col: TableColumn<Record<string, unknown>>): number {
66
+ const w = col.width;
67
+ if (!w) {
68
+ return DEFAULT_MIN_COLUMN_WIDTH;
69
+ }
70
+ if (w.type === 'pixel') {
71
+ return w.value;
72
+ }
73
+ // proportional
74
+ return w.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
75
+ }
76
+
77
+ /**
78
+ * Columns pinned to the START edge, keyed by column key → cumulative inline
79
+ * offset in pixels. The pinned block is the CONTIGUOUS run of leading columns
80
+ * from index 0 through the last column whose key is in `startKeys` (inclusive),
81
+ * INCLUDING synthetic columns (selection checkbox, row-index, …) that sit to
82
+ * the start of the user's sticky column. Returns `null` when no start-sticky
83
+ * column is present or column context is unavailable.
84
+ */
85
+ function computeStartOffsets(
86
+ columns: ReadonlyArray<TableColumn<Record<string, unknown>>> | undefined,
87
+ startKeys: string[],
88
+ ): Map<string, number> | null {
89
+ if (!columns || columns.length === 0 || startKeys.length === 0) {
90
+ return null;
91
+ }
92
+ let lastStickyIndex = -1;
93
+ for (let i = 0; i < columns.length; i++) {
94
+ if (startKeys.includes(columns[i].key)) {
95
+ lastStickyIndex = i;
96
+ }
97
+ }
98
+ if (lastStickyIndex === -1) {
99
+ return null;
100
+ }
101
+ const offsets = new Map<string, number>();
102
+ let cumulative = 0;
103
+ for (let i = 0; i <= lastStickyIndex; i++) {
104
+ offsets.set(columns[i].key, cumulative);
105
+ cumulative += getColumnWidth(columns[i]);
106
+ }
107
+ return offsets;
108
+ }
109
+
110
+ /**
111
+ * Mirror image of {@link computeStartOffsets} — columns pinned to the END edge,
112
+ * keyed by column key → cumulative inline-end offset. The pinned block is the
113
+ * CONTIGUOUS run of trailing columns from the FIRST column whose key is in
114
+ * `endKeys` through the last column (inclusive). Offsets accumulate from the
115
+ * end edge — the last column gets `0`, its neighbor gets that column's width,
116
+ * etc. Returns `null` when no end-sticky column is present.
117
+ */
118
+ function computeEndOffsets(
119
+ columns: ReadonlyArray<TableColumn<Record<string, unknown>>> | undefined,
120
+ endKeys: string[],
121
+ ): Map<string, number> | null {
122
+ if (!columns || columns.length === 0 || endKeys.length === 0) {
123
+ return null;
124
+ }
125
+ let firstStickyIndex = -1;
126
+ for (let i = 0; i < columns.length; i++) {
127
+ if (endKeys.includes(columns[i].key)) {
128
+ firstStickyIndex = i;
129
+ break;
130
+ }
131
+ }
132
+ if (firstStickyIndex === -1) {
133
+ return null;
134
+ }
135
+ const offsets = new Map<string, number>();
136
+ let cumulative = 0;
137
+ for (let i = columns.length - 1; i >= firstStickyIndex; i--) {
138
+ offsets.set(columns[i].key, cumulative);
139
+ cumulative += getColumnWidth(columns[i]);
140
+ }
141
+ return offsets;
142
+ }
143
+
144
+ type StickySide = {edge: 'start' | 'end'; offset: number};
145
+
146
+ /**
147
+ * Resolve how a single column should be pinned given the start/end configs and
148
+ * the full column list. A key (mis)configured on both edges resolves to start.
149
+ * Returns `null` for columns that should not be pinned.
150
+ */
151
+ function resolveStickySide(
152
+ columns: ReadonlyArray<TableColumn<Record<string, unknown>>> | undefined,
153
+ columnKey: string,
154
+ startKeys: string[],
155
+ endKeys: string[],
156
+ ): StickySide | null {
157
+ const startOffsets = computeStartOffsets(columns, startKeys);
158
+ if (startOffsets?.has(columnKey)) {
159
+ return {edge: 'start', offset: startOffsets.get(columnKey) ?? 0};
160
+ }
161
+ const endOffsets = computeEndOffsets(columns, endKeys);
162
+ if (endOffsets?.has(columnKey)) {
163
+ return {edge: 'end', offset: endOffsets.get(columnKey) ?? 0};
164
+ }
165
+ return null;
166
+ }
167
+
168
+ // =============================================================================
169
+ // Styles
170
+ // =============================================================================
171
+
172
+ // CSS variables toggled on the scroll container by the layout ref. The cell
173
+ // ::after shadows read these, so each edge's shadow only shows when there is
174
+ // horizontally-scrolled content hidden behind that edge. We use CSS variables
175
+ // (not stylex.when.ancestor) because StyleX ancestor selectors support pseudo-
176
+ // classes only, not attribute/className matching — a CSS variable inherited
177
+ // from the scroll container is the supported way to gate descendant styles on
178
+ // container scroll state.
179
+ const SHADOW_VAR_START = '--table-sticky-shadow-start';
180
+ const SHADOW_VAR_END = '--table-sticky-shadow-end';
181
+
182
+ const stickyStyles = stylex.create({
183
+ cell: {
184
+ position: 'sticky',
185
+ // Pinned cells must be opaque so scrolling content doesn't show through.
186
+ backgroundColor: colorVars['--color-background-surface'],
187
+ // Clip the background to the padding box so it doesn't paint over the
188
+ // cell's (translucent, collapsed) bottom/right divider border. Sticky cells
189
+ // sit above regular cells, so without this the opaque background would hide
190
+ // the row divider on the pinned column.
191
+ backgroundClip: 'padding-box',
192
+ // Default table cells are `overflow: hidden` (for text truncation), which
193
+ // would clip the shadow ::after that bleeds past the pinned edge. Sticky
194
+ // cells opt back into visible overflow so the shadow can render outside.
195
+ // Text truncation on sticky columns still works because the cell width is
196
+ // fixed and the inner content wraps/ellipsizes within it.
197
+ overflow: 'visible',
198
+ },
199
+ headerCell: {
200
+ // Header cells stack above body cells; both stack above non-sticky cells.
201
+ zIndex: 3,
202
+ },
203
+ bodyCell: {
204
+ zIndex: 1,
205
+ },
206
+ });
207
+
208
+ // A soft drop shadow over the scrolling region, matching the EPS sticky-column
209
+ // treatment. `border-collapse: collapse` tables (Astryx Table uses
210
+ // table-layout: fixed + border-collapse: collapse) do NOT paint `box-shadow`
211
+ // on the cells themselves in Chromium, so the shadow is cast by a ::after strip
212
+ // positioned just outside the pinned edge, filled with a soft gradient that
213
+ // fades from a shadow tint to transparent over the scrolled content. Sticky
214
+ // cells set `overflow: visible` (above) so this strip isn't clipped. Its
215
+ // opacity reads a CSS variable inherited from the scroll container, which the
216
+ // layout ref toggles between 0 and 1 on scroll so the shadow only shows when
217
+ // there is hidden content behind that edge.
218
+ const SHADOW_WIDTH = '6px';
219
+ // A subtle tint that fades to transparent. --color-shadow is only ~10% alpha,
220
+ // which reads too faintly here, so use a slightly stronger but still soft tint.
221
+ const SHADOW_TINT = 'light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.32))';
222
+ const shadowStyles = stylex.create({
223
+ // start-pinned: shadow falls to the right (inline-end), over scrolled content
224
+ start: {
225
+ '::after': {
226
+ content: '""',
227
+ position: 'absolute',
228
+ top: 0,
229
+ bottom: 0,
230
+ insetInlineEnd: 0,
231
+ width: SHADOW_WIDTH,
232
+ transform: 'translateX(100%)',
233
+ pointerEvents: 'none',
234
+ transition: 'opacity 150ms ease',
235
+ opacity: `var(${SHADOW_VAR_START}, 0)`,
236
+ backgroundImage: `linear-gradient(to right, ${SHADOW_TINT}, transparent)`,
237
+ },
238
+ },
239
+ // end-pinned: shadow falls to the left (inline-start), over scrolled content
240
+ end: {
241
+ '::after': {
242
+ content: '""',
243
+ position: 'absolute',
244
+ top: 0,
245
+ bottom: 0,
246
+ insetInlineStart: 0,
247
+ width: SHADOW_WIDTH,
248
+ transform: 'translateX(-100%)',
249
+ pointerEvents: 'none',
250
+ transition: 'opacity 150ms ease',
251
+ opacity: `var(${SHADOW_VAR_END}, 0)`,
252
+ backgroundImage: `linear-gradient(to left, ${SHADOW_TINT}, transparent)`,
253
+ },
254
+ },
255
+ });
256
+
257
+ // Stable empty default so an unset start/end doesn't allocate a fresh array
258
+ // (and bust the memo) on every render.
259
+ const EMPTY: string[] = [];
260
+
261
+ // =============================================================================
262
+ // Hook
263
+ // =============================================================================
264
+
265
+ export function useTableStickyColumns<T extends Record<string, unknown>>(
266
+ config: UseTableStickyColumnsConfig,
267
+ ): TablePlugin<T> {
268
+ const {startKeys, endKeys} = config;
269
+ const start = startKeys ?? EMPTY;
270
+ const end = endKeys ?? EMPTY;
271
+
272
+ const hasStart = start.length > 0;
273
+ const hasEnd = end.length > 0;
274
+
275
+ // Live snapshot of the resolved config, read inside the (memoized) transforms
276
+ // and the scroll-shadow callback. Keeping these in a ref lets the plugin memo
277
+ // be computed once (deps: only the stable scroll-shadow callback) while never
278
+ // reading stale config — consumers typically pass fresh array literals each
279
+ // render, so we never want array identity in the deps.
280
+ const stateRef = useRef({start, end, hasStart, hasEnd});
281
+ stateRef.current = {start, end, hasStart, hasEnd};
282
+
283
+ // Scroll-aware shadows: toggle CSS variables on the scroll container so each
284
+ // edge's shadow only paints when there is hidden, horizontally-scrolled
285
+ // content behind that edge. Implemented with a callback ref + scroll/resize
286
+ // listeners (synchronizing with the DOM) rather than React state, so
287
+ // scrolling never triggers re-renders.
288
+ const detachRef = useRef<(() => void) | null>(null);
289
+ const attachScrollShadow = useCallback((el: HTMLDivElement | null) => {
290
+ detachRef.current?.();
291
+ detachRef.current = null;
292
+ if (!el) {
293
+ return;
294
+ }
295
+ const update = () => {
296
+ const {hasStart: hs, hasEnd: he} = stateRef.current;
297
+ const maxScroll = el.scrollWidth - el.clientWidth;
298
+ const hasOverflow = maxScroll > 1;
299
+ if (hs) {
300
+ el.style.setProperty(
301
+ SHADOW_VAR_START,
302
+ hasOverflow && el.scrollLeft > 1 ? '1' : '0',
303
+ );
304
+ }
305
+ if (he) {
306
+ el.style.setProperty(
307
+ SHADOW_VAR_END,
308
+ hasOverflow && el.scrollLeft < maxScroll - 1 ? '1' : '0',
309
+ );
310
+ }
311
+ };
312
+ el.addEventListener('scroll', update, {passive: true});
313
+ const resizeObserver =
314
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
315
+ resizeObserver?.observe(el);
316
+ update();
317
+ detachRef.current = () => {
318
+ el.removeEventListener('scroll', update);
319
+ resizeObserver?.disconnect();
320
+ };
321
+ }, []);
322
+
323
+ return useMemo(
324
+ (): TablePlugin<T> => ({
325
+ transformHeaderCell(
326
+ props: HeaderCellRenderProps,
327
+ column: TableColumn<T>,
328
+ ): HeaderCellRenderProps {
329
+ const {start: s, end: e} = stateRef.current;
330
+ const side = resolveStickySide(props.columns, column.key, s, e);
331
+ if (!side) {
332
+ return props;
333
+ }
334
+ // position/inline-offset are runtime values → set via inline style so
335
+ // they are authoritative regardless of plugin composition order (the
336
+ // resize plugin also writes inline style on header cells).
337
+ const offsetStyle: CSSProperties =
338
+ side.edge === 'start'
339
+ ? {insetInlineStart: `${side.offset}px`}
340
+ : {insetInlineEnd: `${side.offset}px`};
341
+ return {
342
+ ...props,
343
+ htmlProps: {
344
+ ...props.htmlProps,
345
+ style: {...props.htmlProps.style, ...offsetStyle},
346
+ },
347
+ styles: [
348
+ ...props.styles,
349
+ stickyStyles.cell,
350
+ stickyStyles.headerCell,
351
+ side.edge === 'start' ? shadowStyles.start : shadowStyles.end,
352
+ ],
353
+ };
354
+ },
355
+
356
+ transformBodyCell(
357
+ props: BodyCellRenderProps,
358
+ column: TableColumn<T>,
359
+ ): BodyCellRenderProps {
360
+ const {start: s, end: e} = stateRef.current;
361
+ const side = resolveStickySide(props.columns, column.key, s, e);
362
+ if (!side) {
363
+ return props;
364
+ }
365
+ const offsetStyle: CSSProperties =
366
+ side.edge === 'start'
367
+ ? {insetInlineStart: `${side.offset}px`}
368
+ : {insetInlineEnd: `${side.offset}px`};
369
+ return {
370
+ ...props,
371
+ htmlProps: {
372
+ ...props.htmlProps,
373
+ style: {...props.htmlProps.style, ...offsetStyle},
374
+ },
375
+ styles: [
376
+ ...props.styles,
377
+ stickyStyles.cell,
378
+ stickyStyles.bodyCell,
379
+ side.edge === 'start' ? shadowStyles.start : shadowStyles.end,
380
+ ],
381
+ };
382
+ },
383
+
384
+ transformScrollWrapper(
385
+ props: ScrollWrapperRenderProps,
386
+ ): ScrollWrapperRenderProps {
387
+ // No pinned edges → nothing to gate; leave the wrapper untouched.
388
+ if (!stateRef.current.hasStart && !stateRef.current.hasEnd) {
389
+ return props;
390
+ }
391
+ // Compose with any ref a prior plugin (e.g. virtualization) set on the
392
+ // scroll container.
393
+ const existingRef = props.htmlProps.ref;
394
+ const mergedRef = (node: HTMLDivElement | null) => {
395
+ attachScrollShadow(node);
396
+ if (typeof existingRef === 'function') {
397
+ existingRef(node);
398
+ } else if (existingRef != null) {
399
+ // RefObject — assign through its writable `.current`.
400
+ existingRef.current = node;
401
+ }
402
+ };
403
+ return {
404
+ ...props,
405
+ htmlProps: {...props.htmlProps, ref: mergedRef},
406
+ };
407
+ },
408
+ }),
409
+ // The returned plugin's transforms read live config from `stateRef` and
410
+ // `attachScrollShadow` is stable (empty deps), so the plugin object can be
411
+ // computed once and reused across renders.
412
+ [attachScrollShadow],
413
+ );
414
+ }