@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
@@ -9,6 +9,7 @@
9
9
  * SYNC: When Dialog.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
+ import {readFileSync} from 'node:fs';
12
13
  import {describe, it, expect, vi, beforeEach} from 'vitest';
13
14
  import {render, screen} from '@testing-library/react';
14
15
  import {Dialog, resolveDialogPositionOffsets} from './Dialog';
@@ -184,6 +185,76 @@ describe('Dialog', () => {
184
185
  });
185
186
  });
186
187
 
188
+ describe('responsive sizing', () => {
189
+ it('keeps the requested width but clamps standard dialogs to container and dynamic viewport gutters', () => {
190
+ render(
191
+ <Dialog
192
+ isOpen={true}
193
+ onOpenChange={() => {}}
194
+ width={600}
195
+ maxHeight="70dvh"
196
+ aria-label="Sized dialog">
197
+ Content
198
+ </Dialog>,
199
+ );
200
+
201
+ const dialog = screen.getByRole('dialog');
202
+ const inlineStyle = dialog.getAttribute('style') ?? '';
203
+ expect(inlineStyle).toContain('--x-width: 600px');
204
+ expect(inlineStyle).toContain(
205
+ '--x-maxWidth: min(100%, calc(100dvw - var(--spacing-4) - var(--spacing-4)))',
206
+ );
207
+ expect(inlineStyle).toContain('--x-maxHeight: 70dvh');
208
+ });
209
+
210
+ it('uses a fullscreen-specific fade animation instead of centered dialog movement', () => {
211
+ const source = readFileSync(
212
+ 'packages/core/src/Dialog/Dialog.tsx',
213
+ 'utf8',
214
+ );
215
+ const standardOpen = source.slice(
216
+ source.indexOf(' open: {'),
217
+ source.indexOf(' // Backdrop using ::backdrop'),
218
+ );
219
+ const fullscreenOpen = source.slice(
220
+ source.indexOf(' fullscreenOpen: {'),
221
+ source.indexOf(' fullscreenSafeArea: {'),
222
+ );
223
+ const modalStyleOrder = source.slice(
224
+ source.indexOf('focusOutlineProps.focusVisible('),
225
+ source.indexOf(
226
+ ' xstyle,',
227
+ source.indexOf('focusOutlineProps.focusVisible('),
228
+ ),
229
+ );
230
+
231
+ expect(standardOpen).toContain('enterDirectional');
232
+ expect(fullscreenOpen).toContain('enterFullscreen');
233
+ expect(fullscreenOpen).not.toContain('enterDirectional');
234
+ expect(modalStyleOrder.indexOf('styles.open')).toBeLessThan(
235
+ modalStyleOrder.indexOf('styles.fullscreenOpen'),
236
+ );
237
+ });
238
+
239
+ it('protects fullscreen content with safe-area padding', () => {
240
+ render(
241
+ <Dialog
242
+ isOpen={true}
243
+ onOpenChange={() => {}}
244
+ variant="fullscreen"
245
+ aria-label="Fullscreen dialog">
246
+ <div data-testid="child">Content</div>
247
+ </Dialog>,
248
+ );
249
+
250
+ const wrapper = screen.getByTestId('child').parentElement!;
251
+ const computed = window.getComputedStyle(wrapper);
252
+ expect(computed.paddingInlineStart).toContain('safe-area-inset-left');
253
+ expect(computed.paddingInlineEnd).toContain('safe-area-inset-right');
254
+ expect(wrapper.parentElement!.tagName).toBe('DIALOG');
255
+ });
256
+ });
257
+
187
258
  describe('position prop', () => {
188
259
  it('accepts position configuration', () => {
189
260
  render(
@@ -8,6 +8,12 @@
8
8
  * @output Exports Dialog component, DialogProps, DialogVariant, DialogPurpose types
9
9
  * @position Core implementation; consumed by index.ts, tested by Dialog.test.tsx
10
10
  *
11
+ * The standard variant treats `width` as the preferred surface width, then
12
+ * clamps it to the dynamic viewport with spacing-token gutters so narrow
13
+ * viewports keep content and controls on screen without changing the public API.
14
+ * Fullscreen dialogs preserve the same padding floor while honoring safe-area
15
+ * insets, and fade in without the centered-dialog translate/scale motion.
16
+ *
11
17
  * SYNC: When modified, update these files to stay in sync:
12
18
  * - /packages/core/src/Dialog/Dialog.doc.mjs (props table, features, implementation notes)
13
19
  * - /packages/core/src/Dialog/Dialog.test.tsx (tests for new/changed behavior)
@@ -35,6 +41,7 @@ import {
35
41
  durationVars,
36
42
  easeVars,
37
43
  shadowVars,
44
+ spacingVars,
38
45
  } from '../theme/tokens.stylex';
39
46
  import {container} from '../Layout/container.stylex';
40
47
  import type {SpacingToken} from '../Layout/container.stylex';
@@ -118,6 +125,11 @@ const enterDirectional = stylex.keyframes({
118
125
  to: {opacity: 1, transform: 'translate(0, 0) scale(1)'},
119
126
  });
120
127
 
128
+ const enterFullscreen = stylex.keyframes({
129
+ from: {opacity: 0},
130
+ to: {opacity: 1},
131
+ });
132
+
121
133
  /**
122
134
  * Dialog styles using native <dialog> element
123
135
  * Uses ::backdrop pseudo-element for overlay
@@ -171,6 +183,22 @@ const styles = stylex.create({
171
183
  margin: 0,
172
184
  inset: 0,
173
185
  },
186
+ fullscreenOpen: {
187
+ animationName: {
188
+ default: enterFullscreen,
189
+ '@media (prefers-reduced-motion: reduce)': 'none',
190
+ },
191
+ },
192
+ fullscreenSafeArea: {
193
+ paddingBlockStart:
194
+ 'max(var(--container-padding-block-start), env(safe-area-inset-top, 0px))',
195
+ paddingBlockEnd:
196
+ 'max(var(--container-padding-block-end), env(safe-area-inset-bottom, 0px))',
197
+ paddingInlineStart:
198
+ 'max(var(--container-padding-inline-start), env(safe-area-inset-left, 0px))',
199
+ paddingInlineEnd:
200
+ 'max(var(--container-padding-inline-end), env(safe-area-inset-right, 0px))',
201
+ },
174
202
  inner: {
175
203
  display: 'flex',
176
204
  flexDirection: 'column',
@@ -194,12 +222,30 @@ const styles = stylex.create({
194
222
  },
195
223
  });
196
224
 
197
- // Dynamic styles for width, maxHeight, and position
225
+ const STANDARD_DIALOG_VIEWPORT_GUTTER = spacingVars['--spacing-4'];
226
+ const STANDARD_DIALOG_VIEWPORT_MAX_WIDTH = `calc(100dvw - ${STANDARD_DIALOG_VIEWPORT_GUTTER} - ${STANDARD_DIALOG_VIEWPORT_GUTTER})`;
227
+ const STANDARD_DIALOG_MAX_WIDTH = `min(100%, ${STANDARD_DIALOG_VIEWPORT_MAX_WIDTH})`;
228
+
229
+ function formatSizeValue(value: number | string): string {
230
+ return typeof value === 'number' ? `${value}px` : value;
231
+ }
232
+
233
+ function resolveDialogSizing(
234
+ width: number | string,
235
+ maxHeight: number | string,
236
+ ) {
237
+ return {
238
+ width: formatSizeValue(width),
239
+ maxWidth: STANDARD_DIALOG_MAX_WIDTH,
240
+ maxHeight: formatSizeValue(maxHeight),
241
+ };
242
+ }
243
+
198
244
  const dynamicStyles = stylex.create({
199
- sizing: (width: number | string, maxHeight: number | string) => ({
200
- width: typeof width === 'number' ? `${width}px` : width,
201
- maxWidth: '90vw',
202
- maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight,
245
+ sizing: (width: string, maxWidth: string, maxHeight: string) => ({
246
+ width,
247
+ maxWidth,
248
+ maxHeight,
203
249
  }),
204
250
  position: (
205
251
  top: string,
@@ -288,7 +334,7 @@ export interface DialogProps extends BaseProps<HTMLDialogElement> {
288
334
  * The actual height will be the height of its content.
289
335
  * Numbers are treated as pixels, strings are used as-is.
290
336
  * Ignored when variant is 'fullscreen'.
291
- * @default '75vh'
337
+ * @default '75dvh'
292
338
  */
293
339
  maxHeight?: number | string;
294
340
 
@@ -356,7 +402,7 @@ export function Dialog({
356
402
  isInline = false,
357
403
  onOpenChange,
358
404
  width = 400,
359
- maxHeight = '75vh',
405
+ maxHeight = '75dvh',
360
406
  position,
361
407
  variant = 'standard',
362
408
  purpose = 'info',
@@ -374,6 +420,9 @@ export function Dialog({
374
420
  const paddingToken = spacingStepToToken[effectivePadding] as SpacingToken;
375
421
 
376
422
  const isFullscreen = variant === 'fullscreen';
423
+ const standardSizing = isFullscreen
424
+ ? null
425
+ : resolveDialogSizing(width, maxHeight);
377
426
 
378
427
  // Default accessible name: publish a title id through DialogContext so a
379
428
  // DialogHeader applies it to its heading (mirrors AlertDialog's explicit
@@ -550,7 +599,6 @@ export function Dialog({
550
599
  }
551
600
  };
552
601
 
553
- // Shared inner content wrapper
554
602
  const innerContent = (
555
603
  <div
556
604
  {...stylex.props(
@@ -559,22 +607,14 @@ export function Dialog({
559
607
  useThemeDefault
560
608
  ? {
561
609
  useThemeDefault: 'dialog',
562
- maxHeight: isFullscreen
563
- ? undefined
564
- : typeof maxHeight === 'number'
565
- ? `${maxHeight}px`
566
- : maxHeight,
610
+ maxHeight: standardSizing?.maxHeight,
567
611
  }
568
612
  : {
569
613
  paddingInnerX: paddingToken,
570
614
  paddingInnerY: paddingToken,
571
615
  paddingOuterX: paddingToken,
572
616
  paddingOuterY: paddingToken,
573
- maxHeight: isFullscreen
574
- ? undefined
575
- : typeof maxHeight === 'number'
576
- ? `${maxHeight}px`
577
- : maxHeight,
617
+ maxHeight: standardSizing?.maxHeight,
578
618
  },
579
619
  ),
580
620
  !useThemeDefault &&
@@ -589,6 +629,7 @@ export function Dialog({
589
629
  !useThemeDefault &&
590
630
  effectivePadding !== 4 &&
591
631
  containerPaddingBlockEndVarStyles[effectivePadding],
632
+ isFullscreen && styles.fullscreenSafeArea,
592
633
  )}>
593
634
  <DialogContext value={dialogContextValue}>{children}</DialogContext>
594
635
  </div>
@@ -612,7 +653,12 @@ export function Dialog({
612
653
  stylex.props(
613
654
  styles.inlineWrapper,
614
655
  overlayPaddingReset.reset,
615
- !isFullscreen && dynamicStyles.sizing(width, maxHeight),
656
+ standardSizing &&
657
+ dynamicStyles.sizing(
658
+ standardSizing.width,
659
+ standardSizing.maxWidth,
660
+ standardSizing.maxHeight,
661
+ ),
616
662
  isFullscreen && styles.fullscreen,
617
663
  xstyle,
618
664
  ),
@@ -641,7 +687,12 @@ export function Dialog({
641
687
  overlayPaddingReset.reset,
642
688
  isOpen && styles.open,
643
689
  styles.backdrop,
644
- !isFullscreen && dynamicStyles.sizing(width, maxHeight),
690
+ standardSizing &&
691
+ dynamicStyles.sizing(
692
+ standardSizing.width,
693
+ standardSizing.maxWidth,
694
+ standardSizing.maxHeight,
695
+ ),
645
696
  hasPosition &&
646
697
  (() => {
647
698
  const o = resolveDialogPositionOffsets(position);
@@ -653,6 +704,7 @@ export function Dialog({
653
704
  );
654
705
  })(),
655
706
  isFullscreen && styles.fullscreen,
707
+ isFullscreen && isOpen && styles.fullscreenOpen,
656
708
  xstyle,
657
709
  ),
658
710
  className,
@@ -60,6 +60,17 @@ export const docs = {
60
60
  description: 'ID for the input element (used for the label htmlFor attribute).',
61
61
  required: true,
62
62
  },
63
+ {
64
+ name: 'labelID',
65
+ type: 'string',
66
+ description: 'ID applied to the label element itself for group accessibility.',
67
+ },
68
+ {
69
+ name: 'isGroupLabel',
70
+ type: 'boolean',
71
+ description: 'Renders the label as a span for control groups (radiogroup, checkbox list).',
72
+ default: 'false',
73
+ },
63
74
  {
64
75
  name: 'children',
65
76
  type: 'ReactNode',
@@ -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 {Heading} from './Heading';
11
11
 
12
12
  describe('Heading', () => {
@@ -16,6 +16,28 @@ describe('Heading', () => {
16
16
  expect(screen.getByText('Page Title')).toBeInTheDocument();
17
17
  });
18
18
 
19
+ it('keeps a forwarded ref attached across rerenders', () => {
20
+ const ref = vi.fn();
21
+ const {rerender} = render(
22
+ <Heading ref={ref} level={1}>
23
+ Before
24
+ </Heading>,
25
+ );
26
+
27
+ const element = screen.getByText('Before');
28
+ expect(ref).toHaveBeenLastCalledWith(element);
29
+ ref.mockClear();
30
+
31
+ rerender(
32
+ <Heading ref={ref} level={1}>
33
+ After
34
+ </Heading>,
35
+ );
36
+
37
+ expect(ref).not.toHaveBeenCalled();
38
+ expect(screen.getByText('After')).toBe(element);
39
+ });
40
+
19
41
  it('renders h1 for level 1', () => {
20
42
  render(<Heading level={1}>H1</Heading>);
21
43
  const element = screen.getByText('H1');
@@ -43,7 +43,8 @@ import {
43
43
  import {useTruncation} from '../Text/useTruncation';
44
44
  import {resolveStyleColor} from '../Text/Text';
45
45
  import type {LayerPlacement} from '../Layer';
46
- import {mergeProps, mergeRefs} from '../utils';
46
+ import {mergeProps} from '../utils';
47
+ import {useMergedRefs} from '../hooks/useMergedRefs';
47
48
  import type {BaseProps} from '../BaseProps';
48
49
  import {themeProps} from '../utils/themeProps';
49
50
 
@@ -242,13 +243,16 @@ export function Heading({
242
243
  // Ref for the heading element (used as tooltip anchor)
243
244
  const headingRef = useRef<HTMLHeadingElement>(null);
244
245
 
246
+ // Keep the merged ref stable across rerenders.
247
+ const mergedRef = useMergedRefs(ref, truncation.ref, headingRef);
248
+
245
249
  // Build inline style for -webkit-line-clamp (dynamic value)
246
250
  const inlineStyle = maxLines > 1 ? {WebkitLineClamp: maxLines} : undefined;
247
251
 
248
252
  return (
249
253
  <>
250
254
  <Component
251
- ref={mergeRefs(ref, truncation.ref, headingRef)}
255
+ ref={mergedRef}
252
256
  {...mergeProps(
253
257
  themeProps('heading', {level, color, ...(type && {type})}),
254
258
  stylex.props(
@@ -23,6 +23,7 @@ describe('Kbd', () => {
23
23
  value: originalPlatform,
24
24
  configurable: true,
25
25
  });
26
+ delete (navigator as {userAgentData?: unknown}).userAgentData;
26
27
  });
27
28
 
28
29
  it('renders a single key', () => {
@@ -54,6 +55,22 @@ describe('Kbd', () => {
54
55
  expect(screen.getByText('\u2318')).toBeInTheDocument(); // \u2318
55
56
  });
56
57
 
58
+ it('reads a blank userAgentData.platform as unknown, not as non-Mac', () => {
59
+ // Builds that rewrite their client-hints identity expose the key with an
60
+ // empty value; navigator.platform is the only surface left that answers.
61
+ Object.defineProperty(navigator, 'userAgentData', {
62
+ value: {platform: ''},
63
+ configurable: true,
64
+ });
65
+ Object.defineProperty(navigator, 'platform', {
66
+ value: 'MacIntel',
67
+ configurable: true,
68
+ });
69
+
70
+ render(<Kbd keys="mod" />);
71
+ expect(screen.getByText('\u2318')).toBeInTheDocument();
72
+ });
73
+
57
74
  it('maps modifier keys to symbols', () => {
58
75
  render(<Kbd keys="ctrl+alt+shift+k" />);
59
76
  expect(screen.getByText('\u2303')).toBeInTheDocument(); // \u2303
package/src/Kbd/Kbd.tsx CHANGED
@@ -124,8 +124,9 @@ function getServerPlatformSnapshot(): boolean {
124
124
 
125
125
  /**
126
126
  * Detects whether the current platform is macOS/iOS.
127
- * Prefers the User-Agent Client Hints API when available (modern Chrome/Edge),
128
- * falls back to navigator.platform (deprecated but universally supported).
127
+ * Prefers the User-Agent Client Hints API when it names a platform (modern
128
+ * Chrome/Edge), falls back to navigator.platform (deprecated but universally
129
+ * supported) when it is absent or blank.
129
130
  */
130
131
  function detectMac(): boolean {
131
132
  if (typeof navigator === 'undefined') {
@@ -134,7 +135,13 @@ function detectMac(): boolean {
134
135
  // Prefer User-Agent Client Hints API (not deprecated)
135
136
  const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
136
137
  if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
137
- return /mac/i.test((uaData as {platform: string}).platform ?? '');
138
+ const uaPlatform = (uaData as {platform?: unknown}).platform;
139
+ // A blank platform is no answer, not a negative one. Builds that rewrite
140
+ // their client-hints identity ship '', so fall through rather than
141
+ // reading it as "not Apple".
142
+ if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') {
143
+ return /mac/i.test(uaPlatform);
144
+ }
138
145
  }
139
146
  // Fallback: navigator.platform (deprecated but still shipped everywhere)
140
147
  return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');
@@ -78,6 +78,17 @@ export const docs = {
78
78
  description:
79
79
  'Link relationship tokens. noopener noreferrer are merged automatically for target="_blank".',
80
80
  },
81
+ {
82
+ name: 'download',
83
+ type: 'string | boolean',
84
+ description:
85
+ 'Causes the browser to download the linked URL. A string specifies the suggested filename.',
86
+ },
87
+ {
88
+ name: 'referrerPolicy',
89
+ type: 'HTMLAttributeReferrerPolicy',
90
+ description: 'Referrer policy for the link.',
91
+ },
81
92
  {
82
93
  name: 'onClick',
83
94
  type: 'MouseEventHandler',
@@ -25,6 +25,7 @@ export {
25
25
  export type {
26
26
  BlockNode,
27
27
  InlineNode,
28
+ SourceRange,
28
29
  ListItemNode,
29
30
  TableCellNode,
30
31
  TableAlignment,
@@ -6,7 +6,7 @@ import {
6
6
  parseMarkdownIncremental,
7
7
  createIncrementalState,
8
8
  } from './parser';
9
- import type {BlockNode} from './parser';
9
+ import type {BlockNode, ParseOptions} from './parser';
10
10
 
11
11
  function generateAIResponse(paragraphs: number): string {
12
12
  const sections: string[] = [];
@@ -69,6 +69,42 @@ function simulateStreamingIncremental(
69
69
  return parseMarkdownIncremental(fullText, state);
70
70
  }
71
71
 
72
+ /**
73
+ * How many blocks each chunk of a streamed document had to build.
74
+ *
75
+ * A block the parser kept in its settled cache is handed back as the very same
76
+ * object, so a returned block that is not one of the previous chunk's is one
77
+ * this chunk built. That makes the cache countable without reaching into the
78
+ * parser's internals.
79
+ */
80
+ function blocksBuiltPerChunk(
81
+ fullText: string,
82
+ chunkSize: number,
83
+ options?: ParseOptions,
84
+ ): number[] {
85
+ const state = createIncrementalState();
86
+ let previous: ReadonlySet<BlockNode> = new Set();
87
+ const built: number[] = [];
88
+ const step = (text: string) => {
89
+ const blocks =
90
+ options == null
91
+ ? parseMarkdownIncremental(text, state)
92
+ : parseMarkdownIncremental(text, state, options);
93
+ built.push(blocks.reduce((n, b) => (previous.has(b) ? n : n + 1), 0));
94
+ previous = new Set(blocks);
95
+ };
96
+ for (let i = chunkSize; i <= fullText.length; i += chunkSize) {
97
+ step(fullText.slice(0, i));
98
+ }
99
+ step(fullText);
100
+ return built;
101
+ }
102
+
103
+ const median = (values: number[]): number =>
104
+ [...values].sort((a, b) => a - b)[Math.floor((values.length - 1) / 2)];
105
+
106
+ const sum = (values: number[]): number => values.reduce((a, b) => a + b, 0);
107
+
72
108
  // A single wall-clock measurement is flaky when the whole suite runs in
73
109
  // parallel forks: scheduler contention can inflate a millisecond-scale
74
110
  // sample well past a tight budget (observed 30.9ms against the 20ms Small
@@ -204,3 +240,37 @@ describe('parseMarkdown performance', () => {
204
240
  expect(incrElapsed).toBeLessThanOrEqual(fullElapsed * 1.1);
205
241
  });
206
242
  });
243
+
244
+ // What the incremental parser exists for is a cache, so these assert the cache:
245
+ // integers that come out the same on a loaded CI box as on an idle laptop,
246
+ // unlike the wall-clock budgets above.
247
+ describe('parseMarkdownIncremental cache', () => {
248
+ it('builds a bounded number of blocks per chunk however long the document is', () => {
249
+ const chunkSize = 50;
250
+ const medians = [50, 200, 500].map(paragraphs =>
251
+ median(blocksBuiltPerChunk(generateAIResponse(paragraphs), chunkSize)),
252
+ );
253
+ console.log(
254
+ ` median blocks built per chunk (50/200/500 paragraphs): ${medians.join('/')}`,
255
+ );
256
+ // A chunk re-parses its unsettled tail, not the document — so the typical
257
+ // chunk's cost is a constant, and a 10x longer document does not move it.
258
+ expect(medians).toEqual([2, 2, 2]);
259
+ });
260
+
261
+ it('keeps the cache when source ranges are asked for', () => {
262
+ const text = generateAIResponse(200);
263
+ const chunkSize = 50;
264
+ const without = sum(blocksBuiltPerChunk(text, chunkSize));
265
+ const withRanges = sum(
266
+ blocksBuiltPerChunk(text, chunkSize, {sourceRanges: true}),
267
+ );
268
+ console.log(
269
+ ` blocks built streaming ${text.length} chars: ${without} without ranges, ${withRanges} with`,
270
+ );
271
+ // Stamping offsets must not cost a cache entry. An implementation that
272
+ // tracked positions by invalidating the settled cache each chunk would
273
+ // still pass any time budget generous enough not to flake; it fails here.
274
+ expect(withRanges).toBe(without);
275
+ });
276
+ });