@astryxdesign/core 0.1.1 → 0.1.2-canary.3893cd5

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 (169) 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/EmptyState/EmptyState.d.ts.map +1 -1
  13. package/dist/EmptyState/EmptyState.js +7 -1
  14. package/dist/HoverCard/HoverCard.d.ts +2 -2
  15. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  16. package/dist/HoverCard/HoverCard.js +18 -6
  17. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  18. package/dist/HoverCard/useHoverCard.js +7 -3
  19. package/dist/Kbd/Kbd.d.ts.map +1 -1
  20. package/dist/Kbd/Kbd.js +1 -1
  21. package/dist/Layer/anchorName.d.ts +20 -0
  22. package/dist/Layer/anchorName.d.ts.map +1 -0
  23. package/dist/Layer/anchorName.js +36 -0
  24. package/dist/Layer/useLayer.d.ts +19 -0
  25. package/dist/Layer/useLayer.d.ts.map +1 -1
  26. package/dist/Layer/useLayer.js +15 -6
  27. package/dist/Link/Link.d.ts +1 -1
  28. package/dist/Link/Link.js +2 -2
  29. package/dist/Link/LinkProvider.d.ts +1 -0
  30. package/dist/Link/LinkProvider.d.ts.map +1 -1
  31. package/dist/Link/LinkProvider.js +1 -0
  32. package/dist/Markdown/Markdown.d.ts.map +1 -1
  33. package/dist/Markdown/Markdown.js +13 -3
  34. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  35. package/dist/MobileNav/MobileNav.js +13 -0
  36. package/dist/Pagination/Pagination.d.ts.map +1 -1
  37. package/dist/Pagination/Pagination.js +31 -27
  38. package/dist/Slider/Slider.js +2 -2
  39. package/dist/Table/BaseTable.d.ts.map +1 -1
  40. package/dist/Table/BaseTable.js +27 -8
  41. package/dist/Table/Table.d.ts.map +1 -1
  42. package/dist/Table/Table.js +30 -7
  43. package/dist/Table/TableRow.d.ts +6 -1
  44. package/dist/Table/TableRow.d.ts.map +1 -1
  45. package/dist/Table/TableRow.js +15 -7
  46. package/dist/Table/index.d.ts +3 -1
  47. package/dist/Table/index.d.ts.map +1 -1
  48. package/dist/Table/index.js +1 -0
  49. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  50. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  51. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  52. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  53. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  54. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +369 -0
  55. package/dist/Table/types.d.ts +95 -5
  56. package/dist/Table/types.d.ts.map +1 -1
  57. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  58. package/dist/Table/useBaseTablePlugins.js +1 -1
  59. package/dist/Text/text.stylex.d.ts +2 -2
  60. package/dist/Text/text.stylex.js +2 -2
  61. package/dist/Timestamp/Timestamp.d.ts +1 -1
  62. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  63. package/dist/Timestamp/Timestamp.js +28 -3
  64. package/dist/ToggleButton/ToggleButton.d.ts +7 -9
  65. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  66. package/dist/ToggleButton/ToggleButton.js +34 -57
  67. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  68. package/dist/Tokenizer/Tokenizer.js +1 -1
  69. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  70. package/dist/Tooltip/useTooltip.js +1 -0
  71. package/dist/astryx.css +30 -1
  72. package/dist/astryx.umd.js +147 -0
  73. package/dist/astryx.umd.js.map +7 -0
  74. package/dist/theme/generateThemeRules.js +1 -1
  75. package/dist/theme/syntax/SyntaxTheme.d.ts +1 -1
  76. package/dist/theme/syntax/SyntaxTheme.js +1 -1
  77. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  78. package/dist/theme/syntax/defineSyntaxTheme.js +2 -2
  79. package/dist/theme/syntax/presets.d.ts +1 -1
  80. package/dist/theme/syntax/presets.d.ts.map +1 -1
  81. package/dist/theme/syntax/presets.js +1 -1
  82. package/dist/theme/syntax/tokens.d.ts +1 -1
  83. package/dist/theme/syntax/tokens.js +1 -1
  84. package/dist/theme/types.d.ts +1 -1
  85. package/package.json +6 -2
  86. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  87. package/src/Banner/Banner.doc.mjs +8 -0
  88. package/src/Banner/Banner.test.tsx +16 -7
  89. package/src/Banner/Banner.tsx +9 -2
  90. package/src/Button/Button.doc.mjs +6 -0
  91. package/src/Button/Button.test.tsx +105 -11
  92. package/src/Button/Button.tsx +73 -5
  93. package/src/Chat/ChatComposer.doc.mjs +3 -0
  94. package/src/Chat/ChatComposerDrawer.doc.mjs +12 -0
  95. package/src/CheckboxInput/CheckboxInput.doc.mjs +8 -0
  96. package/src/ClickableCard/ClickableCard.doc.mjs +15 -0
  97. package/src/Dialog/Dialog.doc.mjs +2 -2
  98. package/src/EmptyState/EmptyState.test.tsx +4 -2
  99. package/src/EmptyState/EmptyState.tsx +6 -2
  100. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  101. package/src/HoverCard/HoverCard.test.tsx +189 -2
  102. package/src/HoverCard/HoverCard.tsx +20 -16
  103. package/src/HoverCard/useHoverCard.tsx +13 -10
  104. package/src/Icon/Icon.doc.mjs +8 -0
  105. package/src/Kbd/Kbd.tsx +2 -9
  106. package/src/Layer/anchorName.test.ts +146 -0
  107. package/src/Layer/anchorName.ts +46 -0
  108. package/src/Layer/useLayer.doc.mjs +7 -2
  109. package/src/Layer/useLayer.tsx +33 -9
  110. package/src/Layout/Layout.doc.mjs +1 -1
  111. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  112. package/src/Link/Link.test.tsx +2 -2
  113. package/src/Link/Link.tsx +3 -3
  114. package/src/Link/LinkProvider.tsx +1 -0
  115. package/src/List/List.doc.mjs +7 -0
  116. package/src/Markdown/Markdown.doc.mjs +2 -0
  117. package/src/Markdown/Markdown.test.tsx +17 -26
  118. package/src/Markdown/Markdown.tsx +16 -6
  119. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  120. package/src/MobileNav/MobileNav.tsx +13 -0
  121. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  122. package/src/OverflowList/OverflowList.doc.mjs +17 -0
  123. package/src/Pagination/Pagination.test.tsx +137 -13
  124. package/src/Pagination/Pagination.tsx +33 -28
  125. package/src/ProgressBar/ProgressBar.test.tsx +13 -3
  126. package/src/Resizable/Resizable.doc.mjs +1 -1
  127. package/src/Section/Section.doc.mjs +11 -0
  128. package/src/SelectableCard/SelectableCard.doc.mjs +15 -0
  129. package/src/Selector/Selector.doc.mjs +4 -0
  130. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  131. package/src/Slider/Slider.doc.mjs +4 -0
  132. package/src/Slider/Slider.test.tsx +3 -16
  133. package/src/Slider/Slider.tsx +1 -1
  134. package/src/TabList/TabList.doc.mjs +6 -0
  135. package/src/Table/BaseTable.tsx +51 -24
  136. package/src/Table/Table.doc.mjs +12 -0
  137. package/src/Table/Table.test.tsx +29 -30
  138. package/src/Table/Table.tsx +22 -1
  139. package/src/Table/TableRow.tsx +37 -7
  140. package/src/Table/index.ts +3 -0
  141. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  142. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  143. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +407 -0
  144. package/src/Table/types.ts +101 -4
  145. package/src/Table/useBaseTablePlugins.ts +1 -0
  146. package/src/Table/useTableColumnResize.doc.mjs +96 -0
  147. package/src/Table/useTableStickyColumns.doc.mjs +59 -0
  148. package/src/Text/Heading.doc.mjs +15 -2
  149. package/src/Text/Text.doc.mjs +7 -1
  150. package/src/Text/text.stylex.ts +2 -2
  151. package/src/Timestamp/Timestamp.doc.mjs +9 -0
  152. package/src/Timestamp/Timestamp.test.tsx +44 -18
  153. package/src/Timestamp/Timestamp.tsx +30 -10
  154. package/src/ToggleButton/ToggleButton.doc.mjs +9 -3
  155. package/src/ToggleButton/ToggleButton.test.tsx +39 -18
  156. package/src/ToggleButton/ToggleButton.tsx +46 -83
  157. package/src/Tokenizer/Tokenizer.tsx +4 -1
  158. package/src/Toolbar/Toolbar.doc.mjs +65 -5
  159. package/src/Tooltip/Tooltip.test.tsx +13 -0
  160. package/src/Tooltip/useTooltip.tsx +2 -3
  161. package/src/hooks/useImageMode.test.ts +133 -0
  162. package/src/theme/derivedVarRegistry.test.ts +4 -0
  163. package/src/theme/generateThemeRules.test.ts +7 -8
  164. package/src/theme/generateThemeRules.ts +1 -1
  165. package/src/theme/syntax/SyntaxTheme.tsx +1 -1
  166. package/src/theme/syntax/defineSyntaxTheme.ts +20 -11
  167. package/src/theme/syntax/presets.ts +5 -2
  168. package/src/theme/syntax/tokens.ts +1 -1
  169. package/src/theme/types.ts +1 -1
@@ -0,0 +1,407 @@
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, durationVars, easeVars} 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 → cumulative inline offset (px). The pinned
79
+ * block is the contiguous run from column 0 through the last key in `startKeys`
80
+ * (inclusive of any synthetic columns before it). `null` if none / no context.
81
+ */
82
+ function computeStartOffsets(
83
+ columns: ReadonlyArray<TableColumn<Record<string, unknown>>> | undefined,
84
+ startKeys: string[],
85
+ ): Map<string, number> | null {
86
+ if (!columns || columns.length === 0 || startKeys.length === 0) {
87
+ return null;
88
+ }
89
+ let lastStickyIndex = -1;
90
+ for (let i = 0; i < columns.length; i++) {
91
+ if (startKeys.includes(columns[i].key)) {
92
+ lastStickyIndex = i;
93
+ }
94
+ }
95
+ if (lastStickyIndex === -1) {
96
+ return null;
97
+ }
98
+ const offsets = new Map<string, number>();
99
+ let cumulative = 0;
100
+ for (let i = 0; i <= lastStickyIndex; i++) {
101
+ offsets.set(columns[i].key, cumulative);
102
+ cumulative += getColumnWidth(columns[i]);
103
+ }
104
+ return offsets;
105
+ }
106
+
107
+ /**
108
+ * Mirror of {@link computeStartOffsets} for the END edge — contiguous run from
109
+ * the first key in `endKeys` through the last column, offsets accumulating from
110
+ * the end (last column = 0). `null` if none.
111
+ */
112
+ function computeEndOffsets(
113
+ columns: ReadonlyArray<TableColumn<Record<string, unknown>>> | undefined,
114
+ endKeys: string[],
115
+ ): Map<string, number> | null {
116
+ if (!columns || columns.length === 0 || endKeys.length === 0) {
117
+ return null;
118
+ }
119
+ let firstStickyIndex = -1;
120
+ for (let i = 0; i < columns.length; i++) {
121
+ if (endKeys.includes(columns[i].key)) {
122
+ firstStickyIndex = i;
123
+ break;
124
+ }
125
+ }
126
+ if (firstStickyIndex === -1) {
127
+ return null;
128
+ }
129
+ const offsets = new Map<string, number>();
130
+ let cumulative = 0;
131
+ for (let i = columns.length - 1; i >= firstStickyIndex; i--) {
132
+ offsets.set(columns[i].key, cumulative);
133
+ cumulative += getColumnWidth(columns[i]);
134
+ }
135
+ return offsets;
136
+ }
137
+
138
+ type StickySide = {edge: 'start' | 'end'; offset: number};
139
+
140
+ /**
141
+ * Resolve how a single column should be pinned given the start/end configs and
142
+ * the full column list. A key (mis)configured on both edges resolves to start.
143
+ * Returns `null` for columns that should not be pinned.
144
+ */
145
+ function resolveStickySide(
146
+ columns: ReadonlyArray<TableColumn<Record<string, unknown>>> | undefined,
147
+ columnKey: string,
148
+ startKeys: string[],
149
+ endKeys: string[],
150
+ ): StickySide | null {
151
+ const startOffsets = computeStartOffsets(columns, startKeys);
152
+ if (startOffsets?.has(columnKey)) {
153
+ return {edge: 'start', offset: startOffsets.get(columnKey) ?? 0};
154
+ }
155
+ const endOffsets = computeEndOffsets(columns, endKeys);
156
+ if (endOffsets?.has(columnKey)) {
157
+ return {edge: 'end', offset: endOffsets.get(columnKey) ?? 0};
158
+ }
159
+ return null;
160
+ }
161
+
162
+ // =============================================================================
163
+ // Styles
164
+ // =============================================================================
165
+
166
+ // Scroll-state CSS variables set on the scroll container by the layout ref;
167
+ // the cell ::after shadows read them so each edge's shadow only shows when
168
+ // content is hidden behind it. (A variable, not stylex.when.ancestor, because
169
+ // StyleX ancestor selectors are pseudo-class only — not scroll state.)
170
+ const SHADOW_VAR_START = '--table-sticky-shadow-start';
171
+ const SHADOW_VAR_END = '--table-sticky-shadow-end';
172
+
173
+ const stickyStyles = stylex.create({
174
+ cell: {
175
+ position: 'sticky',
176
+ // Opaque base (overridable; defaults to the card token, the common
177
+ // container) so scrolled content doesn't show through the pinned column.
178
+ backgroundColor: `var(--table-sticky-background, ${colorVars['--color-background-card']})`,
179
+ // Replay the row's overlay on a ::before layer (above the base, below text)
180
+ // so the pinned column mirrors the row's striping/hover exactly via
181
+ // --table-row-overlay. Transitions background-COLOR (not background-image)
182
+ // so hover stays smooth at rest, not just when GPU-promoted while scrolled.
183
+ '::before': {
184
+ content: '""',
185
+ position: 'absolute',
186
+ inset: 0,
187
+ zIndex: -1,
188
+ pointerEvents: 'none',
189
+ backgroundColor: 'var(--table-row-overlay, transparent)',
190
+ transitionProperty: 'background-color',
191
+ transitionDuration: durationVars['--duration-fast'],
192
+ transitionTimingFunction: easeVars['--ease-standard'],
193
+ },
194
+ // padding-box keeps the base off the cell's collapsed divider border.
195
+ backgroundClip: 'padding-box',
196
+ // Sticky cells need visible overflow so the shadow ::after can bleed out.
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
+ // Soft drop shadow over the scrolling region (matches the EPS sticky treatment).
209
+ // border-collapse tables don't paint box-shadow on cells in Chromium, so it's a
210
+ // ::after gradient strip just past the pinned edge (visible thanks to the cell's
211
+ // overflow: visible). Opacity is gated by the scroll-state variable above.
212
+ const SHADOW_WIDTH = '6px';
213
+ // --color-shadow is only ~10% alpha (too faint here); use a slightly stronger soft tint.
214
+ const SHADOW_TINT = 'light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.32))';
215
+ const shadowStyles = stylex.create({
216
+ // start-pinned: shadow falls to the right (inline-end), over scrolled content
217
+ start: {
218
+ '::after': {
219
+ content: '""',
220
+ position: 'absolute',
221
+ top: 0,
222
+ bottom: 0,
223
+ insetInlineEnd: 0,
224
+ width: SHADOW_WIDTH,
225
+ transform: 'translateX(100%)',
226
+ pointerEvents: 'none',
227
+ transition: 'opacity 150ms ease',
228
+ opacity: `var(${SHADOW_VAR_START}, 0)`,
229
+ backgroundImage: `linear-gradient(to right, ${SHADOW_TINT}, transparent)`,
230
+ },
231
+ },
232
+ // end-pinned: shadow falls to the left (inline-start), over scrolled content
233
+ end: {
234
+ '::after': {
235
+ content: '""',
236
+ position: 'absolute',
237
+ top: 0,
238
+ bottom: 0,
239
+ insetInlineStart: 0,
240
+ width: SHADOW_WIDTH,
241
+ transform: 'translateX(-100%)',
242
+ pointerEvents: 'none',
243
+ transition: 'opacity 150ms ease',
244
+ opacity: `var(${SHADOW_VAR_END}, 0)`,
245
+ backgroundImage: `linear-gradient(to left, ${SHADOW_TINT}, transparent)`,
246
+ },
247
+ },
248
+ });
249
+
250
+ // Stable empty default so an unset start/end doesn't allocate a fresh array
251
+ // (and bust the memo) on every render.
252
+ const EMPTY: string[] = [];
253
+
254
+ // =============================================================================
255
+ // Hook
256
+ // =============================================================================
257
+
258
+ export function useTableStickyColumns<T extends Record<string, unknown>>(
259
+ config: UseTableStickyColumnsConfig,
260
+ ): TablePlugin<T> {
261
+ const {startKeys, endKeys} = config;
262
+ const start = startKeys ?? EMPTY;
263
+ const end = endKeys ?? EMPTY;
264
+
265
+ const hasStart = start.length > 0;
266
+ const hasEnd = end.length > 0;
267
+
268
+ // Live snapshot of the resolved config, read inside the (memoized) transforms
269
+ // and the scroll-shadow callback. Keeping these in a ref lets the plugin memo
270
+ // be computed once (deps: only the stable scroll-shadow callback) while never
271
+ // reading stale config — consumers typically pass fresh array literals each
272
+ // render, so we never want array identity in the deps.
273
+ const stateRef = useRef({start, end, hasStart, hasEnd});
274
+ stateRef.current = {start, end, hasStart, hasEnd};
275
+
276
+ // Scroll-aware shadows: toggle CSS variables on the scroll container so each
277
+ // edge's shadow only paints when there is hidden, horizontally-scrolled
278
+ // content behind that edge. Implemented with a callback ref + scroll/resize
279
+ // listeners (synchronizing with the DOM) rather than React state, so
280
+ // scrolling never triggers re-renders.
281
+ const detachRef = useRef<(() => void) | null>(null);
282
+ const attachScrollShadow = useCallback((el: HTMLDivElement | null) => {
283
+ detachRef.current?.();
284
+ detachRef.current = null;
285
+ if (!el) {
286
+ return;
287
+ }
288
+ const update = () => {
289
+ const {hasStart: hs, hasEnd: he} = stateRef.current;
290
+ const maxScroll = el.scrollWidth - el.clientWidth;
291
+ const hasOverflow = maxScroll > 1;
292
+ if (hs) {
293
+ el.style.setProperty(
294
+ SHADOW_VAR_START,
295
+ hasOverflow && el.scrollLeft > 1 ? '1' : '0',
296
+ );
297
+ }
298
+ if (he) {
299
+ el.style.setProperty(
300
+ SHADOW_VAR_END,
301
+ hasOverflow && el.scrollLeft < maxScroll - 1 ? '1' : '0',
302
+ );
303
+ }
304
+ };
305
+ el.addEventListener('scroll', update, {passive: true});
306
+ const resizeObserver =
307
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
308
+ resizeObserver?.observe(el);
309
+ update();
310
+ detachRef.current = () => {
311
+ el.removeEventListener('scroll', update);
312
+ resizeObserver?.disconnect();
313
+ };
314
+ }, []);
315
+
316
+ return useMemo(
317
+ (): TablePlugin<T> => ({
318
+ transformHeaderCell(
319
+ props: HeaderCellRenderProps,
320
+ column: TableColumn<T>,
321
+ ): HeaderCellRenderProps {
322
+ const {start: s, end: e} = stateRef.current;
323
+ const side = resolveStickySide(props.columns, column.key, s, e);
324
+ if (!side) {
325
+ return props;
326
+ }
327
+ // position/inline-offset are runtime values → set via inline style so
328
+ // they are authoritative regardless of plugin composition order (the
329
+ // resize plugin also writes inline style on header cells).
330
+ const offsetStyle: CSSProperties =
331
+ side.edge === 'start'
332
+ ? {insetInlineStart: `${side.offset}px`}
333
+ : {insetInlineEnd: `${side.offset}px`};
334
+ return {
335
+ ...props,
336
+ htmlProps: {
337
+ ...props.htmlProps,
338
+ style: {...props.htmlProps.style, ...offsetStyle},
339
+ },
340
+ styles: [
341
+ ...props.styles,
342
+ stickyStyles.cell,
343
+ stickyStyles.headerCell,
344
+ side.edge === 'start' ? shadowStyles.start : shadowStyles.end,
345
+ ],
346
+ };
347
+ },
348
+
349
+ transformBodyCell(
350
+ props: BodyCellRenderProps,
351
+ column: TableColumn<T>,
352
+ ): BodyCellRenderProps {
353
+ const {start: s, end: e} = stateRef.current;
354
+ const side = resolveStickySide(props.columns, column.key, s, e);
355
+ if (!side) {
356
+ return props;
357
+ }
358
+ const offsetStyle: CSSProperties =
359
+ side.edge === 'start'
360
+ ? {insetInlineStart: `${side.offset}px`}
361
+ : {insetInlineEnd: `${side.offset}px`};
362
+ return {
363
+ ...props,
364
+ htmlProps: {
365
+ ...props.htmlProps,
366
+ style: {...props.htmlProps.style, ...offsetStyle},
367
+ },
368
+ styles: [
369
+ ...props.styles,
370
+ stickyStyles.cell,
371
+ stickyStyles.bodyCell,
372
+ side.edge === 'start' ? shadowStyles.start : shadowStyles.end,
373
+ ],
374
+ };
375
+ },
376
+
377
+ transformScrollWrapper(
378
+ props: ScrollWrapperRenderProps,
379
+ ): ScrollWrapperRenderProps {
380
+ // No pinned edges → nothing to gate; leave the wrapper untouched.
381
+ if (!stateRef.current.hasStart && !stateRef.current.hasEnd) {
382
+ return props;
383
+ }
384
+ // Compose with any ref a prior plugin (e.g. virtualization) set on the
385
+ // scroll container.
386
+ const existingRef = props.htmlProps.ref;
387
+ const mergedRef = (node: HTMLDivElement | null) => {
388
+ attachScrollShadow(node);
389
+ if (typeof existingRef === 'function') {
390
+ existingRef(node);
391
+ } else if (existingRef != null) {
392
+ // RefObject — assign through its writable `.current`.
393
+ existingRef.current = node;
394
+ }
395
+ };
396
+ return {
397
+ ...props,
398
+ htmlProps: {...props.htmlProps, ref: mergedRef},
399
+ };
400
+ },
401
+ }),
402
+ // The returned plugin's transforms read live config from `stateRef` and
403
+ // `attachScrollShadow` is stable (empty deps), so the plugin object can be
404
+ // computed once and reused across renders.
405
+ [attachScrollShadow],
406
+ );
407
+ }
@@ -261,6 +261,20 @@ export interface HeaderCellRenderProps {
261
261
  overlay?: ReactNode;
262
262
  /** Content rendered below the header label row (e.g. inline filter controls). */
263
263
  below?: ReactNode;
264
+ /**
265
+ * Index of this column within the final, ordered list of rendered columns
266
+ * (after column injection/reordering by other plugins). Populated by
267
+ * BaseTable. Optional for backward compatibility with hand-constructed
268
+ * renders in tests.
269
+ */
270
+ columnIndex?: number;
271
+ /**
272
+ * The full, final ordered list of columns being rendered (after column
273
+ * injection/reordering by other plugins). Populated by BaseTable so plugins
274
+ * can reason about column position — e.g. cumulative sticky offsets. Optional
275
+ * for backward compatibility.
276
+ */
277
+ columns?: ReadonlyArray<TableColumn<Record<string, unknown>>>;
264
278
  }
265
279
 
266
280
  /** Props passed through the plugin pipeline for each body `<tr>` */
@@ -276,6 +290,49 @@ export interface BodyRowRenderProps {
276
290
  export interface BodyCellRenderProps {
277
291
  htmlProps: TdHTMLAttributes<HTMLTableCellElement>;
278
292
  styles: StyleXStyles[];
293
+ /**
294
+ * Index of this cell's column within the final ordered column list.
295
+ * Mirrors the `columnIndex` passed to `transformHeaderCell`. Populated by
296
+ * BaseTable. Optional for backward compatibility with hand-constructed
297
+ * renders in tests.
298
+ */
299
+ columnIndex?: number;
300
+ /**
301
+ * The full, final ordered list of columns being rendered. Populated by
302
+ * BaseTable so plugins can reason about column position — e.g. cumulative
303
+ * sticky offsets. Optional for backward compatibility.
304
+ */
305
+ columns?: ReadonlyArray<TableColumn<Record<string, unknown>>>;
306
+ }
307
+
308
+ /**
309
+ * Props passed through the plugin pipeline for the scroll-wrapper region — the
310
+ * `<div>` wrapping the `<table>` element (the horizontal scroll container, see
311
+ * the `scrollWrapper` prop on `BaseTableProps`). Lets plugins attach a `ref` to
312
+ * the scrollable element (e.g. for scroll-aware sticky-column shadows or
313
+ * virtualization) and inject chrome before/after the table.
314
+ *
315
+ * Named after `scrollWrapper` (not "layout") to avoid ambiguity: it transforms
316
+ * the wrapper element, not the internal header/body/footer layout of `<table>`.
317
+ *
318
+ * Runs after `transformTable`/cell transforms but inside `transformTableContext`,
319
+ * so plugin chrome added here stays within any context providers but wraps the
320
+ * scroll area.
321
+ */
322
+ export interface ScrollWrapperRenderProps {
323
+ /**
324
+ * HTML attributes applied to the scroll container `<div>`, including an
325
+ * optional `ref`. Plugins compose refs by reading the existing `ref` and
326
+ * merging their own (see `useTableStickyColumns`).
327
+ */
328
+ htmlProps: HTMLAttributes<HTMLDivElement> & {
329
+ ref?: Ref<HTMLDivElement>;
330
+ };
331
+ styles: StyleXStyles[];
332
+ /** Content rendered before the `<table>`, inside the scroll container. */
333
+ beforeTable?: ReactNode;
334
+ /** Content rendered after the `<table>`, inside the scroll container. */
335
+ afterTable?: ReactNode;
279
336
  }
280
337
 
281
338
  // =============================================================================
@@ -294,7 +351,8 @@ export interface BodyCellRenderProps {
294
351
  * 4. `transformHeaderCell` — transform each `<th>` props
295
352
  * 5. `transformBodyRow` — transform each body `<tr>` props
296
353
  * 6. `transformBodyCell` — transform each body `<td>` props
297
- * 7. `transformTableContext` — wrap the table output in context providers
354
+ * 7. `transformScrollWrapper` — transform the scroll-container wrapper around the table
355
+ * 8. `transformTableContext` — wrap the table output in context providers
298
356
  */
299
357
  export interface TablePlugin<
300
358
  T extends Record<string, unknown> = Record<string, unknown>,
@@ -309,10 +367,17 @@ export interface TablePlugin<
309
367
  transformTable?: (props: TableRenderProps) => TableRenderProps;
310
368
  /** Transform the header `<tr>` props */
311
369
  transformHeaderRow?: (props: HeaderRowRenderProps) => HeaderRowRenderProps;
312
- /** Transform each `<th>` props */
370
+ /**
371
+ * Transform each `<th>` props.
372
+ *
373
+ * `columnIndex` and the full `columns` list are provided for plugins that
374
+ * need to reason about column position (e.g. cumulative sticky offsets).
375
+ */
313
376
  transformHeaderCell?: (
314
377
  props: HeaderCellRenderProps,
315
378
  column: TableColumn<T>,
379
+ columnIndex: number,
380
+ columns: ReadonlyArray<TableColumn<T>>,
316
381
  ) => HeaderCellRenderProps;
317
382
  /** Transform each body `<tr>` props */
318
383
  transformBodyRow?: (
@@ -320,12 +385,28 @@ export interface TablePlugin<
320
385
  item: T,
321
386
  index: number,
322
387
  ) => BodyRowRenderProps;
323
- /** Transform each body `<td>` props */
388
+ /**
389
+ * Transform each body `<td>` props.
390
+ *
391
+ * `columnIndex` and the full `columns` list are provided for plugins that
392
+ * need to reason about column position (e.g. cumulative sticky offsets).
393
+ */
324
394
  transformBodyCell?: (
325
395
  props: BodyCellRenderProps,
326
396
  column: TableColumn<T>,
327
397
  item: T,
398
+ columnIndex: number,
399
+ columns: ReadonlyArray<TableColumn<T>>,
328
400
  ) => BodyCellRenderProps;
401
+ /**
402
+ * Transform the scroll-wrapper region — the `<div>` wrapping the `<table>`
403
+ * (see the `scrollWrapper` prop). Use to attach a `ref` to the scrollable
404
+ * element (scroll-aware shadows, virtualization) or to inject chrome
405
+ * before/after the table.
406
+ */
407
+ transformScrollWrapper?: (
408
+ props: ScrollWrapperRenderProps,
409
+ ) => ScrollWrapperRenderProps;
329
410
  /** Wrap the table output in context providers */
330
411
  transformTableContext?: (children: ReactNode) => ReactNode;
331
412
  }
@@ -340,6 +421,11 @@ export interface TableRowComponentProps extends HTMLAttributes<HTMLTableRowEleme
340
421
  ref?: Ref<HTMLTableRowElement>;
341
422
  children: ReactNode;
342
423
  xstyle?: StyleXStyles[];
424
+ /**
425
+ * Whether this row is the header row. Header rows skip the striped/hover
426
+ * row styling, which is only meant for body rows.
427
+ */
428
+ isHeaderRow?: boolean;
343
429
  }
344
430
 
345
431
  /** Props for cell components used in the components prop */
@@ -390,8 +476,19 @@ export interface BaseTableProps<
390
476
  * plugin `transformTableContext` layer. Used by `Table` to add a
391
477
  * horizontal scroll container so plugin chrome (pagination, toolbars)
392
478
  * stays outside the scrollable area.
479
+ *
480
+ * Receives `htmlProps` (including an optional `ref`) and `styles` produced
481
+ * by the plugin `transformScrollWrapper` pipeline, plus `beforeTable`/`afterTable`
482
+ * chrome. The wrapper must spread `htmlProps` (and apply `styles`) onto its
483
+ * scroll-container element so plugins can attach refs / scroll listeners.
393
484
  */
394
- scrollWrapper?: ComponentType<{children: ReactNode}>;
485
+ scrollWrapper?: ComponentType<{
486
+ children: ReactNode;
487
+ htmlProps?: HTMLAttributes<HTMLDivElement> & {ref?: Ref<HTMLDivElement>};
488
+ styles?: StyleXStyles[];
489
+ beforeTable?: ReactNode;
490
+ afterTable?: ReactNode;
491
+ }>;
395
492
  /**
396
493
  * How default-rendered body cell text behaves when it exceeds column width.
397
494
  *
@@ -87,6 +87,7 @@ const VALID_TRANSFORM_KEYS = new Set([
87
87
  'transformHeaderCell',
88
88
  'transformBodyRow',
89
89
  'transformBodyCell',
90
+ 'transformScrollWrapper',
90
91
  'transformTableContext',
91
92
  ]);
92
93
 
@@ -0,0 +1,96 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('../docs-types').ComponentDoc} */
4
+
5
+ export const docs = {
6
+ name: 'useTableColumnResize',
7
+ subComponentOf: 'Table',
8
+ displayName: 'useTableColumnResize',
9
+ description:
10
+ 'Hook that returns a TablePlugin adding draggable resize handles to column header borders. Supports pointer + keyboard (WAI-ARIA window splitter) resizing, RTL, per-column min/max widths, and proportional-column preservation (resizes the neighbor so the table stays full-width). Commits widths on release.',
11
+ props: [
12
+ {
13
+ name: 'columnWidths',
14
+ type: 'Record<string, number>',
15
+ description:
16
+ 'Controlled pixel-width overrides keyed by column key. When a key is present it overrides the column\'s declared width.',
17
+ },
18
+ {
19
+ name: 'onColumnResizeEnd',
20
+ type: '(updates: Record<string, number>) => void',
21
+ description:
22
+ 'Called when a resize completes (pointerup / Enter). Receives a map of every column key whose width changed — merge it into your columnWidths state.',
23
+ },
24
+ {
25
+ name: 'minWidth',
26
+ type: 'number',
27
+ description:
28
+ 'Global minimum column width in pixels during resize. Overrides per-column defaults when set.',
29
+ },
30
+ {
31
+ name: 'maxWidth',
32
+ type: 'number',
33
+ description: 'Global maximum column width in pixels during resize.',
34
+ default: 'Infinity',
35
+ },
36
+ {
37
+ name: 'columns',
38
+ type: 'TableColumn<T>[]',
39
+ description:
40
+ 'Column definitions — needed to derive per-column min widths and detect proportional vs pixel columns for neighbor/last-column resize behavior.',
41
+ },
42
+ ],
43
+ };
44
+
45
+ export const docsZh = {
46
+ name: 'useTableColumnResize',
47
+ displayName: 'useTableColumnResize',
48
+ description:
49
+ '返回 TablePlugin 的 Hook,为列表头边框添加可拖动的调整手柄。支持指针 + 键盘(WAI-ARIA 窗口分隔符)调整、RTL、每列最小/最大宽度,以及比例列保留(调整相邻列以保持表格全宽)。在释放时提交宽度。',
50
+ props: [
51
+ {
52
+ name: 'columnWidths',
53
+ type: 'Record<string, number>',
54
+ description: '受控的像素宽度覆盖,按列键索引。存在某键时会覆盖该列声明的宽度。',
55
+ },
56
+ {
57
+ name: 'onColumnResizeEnd',
58
+ type: '(updates: Record<string, number>) => void',
59
+ description:
60
+ '调整完成时(pointerup / Enter)调用。接收所有宽度发生变化的列键的映射——将其合并到你的 columnWidths 状态中。',
61
+ },
62
+ {
63
+ name: 'minWidth',
64
+ type: 'number',
65
+ description: '调整期间的全局最小列宽(像素)。设置后覆盖每列默认值。',
66
+ },
67
+ {
68
+ name: 'maxWidth',
69
+ type: 'number',
70
+ description: '调整期间的全局最大列宽(像素)。',
71
+ default: 'Infinity',
72
+ },
73
+ {
74
+ name: 'columns',
75
+ type: 'TableColumn<T>[]',
76
+ description:
77
+ '列定义——用于推导每列最小宽度,并区分比例列与像素列以确定相邻列/末列的调整行为。',
78
+ },
79
+ ],
80
+ };
81
+
82
+ export const docsDense = {
83
+ name: 'useTableColumnResize',
84
+ displayName: 'useTableColumnResize',
85
+ description:
86
+ 'Hook returning TablePlugin w/ draggable column resize handles. Pointer + keyboard (WAI-ARIA splitter), RTL, per-column min/max, proportional-preserving (resizes neighbor). Commits on release.',
87
+ propDescriptions: {
88
+ columnWidths: 'Controlled pixel-width overrides keyed by column key.',
89
+ onColumnResizeEnd:
90
+ 'Called on resize end; receives map of all changed column widths to merge into state.',
91
+ minWidth: 'Global min column width (px) during resize.',
92
+ maxWidth: 'Global max column width (px) during resize. Default Infinity.',
93
+ columns:
94
+ 'Column defs — derive per-column mins + proportional/pixel detection for neighbor/last-column behavior.',
95
+ },
96
+ };