@astryxdesign/core 0.4.6 → 0.4.7-canary.29be96d

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 (197) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -0
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +54 -15
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
  6. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
  7. package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
  8. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetPanel.js +1 -0
  10. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
  11. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  12. package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
  13. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  14. package/dist/BottomSheet/useSheetGestures.js +23 -5
  15. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  16. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -5
  17. package/dist/Calendar/Calendar.d.ts +3 -1
  18. package/dist/Calendar/Calendar.d.ts.map +1 -1
  19. package/dist/Calendar/Calendar.js +13 -14
  20. package/dist/Calendar/getInitialFocusDate.d.ts +46 -0
  21. package/dist/Calendar/getInitialFocusDate.d.ts.map +1 -0
  22. package/dist/Calendar/getInitialFocusDate.js +62 -0
  23. package/dist/Chat/ChatMessageList.d.ts +22 -3
  24. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  25. package/dist/Chat/ChatMessageList.js +6 -3
  26. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  27. package/dist/DateInput/TouchDateField.js +34 -1
  28. package/dist/Dialog/Dialog.d.ts +7 -1
  29. package/dist/Dialog/Dialog.d.ts.map +1 -1
  30. package/dist/Dialog/Dialog.js +48 -22
  31. package/dist/Heading/Heading.d.ts.map +1 -1
  32. package/dist/Heading/Heading.js +6 -2
  33. package/dist/Kbd/Kbd.d.ts.map +1 -1
  34. package/dist/Kbd/Kbd.js +10 -3
  35. package/dist/Markdown/index.d.ts +1 -1
  36. package/dist/Markdown/index.d.ts.map +1 -1
  37. package/dist/Markdown/parser.d.ts +38 -1
  38. package/dist/Markdown/parser.d.ts.map +1 -1
  39. package/dist/Markdown/parser.js +149 -23
  40. package/dist/MultiSelector/MultiSelector.d.ts +15 -1
  41. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  42. package/dist/MultiSelector/MultiSelector.js +17 -6
  43. package/dist/MultiSelector/index.d.ts +1 -1
  44. package/dist/MultiSelector/index.d.ts.map +1 -1
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +5 -0
  47. package/dist/Stepper/Step.d.ts +140 -0
  48. package/dist/Stepper/Step.d.ts.map +1 -0
  49. package/dist/Stepper/Step.js +1045 -0
  50. package/dist/Stepper/StepStatus.d.ts +24 -0
  51. package/dist/Stepper/StepStatus.d.ts.map +1 -0
  52. package/dist/Stepper/StepStatus.js +1 -0
  53. package/dist/Stepper/Stepper.d.ts +93 -0
  54. package/dist/Stepper/Stepper.d.ts.map +1 -0
  55. package/dist/Stepper/Stepper.js +184 -0
  56. package/dist/Stepper/StepperContext.d.ts +41 -0
  57. package/dist/Stepper/StepperContext.d.ts.map +1 -0
  58. package/dist/Stepper/StepperContext.js +34 -0
  59. package/dist/Stepper/index.d.ts +8 -0
  60. package/dist/Stepper/index.d.ts.map +1 -0
  61. package/dist/Stepper/index.js +7 -0
  62. package/dist/Stepper/stepper.stylex.d.ts +15 -0
  63. package/dist/Stepper/stepper.stylex.d.ts.map +1 -0
  64. package/dist/Stepper/stepper.stylex.js +20 -0
  65. package/dist/TabList/Tab.d.ts.map +1 -1
  66. package/dist/TabList/Tab.js +5 -1
  67. package/dist/Table/BaseTable.d.ts.map +1 -1
  68. package/dist/Table/BaseTable.js +4 -1
  69. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  70. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +20 -8
  71. package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts.map +1 -1
  72. package/dist/Table/plugins/rowStatus/useTableRowStatus.js +10 -3
  73. package/dist/Table/plugins/selection/useTableSelection.d.ts +16 -0
  74. package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
  75. package/dist/Table/plugins/selection/useTableSelection.js +19 -5
  76. package/dist/Table/types.d.ts +22 -4
  77. package/dist/Table/types.d.ts.map +1 -1
  78. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  79. package/dist/Table/useBaseTablePlugins.js +5 -0
  80. package/dist/Text/Text.d.ts.map +1 -1
  81. package/dist/Text/Text.js +6 -2
  82. package/dist/astryx.css +41 -1
  83. package/dist/hooks/index.d.ts +1 -0
  84. package/dist/hooks/index.d.ts.map +1 -1
  85. package/dist/hooks/index.js +1 -0
  86. package/dist/hooks/useHotkeys.d.ts.map +1 -1
  87. package/dist/hooks/useHotkeys.js +10 -3
  88. package/dist/hooks/useListFocus.d.ts +5 -2
  89. package/dist/hooks/useListFocus.d.ts.map +1 -1
  90. package/dist/hooks/useListFocus.js +12 -6
  91. package/dist/hooks/useMergedRefs.d.ts +18 -0
  92. package/dist/hooks/useMergedRefs.d.ts.map +1 -0
  93. package/dist/hooks/useMergedRefs.js +25 -0
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +1 -0
  97. package/dist/utils/timeParser.d.ts +1 -1
  98. package/dist/utils/timeParser.d.ts.map +1 -1
  99. package/dist/utils/timeParser.js +26 -12
  100. package/locales/en.json +14 -6
  101. package/locales/pseudo.json +6 -0
  102. package/package.json +8 -3
  103. package/src/Avatar/Avatar.doc.mjs +2 -1
  104. package/src/BottomSheet/BottomSheet.test.tsx +123 -0
  105. package/src/BottomSheet/BottomSheet.tsx +36 -4
  106. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
  107. package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
  108. package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
  109. package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
  110. package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
  111. package/src/BottomSheet/useSheetGestures.test.ts +27 -0
  112. package/src/BottomSheet/useSheetGestures.ts +25 -5
  113. package/src/Breadcrumbs/BreadcrumbItem.tsx +2 -1
  114. package/src/Button/Button.doc.mjs +22 -0
  115. package/src/Calendar/Calendar.doc.mjs +4 -3
  116. package/src/Calendar/Calendar.test.tsx +52 -0
  117. package/src/Calendar/Calendar.tsx +18 -15
  118. package/src/Calendar/getInitialFocusDate.test.ts +94 -0
  119. package/src/Calendar/getInitialFocusDate.ts +93 -0
  120. package/src/Chat/ChatMessageList.doc.mjs +9 -1
  121. package/src/Chat/ChatMessageList.test.tsx +46 -0
  122. package/src/Chat/ChatMessageList.tsx +28 -4
  123. package/src/CodeBlock/CodeBlock.doc.mjs +6 -0
  124. package/src/ContextMenu/ContextMenu.doc.mjs +5 -0
  125. package/src/DateInput/DateInputTouch.test.tsx +36 -0
  126. package/src/DateInput/TouchDateField.tsx +35 -1
  127. package/src/Dialog/Dialog.doc.mjs +8 -3
  128. package/src/Dialog/Dialog.test.tsx +71 -0
  129. package/src/Dialog/Dialog.tsx +72 -20
  130. package/src/Field/Field.doc.mjs +11 -0
  131. package/src/Heading/Heading.test.tsx +23 -1
  132. package/src/Heading/Heading.tsx +6 -2
  133. package/src/Kbd/Kbd.test.tsx +17 -0
  134. package/src/Kbd/Kbd.tsx +10 -3
  135. package/src/Link/Link.doc.mjs +11 -0
  136. package/src/Markdown/index.ts +1 -0
  137. package/src/Markdown/parser.perf.test.ts +71 -1
  138. package/src/Markdown/parser.test.ts +145 -2
  139. package/src/Markdown/parser.ts +208 -22
  140. package/src/MoreMenu/MoreMenu.doc.mjs +7 -1
  141. package/src/MultiSelector/MultiSelector.doc.mjs +10 -0
  142. package/src/MultiSelector/MultiSelector.test.tsx +105 -0
  143. package/src/MultiSelector/MultiSelector.tsx +50 -6
  144. package/src/MultiSelector/index.ts +1 -0
  145. package/src/NumberInput/NumberInput.doc.mjs +5 -0
  146. package/src/Popover/Popover.test.tsx +27 -1
  147. package/src/PowerSearch/PowerSearch.doc.mjs +10 -0
  148. package/src/Selector/Selector.doc.mjs +11 -0
  149. package/src/Selector/Selector.test.tsx +21 -0
  150. package/src/Selector/Selector.tsx +11 -1
  151. package/src/Stepper/Step.doc.mjs +98 -0
  152. package/src/Stepper/Step.tsx +1638 -0
  153. package/src/Stepper/StepStatus.ts +26 -0
  154. package/src/Stepper/Stepper.doc.mjs +370 -0
  155. package/src/Stepper/Stepper.test.tsx +1024 -0
  156. package/src/Stepper/Stepper.tsx +258 -0
  157. package/src/Stepper/StepperContext.ts +71 -0
  158. package/src/Stepper/index.ts +18 -0
  159. package/src/Stepper/stepper.stylex.ts +19 -0
  160. package/src/TabList/Tab.tsx +5 -1
  161. package/src/TabList/TabList.test.tsx +21 -4
  162. package/src/Table/BaseTable.tsx +6 -3
  163. package/src/Table/Table.test.tsx +35 -0
  164. package/src/Table/plugins/groupedRows/useTableGroupedRows-perf.test.tsx +112 -0
  165. package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +100 -0
  166. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +17 -8
  167. package/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +13 -7
  168. package/src/Table/plugins/rowStatus/useTableRowStatus.tsx +12 -3
  169. package/src/Table/plugins/selection/useTableSelection.test.tsx +76 -0
  170. package/src/Table/plugins/selection/useTableSelection.tsx +40 -7
  171. package/src/Table/types.ts +22 -4
  172. package/src/Table/useBaseTablePlugins.ts +5 -0
  173. package/src/Table/useTableGroupedRows.doc.mjs +5 -4
  174. package/src/Table/useTableRowStatus.doc.mjs +1 -1
  175. package/src/Table/useTableSelection.doc.mjs +31 -0
  176. package/src/Text/Text.test.tsx +23 -1
  177. package/src/Text/Text.tsx +6 -2
  178. package/src/TextInput/TextInput.doc.mjs +10 -0
  179. package/src/Toast/Toast.doc.mjs +6 -0
  180. package/src/Tokenizer/Tokenizer.doc.mjs +10 -0
  181. package/src/Toolbar/Toolbar.doc.mjs +5 -0
  182. package/src/__tests__/apiContractDrift.test.tsx +80 -0
  183. package/src/__tests__/fieldContract.test.tsx +24 -0
  184. package/src/__tests__/structuralComponentContract.test.tsx +39 -0
  185. package/src/hooks/index.ts +2 -0
  186. package/src/hooks/useHotkeys.test.ts +18 -0
  187. package/src/hooks/useHotkeys.ts +10 -3
  188. package/src/hooks/useListFocus.doc.mjs +2 -2
  189. package/src/hooks/useListFocus.test.tsx +65 -3
  190. package/src/hooks/useListFocus.ts +15 -7
  191. package/src/hooks/useMergedRefs.doc.mjs +79 -0
  192. package/src/hooks/useMergedRefs.test.tsx +62 -0
  193. package/src/hooks/useMergedRefs.ts +36 -0
  194. package/src/index.ts +1 -0
  195. package/src/theme/MediaTheme.doc.mjs +5 -5
  196. package/src/utils/timeParser.test.ts +6 -1
  197. package/src/utils/timeParser.ts +35 -13
@@ -0,0 +1,258 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file Stepper.tsx
7
+ * @input Uses React, stylex, theme tokens, StepperContext
8
+ * @output Exports Stepper component and StepperProps
9
+ * @position Core container component; consumed by index.ts
10
+ *
11
+ * Besides the props it is given, this component tracks the `activeStep` it
12
+ * last rendered with and publishes it on the context. Steps need the distance
13
+ * and direction of a change to choreograph their connector fill; see the
14
+ * CONNECTOR FILL block in Step.tsx.
15
+ *
16
+ * SYNC: When modified, update these files to stay in sync:
17
+ * - /packages/core/src/Stepper/Stepper.doc.mjs (props table, features, implementation notes)
18
+ * - /packages/core/src/Stepper/Stepper.test.tsx (tests for new/changed behavior)
19
+ * - /packages/core/src/Stepper/index.ts (exports if types change)
20
+ * - /apps/storybook/stories/Stepper.stories.tsx (storybook stories)
21
+ * - /packages/cli/assets/templates/blocks/components/Stepper/ (showcase blocks)
22
+ */
23
+
24
+ import {useCallback, useMemo, useRef, useState, type ReactNode} from 'react';
25
+ import * as stylex from '@stylexjs/stylex';
26
+
27
+ import {spacingVars} from '../theme/tokens.stylex';
28
+ import {mergeProps} from '../utils';
29
+ import type {BaseProps} from '../BaseProps';
30
+ import {themeProps} from '../utils';
31
+ import {useTranslator} from '../i18n';
32
+ import {
33
+ StepperContext,
34
+ type StepperOrientation,
35
+ type StepperIndicatorPosition,
36
+ type StepperContextValue,
37
+ } from './StepperContext';
38
+
39
+ export interface StepperProps extends BaseProps<HTMLOListElement> {
40
+ /** Ref forwarded to the root element */
41
+ ref?: React.Ref<HTMLOListElement>;
42
+ /**
43
+ * Zero-based index of the active step.
44
+ */
45
+ activeStep: number;
46
+ /**
47
+ * Step elements to render.
48
+ */
49
+ children: ReactNode;
50
+ /**
51
+ * Layout direction of the stepper.
52
+ * @default 'horizontal'
53
+ */
54
+ orientation?: StepperOrientation;
55
+ /**
56
+ * Called when a step indicator is clicked. Enables non-linear navigation.
57
+ * When provided, completed and current steps become clickable.
58
+ */
59
+ onStepClick?: (index: number) => void;
60
+ /**
61
+ * Accessible label describing the set of steps. Defaults to a translated
62
+ * "Progress" when unset.
63
+ */
64
+ label?: string;
65
+ /**
66
+ * Controls density (padding) of all steps.
67
+ * @default 'balanced'
68
+ */
69
+ density?: 'compact' | 'balanced' | 'spacious';
70
+ /**
71
+ * Controls where each step's indicator sits relative to the connector track.
72
+ * - 'separated': indicator lives in the label row, distinct from the progress
73
+ * bar (the original Astryx layout).
74
+ * - 'on-track': indicator is slotted into the connector line as a node on the
75
+ * track (the on-track indicator design).
76
+ * @default 'separated'
77
+ */
78
+ indicatorPosition?: StepperIndicatorPosition;
79
+ }
80
+
81
+ const styles = stylex.create({
82
+ root: {
83
+ display: 'flex',
84
+ width: '100%',
85
+ listStyleType: 'none',
86
+ margin: 0,
87
+ padding: 0,
88
+ },
89
+ horizontal: {
90
+ flexDirection: 'row',
91
+ alignItems: 'flex-start',
92
+ gap: spacingVars['--spacing-0-5'],
93
+ },
94
+ vertical: {
95
+ flexDirection: 'column',
96
+ gap: spacingVars['--spacing-0-5'],
97
+ },
98
+ // On-track: steps must abut so their connector segments form one continuous
99
+ // line, so the inter-step gap collapses to zero.
100
+ horizontalOnTrack: {
101
+ flexDirection: 'row',
102
+ alignItems: 'flex-start',
103
+ gap: 0,
104
+ },
105
+ verticalOnTrack: {
106
+ flexDirection: 'column',
107
+ gap: 0,
108
+ },
109
+ });
110
+
111
+ /**
112
+ * A stepper component for multi-step workflows. Displays numbered steps
113
+ * with visual indicators for completed, active, and upcoming states.
114
+ *
115
+ * Each Step child must provide a `step` prop (zero-based index) so it
116
+ * can derive its state from the parent's activeStep. The on-track layout
117
+ * hides the leading connector on the first step and the trailing connector
118
+ * on the last step structurally, from each step's own `<li>` position, so
119
+ * it works regardless of how the steps are grouped.
120
+ *
121
+ * Rendered as an ordered list (`<ol>`/`<li>`) rather than a `nav`
122
+ * landmark: a stepper communicates *progress through a sequence*, not a
123
+ * set of site navigation links. The active step is marked with
124
+ * `aria-current="step"` (handled per-step) and the list carries an
125
+ * accessible `label`. This follows the WAI-ARIA pattern for steppers /
126
+ * progress sequences and avoids polluting the page's landmark map.
127
+ *
128
+ * @example
129
+ * ```
130
+ * <Stepper activeStep={1}>
131
+ * <Step step={0} label="Account" />
132
+ * <Step step={1} label="Profile" />
133
+ * <Step step={2} label="Review" />
134
+ * </Stepper>
135
+ * ```
136
+ */
137
+ export function Stepper({
138
+ activeStep,
139
+ children,
140
+ orientation = 'horizontal',
141
+ onStepClick,
142
+ label: labelFromProps,
143
+ density = 'balanced',
144
+ indicatorPosition = 'separated',
145
+ xstyle,
146
+ className,
147
+ style,
148
+ ref,
149
+ ...rest
150
+ }: StepperProps) {
151
+ const t = useTranslator();
152
+ const label = labelFromProps ?? t('@astryx.stepper.label');
153
+
154
+ // Dev-mode duplicate step index detection. Steps register on mount and
155
+ // deregister on unmount; a Map tracks count per index so we can warn when
156
+ // two Steps share the same `step` value (which breaks aria-current).
157
+ const stepCountsRef = useRef<Map<number, number>>(new Map());
158
+ const registerStep = useCallback((index: number) => {
159
+ const counts = stepCountsRef.current;
160
+ const prev = counts.get(index) ?? 0;
161
+ counts.set(index, prev + 1);
162
+ if (process.env.NODE_ENV !== 'production' && prev + 1 > 1) {
163
+ console.warn(
164
+ `[Stepper] Duplicate step index ${index}: two <Step> elements share the same \`step\` value. ` +
165
+ `This breaks \`aria-current="step"\` and causes both to show as active simultaneously.`,
166
+ );
167
+ }
168
+ return () => {
169
+ const cur = counts.get(index) ?? 1;
170
+ if (cur <= 1) {
171
+ counts.delete(index);
172
+ } else {
173
+ counts.set(index, cur - 1);
174
+ }
175
+ };
176
+ }, []);
177
+
178
+ // The step we came *from*. Steps need it to stagger their connector fill:
179
+ // the distance and direction of the change decide which segment moves first
180
+ // and how long the whole sweep may take (see Step.tsx's CONNECTOR FILL).
181
+ //
182
+ // Derived during render from state rather than written in an effect. An
183
+ // effect runs after paint, so the browser would already have committed the
184
+ // new fill states with last render's delays — the first frame of the sweep
185
+ // would be wrong, and on a jump of one that is the entire animation. React
186
+ // discards and re-runs a render that sets its own state before committing,
187
+ // so this costs a re-render but never a wrong frame.
188
+ //
189
+ // Seeding both halves from the current `activeStep` is what suppresses the
190
+ // cascade on mount: a stepper that opens on step 3 has no previous step to
191
+ // have travelled from, so its completed segments paint filled at once. That
192
+ // also makes the first render pure and identical on the server, so there is
193
+ // nothing for hydration to disagree about. Storing the pair in one state
194
+ // object keeps the update idempotent under StrictMode's double render — both
195
+ // invocations read the same `seen` and queue the same successor.
196
+ const [seen, setSeen] = useState(() => ({
197
+ current: activeStep,
198
+ previous: activeStep,
199
+ }));
200
+ if (seen.current !== activeStep) {
201
+ setSeen({current: activeStep, previous: seen.current});
202
+ }
203
+ const previousActiveStep =
204
+ seen.current === activeStep ? seen.previous : seen.current;
205
+
206
+ const ctxValue = useMemo<StepperContextValue>(
207
+ () => ({
208
+ activeStep,
209
+ previousActiveStep,
210
+ orientation,
211
+ isNonLinear: onStepClick != null,
212
+ onStepClick: onStepClick ?? null,
213
+ density,
214
+ indicatorPosition,
215
+ registerStep,
216
+ }),
217
+ [
218
+ activeStep,
219
+ previousActiveStep,
220
+ orientation,
221
+ onStepClick,
222
+ density,
223
+ indicatorPosition,
224
+ registerStep,
225
+ ],
226
+ );
227
+
228
+ const isOnTrack = indicatorPosition === 'on-track';
229
+ const orientationStyle =
230
+ orientation === 'horizontal'
231
+ ? isOnTrack
232
+ ? styles.horizontalOnTrack
233
+ : styles.horizontal
234
+ : isOnTrack
235
+ ? styles.verticalOnTrack
236
+ : styles.vertical;
237
+
238
+ return (
239
+ <StepperContext value={ctxValue}>
240
+ <ol
241
+ ref={ref}
242
+ aria-label={label}
243
+ {...rest}
244
+ {...mergeProps(
245
+ themeProps('stepper', {orientation, indicatorPosition}),
246
+ stylex.props(styles.root, orientationStyle, xstyle),
247
+ className,
248
+ style,
249
+ )}>
250
+ {/* Each step renders its own progress bar segment; no child
251
+ introspection needed — steps derive state from context. */}
252
+ {children}
253
+ </ol>
254
+ </StepperContext>
255
+ );
256
+ }
257
+
258
+ Stepper.displayName = 'Stepper';
@@ -0,0 +1,71 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file StepperContext.ts
7
+ * @input Uses React createContext/use
8
+ * @output Exports StepperContext, useStepperContext, and context types
9
+ * @position Context for Stepper <-> Step communication
10
+ *
11
+ * SYNC: When modified, update these files to stay in sync:
12
+ * - /packages/core/src/Stepper/Stepper.doc.mjs
13
+ * - /packages/core/src/Stepper/index.ts
14
+ */
15
+
16
+ import {createContext, use} from 'react';
17
+
18
+ export type StepperOrientation = 'horizontal' | 'vertical';
19
+ export type StepperDensity = 'compact' | 'balanced' | 'spacious';
20
+
21
+ /**
22
+ * Controls where each step's indicator sits relative to the connector track.
23
+ * - 'separated': indicator lives in the label row, distinct from the progress
24
+ * bar (Astryx's original layout).
25
+ * - 'on-track': indicator is slotted *into* the connector line as a node on the
26
+ * track, with the label beside (vertical) or below (horizontal). Aligns with
27
+ * the on-track stepper design.
28
+ */
29
+ export type StepperIndicatorPosition = 'separated' | 'on-track';
30
+
31
+ export interface StepperContextValue {
32
+ activeStep: number;
33
+ /**
34
+ * The `activeStep` this stepper last rendered with, so a Step can tell
35
+ * whether the change it is reacting to was a single step forward — the one
36
+ * change that animates the connector fill — and which span that change
37
+ * crossed (see the CONNECTOR FILL block in Step.tsx). Equal to `activeStep`
38
+ * on the first render, which is what keeps a stepper that mounts mid-flow
39
+ * from animating its way to the step it opened on.
40
+ *
41
+ * Internal: not part of the public API, and deliberately not a Stepper prop.
42
+ * When the connector animates is behaviour the stepper owns, not something a
43
+ * consumer configures.
44
+ */
45
+ previousActiveStep: number;
46
+ orientation: StepperOrientation;
47
+ isNonLinear: boolean;
48
+ onStepClick: ((index: number) => void) | null;
49
+ density: StepperDensity;
50
+ indicatorPosition: StepperIndicatorPosition;
51
+ /**
52
+ * Dev-mode index registration. Each Step calls this on mount with its `step`
53
+ * index. The Stepper tracks the set and warns if two Steps share the same
54
+ * index. Returns a cleanup function to call on unmount.
55
+ */
56
+ registerStep: (index: number) => () => void;
57
+ }
58
+
59
+ export const StepperContext = createContext<StepperContextValue | null>(null);
60
+ StepperContext.displayName = 'StepperContext';
61
+
62
+ export function useStepperContext(): StepperContextValue {
63
+ const ctx = use(StepperContext);
64
+ if (ctx == null) {
65
+ throw new Error(
66
+ 'useStepperContext must be used within Stepper. ' +
67
+ 'Wrap your Step in <Stepper>.',
68
+ );
69
+ }
70
+ return ctx;
71
+ }
@@ -0,0 +1,18 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ export {Stepper} from './Stepper';
6
+ export type {StepperProps} from './Stepper';
7
+
8
+ export {Step} from './Step';
9
+ export type {StepProps, StepIndicatorPreset} from './Step';
10
+
11
+ export type {StepStatus} from './StepStatus';
12
+
13
+ export {useStepperContext} from './StepperContext';
14
+ export type {
15
+ StepperContextValue,
16
+ StepperOrientation,
17
+ StepperIndicatorPosition,
18
+ } from './StepperContext';
@@ -0,0 +1,19 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file stepper.stylex.ts
5
+ * @input StyleX marker system
6
+ * @output stepMarker — a scoped marker applied to each Step's <li>
7
+ * @position Shared StyleX marker consumed by Step for structural selectors
8
+ *
9
+ * Applied to each Step's root `<li>` so the on-track connector segments can
10
+ * key their first/last-node visibility off `stylex.when.ancestor(':first-child'
11
+ * | ':last-child', stepMarker)` — matching only the parent step row, never the
12
+ * outer `<ol>`. This replaces counting children in the parent, so steps behave
13
+ * correctly regardless of how the consumer groups them (arrays, fragments).
14
+ */
15
+
16
+ import * as stylex from '@stylexjs/stylex';
17
+
18
+ export const stepMarker: ReturnType<typeof stylex.defineMarker> =
19
+ stylex.defineMarker();
@@ -260,7 +260,11 @@ export function Tab({
260
260
  ...(isLabelHidden ? {'aria-label': label} : {}),
261
261
  [EDGE_COMP_ATTR]: '',
262
262
  'data-tab-value': value,
263
- 'aria-current': isSelected ? ('page' as const) : undefined,
263
+ // Generic `true` ("the current item within a set"), not `page`: the strip
264
+ // switches views in place at least as often as it navigates, and claiming
265
+ // "current page" when no page changed is a false statement to a screen
266
+ // reader. Stays truthful for the `href` case too, just less specific.
267
+ 'aria-current': isSelected ? ('true' as const) : undefined,
264
268
  // Roving tabindex: the tab strip is a single Tab stop. The selected tab is
265
269
  // the tabbable one; the rest are reachable via arrow keys (handled by
266
270
  // TabList's onKeyDown). When no tab is selected, TabList's repair effect
@@ -106,7 +106,7 @@ describe('TabList', () => {
106
106
  );
107
107
  });
108
108
 
109
- it('marks selected tab with aria-current', () => {
109
+ it('marks selected tab with a generic aria-current, not "page"', () => {
110
110
  render(
111
111
  <TabList value="home" onChange={() => {}}>
112
112
  <Tab value="home" label="Home" />
@@ -116,13 +116,30 @@ describe('TabList', () => {
116
116
 
117
117
  expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
118
118
  'aria-current',
119
- 'page',
119
+ 'true',
120
120
  );
121
121
  expect(screen.getByRole('button', {name: 'Settings'})).not.toHaveAttribute(
122
122
  'aria-current',
123
123
  );
124
124
  });
125
125
 
126
+ it('marks a selected link tab with the same generic aria-current', () => {
127
+ render(
128
+ <TabList value="home" onChange={() => {}}>
129
+ <Tab value="home" label="Home" href="/home" />
130
+ <Tab value="settings" label="Settings" href="/settings" />
131
+ </TabList>,
132
+ );
133
+
134
+ expect(screen.getByRole('link', {name: 'Home'})).toHaveAttribute(
135
+ 'aria-current',
136
+ 'true',
137
+ );
138
+ expect(screen.getByRole('link', {name: 'Settings'})).not.toHaveAttribute(
139
+ 'aria-current',
140
+ );
141
+ });
142
+
126
143
  it('calls onChange when a tab is clicked', async () => {
127
144
  const user = userEvent.setup();
128
145
  const handleChange = vi.fn();
@@ -148,7 +165,7 @@ describe('TabList', () => {
148
165
 
149
166
  expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
150
167
  'aria-current',
151
- 'page',
168
+ 'true',
152
169
  );
153
170
 
154
171
  rerender(
@@ -163,7 +180,7 @@ describe('TabList', () => {
163
180
  );
164
181
  expect(screen.getByRole('button', {name: 'Settings'})).toHaveAttribute(
165
182
  'aria-current',
166
- 'page',
183
+ 'true',
167
184
  );
168
185
  });
169
186
 
@@ -183,9 +183,12 @@ function TableRowInner<T extends Record<string, unknown>>({
183
183
  );
184
184
 
185
185
  const isDefaultRenderer = !col.renderCell;
186
- const rawContent = isDefaultRenderer
187
- ? defaultCellRenderer(item, col.key)
188
- : (col.renderCell?.(item) ?? null);
186
+ let rawContent: ReactNode = null;
187
+ if (!cellRenderProps.isContentSuppressed) {
188
+ rawContent = isDefaultRenderer
189
+ ? defaultCellRenderer(item, col.key)
190
+ : (col.renderCell?.(item) ?? null);
191
+ }
189
192
 
190
193
  // In truncate mode, wrap default-rendered string content in
191
194
  // <Text maxLines={1}> for smart tooltips that only appear
@@ -602,6 +602,41 @@ describe('BaseTable', () => {
602
602
  expect(calls[0]).toEqual({col: 'name', name: 'Alice'});
603
603
  });
604
604
 
605
+ it('isContentSuppressed renders an empty cell and never calls the column renderer', () => {
606
+ const rendered: string[] = [];
607
+ const withRenderer: TableColumn<User>[] = [
608
+ {
609
+ key: 'name',
610
+ header: 'Name',
611
+ renderCell: item => {
612
+ rendered.push(item.name);
613
+ return <b>{item.name}</b>;
614
+ },
615
+ },
616
+ ];
617
+ const plugin: TablePlugin<User> = {
618
+ transformBodyCell: (props, _column, item) =>
619
+ item.name === 'Bob' ? {...props, isContentSuppressed: true} : props,
620
+ };
621
+ render(
622
+ <BaseTable data={users} columns={withRenderer} plugins={[plugin]} />,
623
+ );
624
+ expect(rendered).toEqual(['Alice', 'Charlie']);
625
+ const cells = screen.getAllByRole('cell');
626
+ expect(cells[1]).toBeEmptyDOMElement();
627
+ expect(cells[0]).toHaveTextContent('Alice');
628
+ });
629
+
630
+ it('isContentSuppressed also suppresses the default renderer', () => {
631
+ const plugin: TablePlugin<User> = {
632
+ transformBodyCell: props => ({...props, isContentSuppressed: true}),
633
+ };
634
+ render(<BaseTable data={users} columns={columns} plugins={[plugin]} />);
635
+ for (const cell of screen.getAllByRole('cell')) {
636
+ expect(cell).toBeEmptyDOMElement();
637
+ }
638
+ });
639
+
605
640
  it('composes multiple plugins sequentially', () => {
606
641
  const plugin1: TablePlugin<User> = {
607
642
  transformTable: props => ({
@@ -0,0 +1,112 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useTableGroupedRows-perf.test.tsx
5
+ * @input Table, useTableGroupedRows, React testing utilities
6
+ * @output Performance tests for grouped-rows render behavior
7
+ * @position Test file; validates that grouping costs no extra row renders
8
+ *
9
+ * The plugin keeps group headers out of cell renderers per cell, at render
10
+ * time. Doing it by rewriting the columns instead would hand BaseTable a new
11
+ * column object on every render, and its element-by-element check on the
12
+ * resolved column array is what stops every row re-rendering.
13
+ */
14
+
15
+ import {describe, it, expect} from 'vitest';
16
+ import {render, screen, act} from '@testing-library/react';
17
+ import {useCallback, useMemo, useState} from 'react';
18
+ import userEvent from '@testing-library/user-event';
19
+ import {Table} from '../../Table';
20
+ import type {TableColumn} from '../../types';
21
+ import {useTableGroupedRows} from './useTableGroupedRows';
22
+
23
+ interface Person extends Record<string, unknown> {
24
+ id: string;
25
+ name: string;
26
+ team: string;
27
+ }
28
+
29
+ const people: Person[] = [
30
+ {id: 'a', name: 'Alice', team: 'Core'},
31
+ {id: 'b', name: 'Bob', team: 'Core'},
32
+ {id: 'c', name: 'Carol', team: 'Infra'},
33
+ ];
34
+
35
+ const EMPTY = new Set<string>();
36
+
37
+ function GroupedRenderCountTable({
38
+ renderCounts,
39
+ }: {
40
+ renderCounts: Record<string, number>;
41
+ }) {
42
+ const [tick, setTick] = useState(0);
43
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(EMPTY);
44
+
45
+ const columns = useMemo<TableColumn<Person>[]>(
46
+ () => [
47
+ {
48
+ key: 'name',
49
+ header: 'Name',
50
+ renderCell: (item: Person) => {
51
+ renderCounts[item.id] = (renderCounts[item.id] ?? 0) + 1;
52
+ return item.name;
53
+ },
54
+ },
55
+ ],
56
+ [renderCounts],
57
+ );
58
+
59
+ const grouped = useTableGroupedRows<Person>({
60
+ data: people,
61
+ groupBy: useCallback((p: Person) => p.team, []),
62
+ collapsedGroups,
63
+ onToggleGroup: useCallback(
64
+ (key: string) =>
65
+ setCollapsedGroups(prev => {
66
+ const next = new Set(prev);
67
+ if (!next.delete(key)) {
68
+ next.add(key);
69
+ }
70
+ return next;
71
+ }),
72
+ [],
73
+ ),
74
+ getRowKey: useCallback((p: Person) => p.id, []),
75
+ });
76
+
77
+ return (
78
+ <>
79
+ <button type="button" onClick={() => setTick(t => t + 1)}>
80
+ bump {tick}
81
+ </button>
82
+ <Table
83
+ data={grouped.data}
84
+ columns={columns}
85
+ idKey={grouped.idKey}
86
+ plugins={{grouped: grouped.plugin}}
87
+ />
88
+ </>
89
+ );
90
+ }
91
+
92
+ describe('Grouped rows render performance', () => {
93
+ it('renders each real row once and no group header at all', () => {
94
+ const renderCounts: Record<string, number> = {};
95
+ render(<GroupedRenderCountTable renderCounts={renderCounts} />);
96
+ expect(renderCounts).toEqual({a: 1, b: 1, c: 1});
97
+ });
98
+
99
+ it('a re-render of the surrounding component re-renders no rows', async () => {
100
+ const user = userEvent.setup();
101
+ const renderCounts: Record<string, number> = {};
102
+ render(<GroupedRenderCountTable renderCounts={renderCounts} />);
103
+ const before = {...renderCounts};
104
+
105
+ await act(async () => {
106
+ await user.click(screen.getByRole('button', {name: /bump/}));
107
+ });
108
+
109
+ expect(screen.getByRole('button', {name: 'bump 1'})).toBeInTheDocument();
110
+ expect(renderCounts).toEqual(before);
111
+ });
112
+ });