@astryxdesign/core 0.4.6 → 0.4.7-canary.0b6762d

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 (207) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts +24 -0
  3. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  4. package/dist/AspectRatio/AspectRatio.js +23 -4
  5. package/dist/BottomSheet/BottomSheet.d.ts +1 -0
  6. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheet.js +54 -15
  8. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
  9. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
  10. package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
  11. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  12. package/dist/BottomSheet/BottomSheetPanel.js +1 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  15. package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
  16. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  17. package/dist/BottomSheet/useSheetGestures.js +23 -5
  18. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  19. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -5
  20. package/dist/Calendar/Calendar.d.ts +3 -1
  21. package/dist/Calendar/Calendar.d.ts.map +1 -1
  22. package/dist/Calendar/Calendar.js +13 -14
  23. package/dist/Calendar/getInitialFocusDate.d.ts +46 -0
  24. package/dist/Calendar/getInitialFocusDate.d.ts.map +1 -0
  25. package/dist/Calendar/getInitialFocusDate.js +62 -0
  26. package/dist/Chat/ChatMessageList.d.ts +22 -3
  27. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  28. package/dist/Chat/ChatMessageList.js +6 -3
  29. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  30. package/dist/DateInput/TouchDateField.js +34 -1
  31. package/dist/Dialog/Dialog.d.ts +7 -1
  32. package/dist/Dialog/Dialog.d.ts.map +1 -1
  33. package/dist/Dialog/Dialog.js +48 -22
  34. package/dist/Heading/Heading.d.ts.map +1 -1
  35. package/dist/Heading/Heading.js +6 -2
  36. package/dist/Kbd/Kbd.d.ts.map +1 -1
  37. package/dist/Kbd/Kbd.js +10 -3
  38. package/dist/Markdown/index.d.ts +1 -1
  39. package/dist/Markdown/index.d.ts.map +1 -1
  40. package/dist/Markdown/parser.d.ts +38 -1
  41. package/dist/Markdown/parser.d.ts.map +1 -1
  42. package/dist/Markdown/parser.js +149 -23
  43. package/dist/MultiSelector/MultiSelector.d.ts +15 -1
  44. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  45. package/dist/MultiSelector/MultiSelector.js +17 -6
  46. package/dist/MultiSelector/index.d.ts +1 -1
  47. package/dist/MultiSelector/index.d.ts.map +1 -1
  48. package/dist/Selector/Selector.d.ts.map +1 -1
  49. package/dist/Selector/Selector.js +5 -0
  50. package/dist/Stepper/Step.d.ts +140 -0
  51. package/dist/Stepper/Step.d.ts.map +1 -0
  52. package/dist/Stepper/Step.js +1045 -0
  53. package/dist/Stepper/StepStatus.d.ts +24 -0
  54. package/dist/Stepper/StepStatus.d.ts.map +1 -0
  55. package/dist/Stepper/StepStatus.js +1 -0
  56. package/dist/Stepper/Stepper.d.ts +93 -0
  57. package/dist/Stepper/Stepper.d.ts.map +1 -0
  58. package/dist/Stepper/Stepper.js +184 -0
  59. package/dist/Stepper/StepperContext.d.ts +41 -0
  60. package/dist/Stepper/StepperContext.d.ts.map +1 -0
  61. package/dist/Stepper/StepperContext.js +34 -0
  62. package/dist/Stepper/index.d.ts +8 -0
  63. package/dist/Stepper/index.d.ts.map +1 -0
  64. package/dist/Stepper/index.js +7 -0
  65. package/dist/Stepper/stepper.stylex.d.ts +15 -0
  66. package/dist/Stepper/stepper.stylex.d.ts.map +1 -0
  67. package/dist/Stepper/stepper.stylex.js +20 -0
  68. package/dist/TabList/Tab.d.ts.map +1 -1
  69. package/dist/TabList/Tab.js +5 -1
  70. package/dist/Table/BaseTable.d.ts.map +1 -1
  71. package/dist/Table/BaseTable.js +4 -1
  72. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  73. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +20 -8
  74. package/dist/Table/plugins/pagination/useTablePagination.d.ts +3 -0
  75. package/dist/Table/plugins/pagination/useTablePagination.d.ts.map +1 -1
  76. package/dist/Table/plugins/pagination/useTablePagination.js +21 -4
  77. package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts.map +1 -1
  78. package/dist/Table/plugins/rowStatus/useTableRowStatus.js +10 -3
  79. package/dist/Table/plugins/selection/useTableSelection.d.ts +16 -0
  80. package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
  81. package/dist/Table/plugins/selection/useTableSelection.js +19 -5
  82. package/dist/Table/types.d.ts +22 -4
  83. package/dist/Table/types.d.ts.map +1 -1
  84. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  85. package/dist/Table/useBaseTablePlugins.js +5 -0
  86. package/dist/Text/Text.d.ts.map +1 -1
  87. package/dist/Text/Text.js +6 -2
  88. package/dist/astryx.css +43 -1
  89. package/dist/hooks/index.d.ts +1 -0
  90. package/dist/hooks/index.d.ts.map +1 -1
  91. package/dist/hooks/index.js +1 -0
  92. package/dist/hooks/useHotkeys.d.ts.map +1 -1
  93. package/dist/hooks/useHotkeys.js +10 -3
  94. package/dist/hooks/useListFocus.d.ts +5 -2
  95. package/dist/hooks/useListFocus.d.ts.map +1 -1
  96. package/dist/hooks/useListFocus.js +12 -6
  97. package/dist/hooks/useMergedRefs.d.ts +18 -0
  98. package/dist/hooks/useMergedRefs.d.ts.map +1 -0
  99. package/dist/hooks/useMergedRefs.js +25 -0
  100. package/dist/index.d.ts +1 -0
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +1 -0
  103. package/dist/utils/timeParser.d.ts +1 -1
  104. package/dist/utils/timeParser.d.ts.map +1 -1
  105. package/dist/utils/timeParser.js +26 -12
  106. package/locales/en.json +23 -7
  107. package/locales/pseudo.json +12 -0
  108. package/package.json +8 -3
  109. package/src/AspectRatio/AspectRatio.doc.mjs +6 -3
  110. package/src/AspectRatio/AspectRatio.test.tsx +57 -10
  111. package/src/AspectRatio/AspectRatio.tsx +40 -1
  112. package/src/Avatar/Avatar.doc.mjs +2 -1
  113. package/src/BottomSheet/BottomSheet.test.tsx +123 -0
  114. package/src/BottomSheet/BottomSheet.tsx +36 -4
  115. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
  116. package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
  117. package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
  118. package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
  119. package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
  120. package/src/BottomSheet/useSheetGestures.test.ts +27 -0
  121. package/src/BottomSheet/useSheetGestures.ts +25 -5
  122. package/src/Breadcrumbs/BreadcrumbItem.tsx +2 -1
  123. package/src/Button/Button.doc.mjs +22 -0
  124. package/src/Calendar/Calendar.doc.mjs +4 -3
  125. package/src/Calendar/Calendar.test.tsx +52 -0
  126. package/src/Calendar/Calendar.tsx +18 -15
  127. package/src/Calendar/getInitialFocusDate.test.ts +94 -0
  128. package/src/Calendar/getInitialFocusDate.ts +93 -0
  129. package/src/Chat/ChatMessageList.doc.mjs +9 -1
  130. package/src/Chat/ChatMessageList.test.tsx +46 -0
  131. package/src/Chat/ChatMessageList.tsx +28 -4
  132. package/src/CodeBlock/CodeBlock.doc.mjs +6 -0
  133. package/src/ContextMenu/ContextMenu.doc.mjs +5 -0
  134. package/src/DateInput/DateInputTouch.test.tsx +36 -0
  135. package/src/DateInput/TouchDateField.tsx +35 -1
  136. package/src/Dialog/Dialog.doc.mjs +8 -3
  137. package/src/Dialog/Dialog.test.tsx +71 -0
  138. package/src/Dialog/Dialog.tsx +72 -20
  139. package/src/Field/Field.doc.mjs +11 -0
  140. package/src/Heading/Heading.test.tsx +23 -1
  141. package/src/Heading/Heading.tsx +6 -2
  142. package/src/Kbd/Kbd.test.tsx +17 -0
  143. package/src/Kbd/Kbd.tsx +10 -3
  144. package/src/Link/Link.doc.mjs +11 -0
  145. package/src/Markdown/index.ts +1 -0
  146. package/src/Markdown/parser.perf.test.ts +71 -1
  147. package/src/Markdown/parser.test.ts +145 -2
  148. package/src/Markdown/parser.ts +208 -22
  149. package/src/MoreMenu/MoreMenu.doc.mjs +7 -1
  150. package/src/MultiSelector/MultiSelector.doc.mjs +10 -0
  151. package/src/MultiSelector/MultiSelector.test.tsx +105 -0
  152. package/src/MultiSelector/MultiSelector.tsx +50 -6
  153. package/src/MultiSelector/index.ts +1 -0
  154. package/src/NumberInput/NumberInput.doc.mjs +5 -0
  155. package/src/Popover/Popover.test.tsx +27 -1
  156. package/src/PowerSearch/PowerSearch.doc.mjs +10 -0
  157. package/src/Selector/Selector.doc.mjs +11 -0
  158. package/src/Selector/Selector.test.tsx +21 -0
  159. package/src/Selector/Selector.tsx +11 -1
  160. package/src/Stepper/Step.doc.mjs +98 -0
  161. package/src/Stepper/Step.tsx +1638 -0
  162. package/src/Stepper/StepStatus.ts +26 -0
  163. package/src/Stepper/Stepper.doc.mjs +370 -0
  164. package/src/Stepper/Stepper.test.tsx +1024 -0
  165. package/src/Stepper/Stepper.tsx +258 -0
  166. package/src/Stepper/StepperContext.ts +71 -0
  167. package/src/Stepper/index.ts +18 -0
  168. package/src/Stepper/stepper.stylex.ts +19 -0
  169. package/src/TabList/Tab.tsx +5 -1
  170. package/src/TabList/TabList.test.tsx +21 -4
  171. package/src/Table/BaseTable.tsx +6 -3
  172. package/src/Table/Table.test.tsx +35 -0
  173. package/src/Table/plugins/groupedRows/useTableGroupedRows-perf.test.tsx +112 -0
  174. package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +100 -0
  175. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +17 -8
  176. package/src/Table/plugins/pagination/useTablePagination.test.tsx +34 -4
  177. package/src/Table/plugins/pagination/useTablePagination.tsx +35 -3
  178. package/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +13 -7
  179. package/src/Table/plugins/rowStatus/useTableRowStatus.tsx +12 -3
  180. package/src/Table/plugins/selection/useTableSelection.test.tsx +76 -0
  181. package/src/Table/plugins/selection/useTableSelection.tsx +40 -7
  182. package/src/Table/types.ts +22 -4
  183. package/src/Table/useBaseTablePlugins.ts +5 -0
  184. package/src/Table/useTableGroupedRows.doc.mjs +5 -4
  185. package/src/Table/useTablePagination.doc.mjs +7 -0
  186. package/src/Table/useTableRowStatus.doc.mjs +1 -1
  187. package/src/Table/useTableSelection.doc.mjs +31 -0
  188. package/src/Text/Text.test.tsx +23 -1
  189. package/src/Text/Text.tsx +6 -2
  190. package/src/TextInput/TextInput.doc.mjs +10 -0
  191. package/src/Toast/Toast.doc.mjs +6 -0
  192. package/src/Tokenizer/Tokenizer.doc.mjs +10 -0
  193. package/src/Toolbar/Toolbar.doc.mjs +5 -0
  194. package/src/hooks/index.ts +2 -0
  195. package/src/hooks/useHotkeys.test.ts +18 -0
  196. package/src/hooks/useHotkeys.ts +10 -3
  197. package/src/hooks/useListFocus.doc.mjs +2 -2
  198. package/src/hooks/useListFocus.test.tsx +65 -3
  199. package/src/hooks/useListFocus.ts +15 -7
  200. package/src/hooks/useMergedRefs.doc.mjs +79 -0
  201. package/src/hooks/useMergedRefs.test.tsx +62 -0
  202. package/src/hooks/useMergedRefs.ts +36 -0
  203. package/src/index.ts +1 -0
  204. package/src/theme/MediaTheme.doc.mjs +5 -5
  205. package/src/utils/timeParser.test.ts +6 -1
  206. package/src/utils/timeParser.ts +35 -13
  207. package/src/__tests__/inputWidthContract.test.tsx +0 -69
@@ -72,6 +72,12 @@ export const docs = {
72
72
  description: 'Horizontal alignment of the pagination controls.',
73
73
  default: "'center'",
74
74
  },
75
+ {
76
+ name: 'label',
77
+ type: 'string',
78
+ description: "Accessible name for the pagination nav landmark. With position='both' the two navs get distinct names — the label suffixed as '(top)' and '(bottom)' — so same-type landmarks stay unique (axe landmark-unique).",
79
+ default: "'Table pagination'",
80
+ },
75
81
  ],
76
82
  };
77
83
 
@@ -129,5 +135,6 @@ export const docsDense = {
129
135
  pageSize: 'Items per page. Default 10.',
130
136
  variant: "Pagination variant: 'pages'|'count'|'compact'|'dots'|'none'.",
131
137
  position: "Render position: 'below'|'above'|'both'|'none'.",
138
+ label: "Accessible name for the nav landmark; position='both' emits '<label> (top)' / '<label> (bottom)'.",
132
139
  },
133
140
  };
@@ -7,7 +7,7 @@ export const docs = {
7
7
  subComponentOf: 'Table',
8
8
  displayName: 'useTableRowStatus',
9
9
  description:
10
- 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status: a colored status dot by default, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and a required accessible label (shown in a tooltip on hover and announced to assistive technology, so status is never color-only); return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.',
10
+ 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status: a colored status dot by default, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and a required accessible label (shown in a tooltip on hover and announced to assistive technology, so status is never color-only); return null for no indicator. The column header is visually blank but carries a screen-reader-only localized name ("Row status", i18n key @astryx.table.rowStatus.columnHeader). Memoize getStatus with useCallback for a stable plugin identity.',
11
11
  props: [
12
12
  {
13
13
  name: 'getStatus',
@@ -61,6 +61,13 @@ export const docs = {
61
61
  description:
62
62
  'Derives a human-readable identity for a row; the row checkbox\'s hidden label becomes `Select ${getRowLabel(item)}` so screen readers announce which row each checkbox selects. Falls back to "Select row" when omitted.',
63
63
  },
64
+ {
65
+ name: 'hasRowHighlight',
66
+ type: 'boolean',
67
+ description:
68
+ 'Paints checked rows with the accent wash. Set false when the surrounding UI already uses row background to mean something else (a row open in a detail panel, say) — the wash is an inline style, so it cannot be overridden from userland. Only the background is dropped: aria-selected is still set on checked rows either way.',
69
+ default: 'true',
70
+ },
64
71
  ],
65
72
  examples: [
66
73
  {
@@ -82,6 +89,21 @@ const selectionPlugin = useTableSelection({
82
89
  idKey="id"
83
90
  plugins={{selection: selectionPlugin}}
84
91
  />;
92
+ `,
93
+ },
94
+ {
95
+ label: 'Opt out of the checked-row wash',
96
+ code: `
97
+ // This table already uses row background to mean "open in the detail
98
+ // panel". Turning the selection wash off keeps that meaning unambiguous;
99
+ // the checkbox and aria-selected still carry the selection.
100
+ const selectionPlugin = useTableSelection({
101
+ getIsItemSelected: item => selectedIds.has(item.id),
102
+ onSelectItem: ({item, isSelected}) => toggle(item.id, isSelected),
103
+ onSelectAll: ({isAllSelected}) => selectAll(isAllSelected),
104
+ getIsAllSelected: () => selectedIds.size === users.length,
105
+ hasRowHighlight: false,
106
+ });
85
107
  `,
86
108
  },
87
109
  ],
@@ -142,6 +164,13 @@ export const docsZh = {
142
164
  description:
143
165
  '为行派生人类可读的标识;行复选框的隐藏标签变为 `Select ${getRowLabel(item)}`,让屏幕阅读器播报每个复选框选择的是哪一行。省略时回退为 "Select row"。',
144
166
  },
167
+ {
168
+ name: 'hasRowHighlight',
169
+ type: 'boolean',
170
+ description:
171
+ '为选中的行绘制强调色背景。当周围的界面已用行背景表达其他含义(例如该行已在详情面板中打开)时设为 false —— 该背景是内联样式,无法从业务代码覆盖。仅去掉背景:无论如何选中的行仍会设置 aria-selected。',
172
+ default: 'true',
173
+ },
145
174
  ],
146
175
  };
147
176
 
@@ -164,5 +193,7 @@ export const docsDense = {
164
193
  'Returns whether row checkbox is interactive; disabled rows show disabled checkbox.',
165
194
  getRowLabel:
166
195
  'Derives row identity for the checkbox\'s hidden label: `Select ${getRowLabel(item)}`. Falls back to "Select row" when omitted.',
196
+ hasRowHighlight:
197
+ 'false => skip the accent wash on checked rows (inline style, not overridable from userland). aria-selected is unaffected. Defaults to true.',
167
198
  },
168
199
  };
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import {render, screen} from '@testing-library/react';
9
- import {describe, it, expect} from 'vitest';
9
+ import {describe, it, expect, vi} from 'vitest';
10
10
  import {Text} from './Text';
11
11
  import type {TextType} from './Text';
12
12
  import type {TextColor} from '../theme/types';
@@ -23,6 +23,28 @@ describe('Text', () => {
23
23
  expect(screen.getByText('Hello World')).toBeInTheDocument();
24
24
  });
25
25
 
26
+ it('keeps a forwarded ref attached across rerenders', () => {
27
+ const ref = vi.fn();
28
+ const {rerender} = render(
29
+ <Text ref={ref} type="body">
30
+ Before
31
+ </Text>,
32
+ );
33
+
34
+ const element = screen.getByText('Before');
35
+ expect(ref).toHaveBeenLastCalledWith(element);
36
+ ref.mockClear();
37
+
38
+ rerender(
39
+ <Text ref={ref} type="body">
40
+ After
41
+ </Text>,
42
+ );
43
+
44
+ expect(ref).not.toHaveBeenCalled();
45
+ expect(screen.getByText('After')).toBe(element);
46
+ });
47
+
26
48
  it('renders as span by default', () => {
27
49
  render(<Text type="body">Text</Text>);
28
50
  const element = screen.getByText('Text');
package/src/Text/Text.tsx CHANGED
@@ -48,7 +48,8 @@ import {
48
48
  } from './text.stylex';
49
49
  import {useTruncation} from './useTruncation';
50
50
  import type {LayerPlacement} from '../Layer';
51
- import {mergeProps, mergeRefs} from '../utils';
51
+ import {mergeProps} from '../utils';
52
+ import {useMergedRefs} from '../hooks/useMergedRefs';
52
53
  import type {BaseProps} from '../BaseProps';
53
54
  import {themeProps} from '../utils/themeProps';
54
55
 
@@ -271,13 +272,16 @@ export function Text({
271
272
  // Ref for the text element (used as tooltip anchor)
272
273
  const textRef = useRef<HTMLElement>(null);
273
274
 
275
+ // Keep the merged ref stable across rerenders.
276
+ const mergedRef = useMergedRefs(ref, truncation.ref, textRef);
277
+
274
278
  // Build inline style for -webkit-line-clamp (dynamic value)
275
279
  const inlineStyle = maxLines > 1 ? {WebkitLineClamp: maxLines} : undefined;
276
280
 
277
281
  return (
278
282
  <>
279
283
  <Component
280
- ref={mergeRefs(ref, truncation.ref, textRef)}
284
+ ref={mergedRef}
281
285
  {...mergeProps(
282
286
  themeProps('text', {type, size, color: resolvedColor}),
283
287
  stylex.props(
@@ -71,6 +71,16 @@ export const docs = {
71
71
  'Displays a "Required" indicator next to the label and sets aria-required. Mutually exclusive with isOptional.',
72
72
  default: 'false',
73
73
  },
74
+ {
75
+ name: 'onEnter',
76
+ type: '() => void',
77
+ description: 'Callback fired when the user presses the Enter key.',
78
+ },
79
+ {
80
+ name: 'onKeyDown',
81
+ type: '(e: KeyboardEvent<HTMLInputElement>) => void',
82
+ description: 'Callback fired on keydown events on the input.',
83
+ },
74
84
  {
75
85
  name: 'isDisabled',
76
86
  type: 'boolean',
@@ -60,6 +60,12 @@ export const docs = {
60
60
  type: '(reason: "auto" | "manual") => void',
61
61
  description: 'Callback fired when the toast is removed.',
62
62
  },
63
+ {
64
+ name: 'onDismiss',
65
+ type: '(reason: "auto" | "manual") => void',
66
+ description: 'Callback fired when the toast is dismissed.',
67
+ required: true,
68
+ },
63
69
  ], theming: {
64
70
  targets: [
65
71
  {className: 'astryx-toast', visualProps: ['type']},
@@ -201,6 +201,16 @@ export const docs = {
201
201
  description:
202
202
  'Width of the field (number = pixels, string used as-is, e.g. "100%"). Sizes the whole field (label, control, and status) so they stay aligned.',
203
203
  },
204
+ {
205
+ name: 'onFocus',
206
+ type: '(e: FocusEvent<HTMLInputElement>) => void',
207
+ description: 'Fires when focus enters the tokenizer input.',
208
+ },
209
+ {
210
+ name: 'onBlur',
211
+ type: '(e: FocusEvent<HTMLInputElement>) => void',
212
+ description: 'Fires when focus leaves the tokenizer input.',
213
+ },
204
214
  {
205
215
  name: 'xstyle',
206
216
  type: 'StyleXStyles',
@@ -133,6 +133,11 @@ export const docs = {
133
133
  description: 'Visual variant passed to Section.',
134
134
  default: "'transparent'",
135
135
  },
136
+ {
137
+ name: 'dividers',
138
+ type: "Array<'top' | 'bottom' | 'start' | 'end'>",
139
+ description: 'Borders/dividers rendered around the toolbar.',
140
+ },
136
141
  {
137
142
  name: 'xstyle',
138
143
  type: 'StyleXStyles',
@@ -56,6 +56,8 @@ export type {UseTypeaheadOptions, UseTypeaheadReturn} from './useTypeahead';
56
56
 
57
57
  export {useMediaQuery} from './useMediaQuery';
58
58
 
59
+ export {useMergedRefs} from './useMergedRefs';
60
+
59
61
  export {useOverflow} from './useOverflow';
60
62
  export type {UseOverflowOptions, UseOverflowReturn} from './useOverflow';
61
63
 
@@ -67,6 +67,24 @@ describe('useHotkeys', () => {
67
67
  expect(onPress).toHaveBeenCalledTimes(1);
68
68
  });
69
69
 
70
+ it('reads a blank userAgentData.platform as unknown, not as non-Apple', () => {
71
+ // Builds that rewrite their client-hints identity expose the key with an
72
+ // empty value; navigator.platform is the only surface left that answers.
73
+ vi.stubGlobal('navigator', {
74
+ userAgentData: {platform: ''},
75
+ platform: 'MacIntel',
76
+ });
77
+ const onPress = vi.fn();
78
+ renderHook(() => useHotkeys([{keys: 'mod+k', onPress}]));
79
+
80
+ press('k', {ctrlKey: true});
81
+ expect(onPress).not.toHaveBeenCalled();
82
+
83
+ const event = press('k', {metaKey: true});
84
+ expect(onPress).toHaveBeenCalledTimes(1);
85
+ expect(onPress).toHaveBeenCalledWith(event);
86
+ });
87
+
70
88
  it('does not fire a bare key when modifiers are held', () => {
71
89
  stubApplePlatform();
72
90
  const onPress = vi.fn();
@@ -73,8 +73,9 @@ const KEY_ALIASES: Record<string, string> = {
73
73
 
74
74
  /**
75
75
  * Detects whether the current platform is macOS/iOS.
76
- * Prefers the User-Agent Client Hints API when available (modern Chrome/Edge),
77
- * falls back to navigator.platform (deprecated but universally supported).
76
+ * Prefers the User-Agent Client Hints API when it names a platform (modern
77
+ * Chrome/Edge), falls back to navigator.platform (deprecated but universally
78
+ * supported) when it is absent or blank.
78
79
  * Mirrors the detection used by Kbd so displayed and handled shortcuts agree.
79
80
  */
80
81
  function isApplePlatform(): boolean {
@@ -83,7 +84,13 @@ function isApplePlatform(): boolean {
83
84
  }
84
85
  const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
85
86
  if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
86
- return /mac/i.test((uaData as {platform: string}).platform ?? '');
87
+ const uaPlatform = (uaData as {platform?: unknown}).platform;
88
+ // A blank platform is no answer, not a negative one. Builds that rewrite
89
+ // their client-hints identity ship '', so fall through rather than
90
+ // reading it as "not Apple".
91
+ if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') {
92
+ return /mac/i.test(uaPlatform);
93
+ }
87
94
  }
88
95
  return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');
89
96
  }
@@ -36,7 +36,7 @@ export const docs = {
36
36
  {
37
37
  name: 'options.onEscape',
38
38
  type: '() => void',
39
- description: 'Callback when Escape key is pressed (e.g., close menu).',
39
+ description: 'Callback when Escape key is pressed (e.g., close menu). Supplying it also consumes the key (preventDefault); without it Escape passes through to the surrounding layer.',
40
40
  required: false,
41
41
  },
42
42
  {
@@ -144,7 +144,7 @@ export const docsDense = {
144
144
  'options.itemSelector': 'selector for focusable items in list.',
145
145
  'options.boundarySelector': "boundary selector for lists that contain nested lists (e.g. submenu flyouts); scopes items + key handling to this level.",
146
146
  'options.wrap': 'whether arrow navigation wraps around at ends.',
147
- 'options.onEscape': 'callback when Escape key pressed (e.g. close menu).',
147
+ 'options.onEscape': 'callback when Escape key pressed (e.g. close menu). Also consumes the key; without it Escape passes through to the surrounding layer.',
148
148
  'options.orientation': "navigation orientation. 'horizontal' uses ArrowLeft/ArrowRight, 'vertical' uses ArrowUp/ArrowDown, 'both' accepts all four arrows.",
149
149
  'options.hasHomeEnd': 'whether Home/End jump to first/last enabled item.',
150
150
  'options.isRtl': 'ArrowLeft/ArrowRight swap for horizontal nav (RTL). default: auto-detect from container computed direction; explicit boolean wins.',
@@ -3,14 +3,14 @@
3
3
  /**
4
4
  * @file useListFocus.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, useListFocus hook
6
- * @output Unit tests for useListFocus disabled-item skipping, navigation, and
7
- * RTL auto-detection
6
+ * @output Unit tests for useListFocus disabled-item skipping, navigation,
7
+ * Escape consumption, and RTL auto-detection
8
8
  * @position Testing; validates useListFocus.ts keyboard navigation
9
9
  *
10
10
  * SYNC: When useListFocus.ts changes, update tests to match new behavior
11
11
  */
12
12
 
13
- import {describe, it, expect} from 'vitest';
13
+ import {describe, it, expect, vi} from 'vitest';
14
14
  import type {KeyboardEvent as ReactKeyboardEvent} from 'react';
15
15
  import {render, screen, fireEvent} from '@testing-library/react';
16
16
  import {useListFocus} from './useListFocus';
@@ -525,3 +525,65 @@ describe('useListFocus boundarySelector (nested lists)', () => {
525
525
  expect(innerProbe).toHaveAttribute('data-owns', 'false');
526
526
  });
527
527
  });
528
+
529
+ // A list inside a host that dismisses on Escape. The host's guard mirrors
530
+ // `useFocusTrap`: it acts only on a key no inner handler has consumed.
531
+ function EscapeHost({
532
+ onEscape,
533
+ onHostEscape,
534
+ }: {
535
+ onEscape?: () => void;
536
+ onHostEscape: () => void;
537
+ }) {
538
+ const {listRef, handleKeyDown} = useListFocus<HTMLDivElement>({onEscape});
539
+ return (
540
+ <div
541
+ data-testid="host"
542
+ onKeyDown={e => {
543
+ if (e.key === 'Escape' && !e.defaultPrevented) {
544
+ onHostEscape();
545
+ }
546
+ }}>
547
+ <div ref={listRef} role="menu" onKeyDown={handleKeyDown}>
548
+ <div role="menuitem" tabIndex={-1} data-testid="One">
549
+ One
550
+ </div>
551
+ <div role="menuitem" tabIndex={-1} data-testid="Two">
552
+ Two
553
+ </div>
554
+ </div>
555
+ </div>
556
+ );
557
+ }
558
+
559
+ describe('useListFocus Escape', () => {
560
+ it('leaves Escape to the host when no onEscape is supplied', () => {
561
+ const onHostEscape = vi.fn();
562
+ render(<EscapeHost onHostEscape={onHostEscape} />);
563
+
564
+ fireEvent.keyDown(screen.getByRole('menu'), {key: 'Escape'});
565
+ expect(onHostEscape).toHaveBeenCalledTimes(1);
566
+ });
567
+
568
+ it('consumes Escape and runs onEscape when one is supplied', () => {
569
+ const onEscape = vi.fn();
570
+ const onHostEscape = vi.fn();
571
+ render(<EscapeHost onEscape={onEscape} onHostEscape={onHostEscape} />);
572
+
573
+ fireEvent.keyDown(screen.getByRole('menu'), {key: 'Escape'});
574
+ expect(onEscape).toHaveBeenCalledTimes(1);
575
+ expect(onHostEscape).not.toHaveBeenCalled();
576
+ });
577
+
578
+ it('still consumes arrow keys with no onEscape (page-scroll suppression)', () => {
579
+ render(<EscapeHost onHostEscape={() => {}} />);
580
+ screen.getByTestId('One').focus();
581
+
582
+ // fireEvent returns false when a handler cancelled the event.
583
+ const wasCancelled = !fireEvent.keyDown(screen.getByRole('menu'), {
584
+ key: 'ArrowDown',
585
+ });
586
+ expect(wasCancelled).toBe(true);
587
+ expect(screen.getByTestId('Two')).toHaveFocus();
588
+ });
589
+ });
@@ -14,6 +14,8 @@
14
14
  *
15
15
  * SYNC: When modified, update:
16
16
  * - /packages/core/src/hooks/index.ts
17
+ * - /packages/core/src/hooks/useListFocus.doc.mjs
18
+ * - /packages/core/src/hooks/useListFocus.test.tsx
17
19
  */
18
20
 
19
21
  import {useCallback, useRef} from 'react';
@@ -64,7 +66,9 @@ export interface UseListFocusOptions {
64
66
  wrap?: boolean;
65
67
 
66
68
  /**
67
- * Callback when Escape key is pressed.
69
+ * Callback when Escape key is pressed. Supplying it also makes the list
70
+ * consume the key (`preventDefault`); without it Escape passes through to
71
+ * the surrounding layer.
68
72
  */
69
73
  onEscape?: () => void;
70
74
 
@@ -277,7 +281,8 @@ function shouldDeferToCaret(target: EventTarget | null, key: string): boolean {
277
281
  * - ArrowUp/ArrowLeft: Move to previous item (wraps to last)
278
282
  * - Home: Move to first item
279
283
  * - End: Move to last item
280
- * - Escape: Custom callback (e.g., close menu)
284
+ * - Escape: runs `onEscape` and consumes the key. With no `onEscape` the key
285
+ * is left alone, so a surrounding layer can still dismiss on it.
281
286
  *
282
287
  * By default the hook only *moves* focus and leaves `tabindex` management to
283
288
  * the caller. Opt into {@link UseListFocusOptions.hasRovingTabIndex} for a hook
@@ -557,12 +562,15 @@ export function useListFocus<T extends HTMLElement = HTMLElement>(
557
562
  return;
558
563
  }
559
564
 
560
- // Escape is handled regardless of orientation. Preserve the historical
561
- // behavior of always consuming Escape here (preventDefault) so consumers
562
- // that relied on it are unaffected.
565
+ // Escape is handled regardless of orientation, but only *consumed* when
566
+ // a handler asked for it: a list with no dismissal to perform must leave
567
+ // the key to whatever host layer does have one, and those defer to
568
+ // `defaultPrevented` (see `useFocusTrap`) or to the native popover.
563
569
  if (e.key === 'Escape') {
564
- e.preventDefault();
565
- onEscape?.();
570
+ if (onEscape) {
571
+ e.preventDefault();
572
+ onEscape();
573
+ }
566
574
  return;
567
575
  }
568
576
 
@@ -0,0 +1,79 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').HookDoc} */
4
+ export const docs = {
5
+ name: 'useMergedRefs',
6
+ displayName: 'useMergedRefs',
7
+ keywords: [
8
+ 'ref',
9
+ 'refs',
10
+ 'merge',
11
+ 'forwardRef',
12
+ 'callback ref',
13
+ 'stable ref',
14
+ ],
15
+ params: [
16
+ {
17
+ name: 'refs',
18
+ type: 'Array<Ref<T> | undefined>',
19
+ description: 'Up to six refs that should all receive the same element.',
20
+ required: true,
21
+ },
22
+ ],
23
+ returns: [
24
+ {
25
+ name: 'ref',
26
+ type: 'RefCallback<T>',
27
+ description:
28
+ 'A merged callback ref that remains stable until an input ref changes.',
29
+ },
30
+ ],
31
+ usage: {
32
+ description:
33
+ 'Combines multiple object or callback refs into one stable callback ref. Use it when a component must forward a consumer ref while also attaching internal refs. Unlike calling mergeRefs during render, the callback identity stays stable across unrelated rerenders, so React does not detach and reattach the element.',
34
+ bestPractices: [
35
+ {
36
+ guidance: true,
37
+ description:
38
+ 'Use useMergedRefs when one element must receive both a forwarded ref and one or more internal refs.',
39
+ },
40
+ {
41
+ guidance: false,
42
+ description:
43
+ 'Call mergeRefs directly in a JSX ref prop; that creates a new callback on every render and forces unnecessary detach and attach work.',
44
+ },
45
+ ],
46
+ },
47
+ relatedComponents: [],
48
+ relatedHooks: [],
49
+ importPath: '@astryxdesign/core/hooks',
50
+ category: 'utility',
51
+ };
52
+
53
+ /** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
54
+ export const docsDense = {
55
+ description:
56
+ 'Combines object/callback refs into one stable callback ref. Identity changes only when an input ref changes, avoiding detach/attach churn from inline mergeRefs calls.',
57
+ paramDescriptions: {
58
+ refs: 'refs that should all receive the same element.',
59
+ },
60
+ returnDescriptions: {
61
+ ref: 'stable merged callback ref; changes only when an input ref changes.',
62
+ },
63
+ usage: {
64
+ description:
65
+ 'Use when one element needs a forwarded consumer ref plus internal refs. Keeps the callback stable across unrelated rerenders.',
66
+ bestPractices: [
67
+ {
68
+ guidance: true,
69
+ description:
70
+ 'Use for a forwarded ref plus internal measurement, focus, or anchor refs.',
71
+ },
72
+ {
73
+ guidance: false,
74
+ description:
75
+ 'Call mergeRefs inline in JSX; it forces ref detach/attach on every render.',
76
+ },
77
+ ],
78
+ },
79
+ };
@@ -0,0 +1,62 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useMergedRefs.test.tsx
5
+ * @input Uses vitest, @testing-library/react, useMergedRefs hook
6
+ * @output Unit tests for stable merged ref behavior
7
+ * @position Testing; validates useMergedRefs.ts
8
+ *
9
+ * SYNC: When useMergedRefs.ts changes, update tests to match new behavior
10
+ */
11
+
12
+ import {renderHook} from '@testing-library/react';
13
+ import {createRef} from 'react';
14
+ import {describe, expect, it, vi} from 'vitest';
15
+ import {useMergedRefs} from './useMergedRefs';
16
+
17
+ describe('useMergedRefs', () => {
18
+ it('forwards values to callback and object refs', () => {
19
+ const callbackRef = vi.fn();
20
+ const objectRef = createRef<HTMLDivElement>();
21
+ const {result} = renderHook(() =>
22
+ useMergedRefs<HTMLDivElement>(callbackRef, objectRef),
23
+ );
24
+ const element = document.createElement('div');
25
+
26
+ const cleanup = result.current(element);
27
+
28
+ expect(callbackRef).toHaveBeenCalledWith(element);
29
+ expect(objectRef.current).toBe(element);
30
+
31
+ cleanup?.();
32
+ expect(callbackRef).toHaveBeenLastCalledWith(null);
33
+ expect(objectRef.current).toBeNull();
34
+ });
35
+
36
+ it('keeps the merged callback stable when input refs are unchanged', () => {
37
+ const callbackRef = vi.fn();
38
+ const objectRef = createRef<HTMLDivElement>();
39
+ const {result, rerender} = renderHook(() =>
40
+ useMergedRefs<HTMLDivElement>(callbackRef, objectRef),
41
+ );
42
+ const first = result.current;
43
+
44
+ rerender();
45
+
46
+ expect(result.current).toBe(first);
47
+ });
48
+
49
+ it('updates the merged callback when an input ref changes', () => {
50
+ const firstRef = vi.fn();
51
+ const secondRef = vi.fn();
52
+ const {result, rerender} = renderHook(
53
+ ({ref}) => useMergedRefs<HTMLDivElement>(ref),
54
+ {initialProps: {ref: firstRef}},
55
+ );
56
+ const first = result.current;
57
+
58
+ rerender({ref: secondRef});
59
+
60
+ expect(result.current).not.toBe(first);
61
+ });
62
+ });
@@ -0,0 +1,36 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useMergedRefs.ts
7
+ * @input React refs to combine into one stable callback ref
8
+ * @output Exports useMergedRefs
9
+ * @position Core hook; use when one element must receive multiple refs
10
+ *
11
+ * SYNC: When modified, update:
12
+ * - /packages/core/src/hooks/index.ts
13
+ * - /packages/core/src/hooks/useMergedRefs.doc.mjs
14
+ * - /packages/core/src/hooks/useMergedRefs.test.tsx
15
+ */
16
+
17
+ import {useMemo, type Ref, type RefCallback} from 'react';
18
+ import {mergeRefs} from '../utils/mergeRefs';
19
+
20
+ /**
21
+ * Combine up to six refs into a callback ref whose identity changes only when
22
+ * one of the input refs changes.
23
+ */
24
+ export function useMergedRefs<T>(
25
+ refA?: Ref<T>,
26
+ refB?: Ref<T>,
27
+ refC?: Ref<T>,
28
+ refD?: Ref<T>,
29
+ refE?: Ref<T>,
30
+ refF?: Ref<T>,
31
+ ): RefCallback<T> {
32
+ return useMemo(
33
+ () => mergeRefs(refA, refB, refC, refD, refE, refF),
34
+ [refA, refB, refC, refD, refE, refF],
35
+ );
36
+ }
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export * from './NavIcon';
55
55
  export * from './NavMenu';
56
56
  export * from './Slider';
57
57
  export * from './Stack';
58
+ export * from './Stepper';
58
59
  export * from './Switch';
59
60
  export * from './DateInput';
60
61
  export * from './DateTimeInput';