@astryxdesign/core 0.4.1 → 0.4.2-canary.356d2f9

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 (201) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/Avatar/Avatar.d.ts +4 -1
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +19 -23
  5. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  6. package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
  7. package/dist/Button/Button.d.ts.map +1 -1
  8. package/dist/Button/Button.js +9 -7
  9. package/dist/Chat/ChatMessage.d.ts +7 -0
  10. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  11. package/dist/Chat/ChatMessageBubble.d.ts +13 -1
  12. package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageBubble.js +19 -1
  14. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.js +5 -0
  16. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
  17. package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
  18. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  19. package/dist/DateTimeInput/DateTimeInput.js +2 -2
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
  22. package/dist/HoverCard/HoverCard.d.ts +1 -1
  23. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/HoverCard.js +5 -13
  25. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  26. package/dist/HoverCard/useHoverCard.js +6 -5
  27. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  28. package/dist/InputGroup/groupStyles.js +7 -2
  29. package/dist/Layer/layerHost.d.ts +24 -0
  30. package/dist/Layer/layerHost.d.ts.map +1 -0
  31. package/dist/Layer/layerHost.js +79 -0
  32. package/dist/Layer/useLayer.d.ts +14 -6
  33. package/dist/Layer/useLayer.d.ts.map +1 -1
  34. package/dist/Layer/useLayer.js +158 -30
  35. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  36. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  37. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  38. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  39. package/dist/RadioList/RadioListItem.js +5 -0
  40. package/dist/SideNav/SideNav.d.ts +7 -9
  41. package/dist/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/SideNav/SideNav.js +32 -5
  43. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  44. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  45. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  46. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  47. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  48. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  49. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  50. package/dist/SideNav/SideNavHeading.js +89 -32
  51. package/dist/SideNav/SideNavItem.d.ts +7 -2
  52. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  53. package/dist/SideNav/SideNavItem.js +119 -75
  54. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  55. package/dist/SideNav/SideNavSection.js +7 -16
  56. package/dist/SideNav/index.d.ts +1 -1
  57. package/dist/SideNav/index.d.ts.map +1 -1
  58. package/dist/Slider/Slider.d.ts.map +1 -1
  59. package/dist/Slider/Slider.js +56 -15
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -0
  62. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  63. package/dist/Thumbnail/Thumbnail.js +5 -0
  64. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavHeading.js +14 -6
  66. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  67. package/dist/TopNav/TopNavMegaMenu.js +25 -93
  68. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  69. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  70. package/dist/TopNav/TopNavMenu.js +10 -5
  71. package/dist/astryx.css +40 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.stylex.d.ts +71 -4
  75. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
  76. package/dist/hooks/containerReveal.stylex.js +57 -5
  77. package/dist/hooks/index.d.ts +2 -2
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +1 -1
  80. package/dist/hooks/useContainerReveal.d.ts +60 -3
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -1
  82. package/dist/hooks/useContainerReveal.js +27 -5
  83. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  84. package/dist/hooks/useFocusTrap.js +16 -2
  85. package/dist/hooks/useMenuHover.d.ts +50 -5
  86. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  87. package/dist/hooks/useMenuHover.js +171 -51
  88. package/dist/theme/defineTheme.d.ts +16 -5
  89. package/dist/theme/defineTheme.d.ts.map +1 -1
  90. package/dist/theme/defineTheme.js +44 -48
  91. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  92. package/dist/theme/derivedVarRegistry.js +7 -0
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +1 -0
  95. package/dist/theme/expandMotionScale.d.ts +1 -0
  96. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  97. package/dist/theme/expandMotionScale.js +1 -0
  98. package/dist/theme/expandRadiusScale.d.ts +1 -0
  99. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  100. package/dist/theme/expandRadiusScale.js +1 -0
  101. package/dist/theme/expandTypeScale.d.ts +1 -0
  102. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  103. package/dist/theme/expandTypeScale.js +1 -0
  104. package/dist/theme/mergeComponents.d.ts +20 -0
  105. package/dist/theme/mergeComponents.d.ts.map +1 -0
  106. package/dist/theme/mergeComponents.js +56 -0
  107. package/dist/theme/onMediaTokens.d.ts +6 -1
  108. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  109. package/dist/theme/onMediaTokens.js +11 -3
  110. package/dist/theme/tokens.stylex.d.ts +3 -1
  111. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  112. package/dist/theme/tokens.stylex.js +3 -1
  113. package/locales/en.json +84 -0
  114. package/locales/pseudo.json +63 -0
  115. package/package.json +3 -3
  116. package/src/Avatar/Avatar.doc.mjs +5 -2
  117. package/src/Avatar/Avatar.test.tsx +51 -0
  118. package/src/Avatar/Avatar.tsx +30 -20
  119. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  120. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  121. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  122. package/src/Button/Button.tsx +5 -3
  123. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  124. package/src/Card/Card.doc.mjs +2 -0
  125. package/src/Chat/Chat.doc.mjs +4 -1
  126. package/src/Chat/ChatMessage.doc.mjs +3 -3
  127. package/src/Chat/ChatMessage.tsx +7 -0
  128. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  129. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  130. package/src/Chat/ChatMessageBubble.tsx +25 -0
  131. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  132. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  133. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  134. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  135. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  136. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  137. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  138. package/src/HoverCard/HoverCard.test.tsx +283 -55
  139. package/src/HoverCard/HoverCard.tsx +5 -13
  140. package/src/HoverCard/useHoverCard.tsx +8 -11
  141. package/src/InputGroup/groupStyles.ts +7 -8
  142. package/src/Item/Item.doc.mjs +4 -0
  143. package/src/Layer/layerHost.test.ts +99 -0
  144. package/src/Layer/layerHost.ts +141 -0
  145. package/src/Layer/useLayer.doc.mjs +14 -4
  146. package/src/Layer/useLayer.test.tsx +332 -7
  147. package/src/Layer/useLayer.tsx +235 -36
  148. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  149. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  150. package/src/RadioList/RadioListItem.tsx +20 -0
  151. package/src/SideNav/SideNav.doc.mjs +13 -4
  152. package/src/SideNav/SideNav.test.tsx +858 -2
  153. package/src/SideNav/SideNav.tsx +37 -16
  154. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  155. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  156. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  157. package/src/SideNav/SideNavHeading.tsx +53 -25
  158. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  159. package/src/SideNav/SideNavItem.tsx +97 -84
  160. package/src/SideNav/SideNavSection.tsx +6 -20
  161. package/src/SideNav/index.ts +2 -0
  162. package/src/Slider/Slider.test.tsx +69 -33
  163. package/src/Slider/Slider.tsx +65 -13
  164. package/src/Switch/Switch.tsx +20 -0
  165. package/src/TabList/TabList.doc.mjs +3 -0
  166. package/src/Text/Text.doc.mjs +1 -1
  167. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  168. package/src/Thumbnail/Thumbnail.tsx +10 -0
  169. package/src/Timestamp/Timestamp.test.tsx +40 -22
  170. package/src/Toolbar/Toolbar.test.tsx +14 -14
  171. package/src/TopNav/TopNav.test.tsx +86 -1
  172. package/src/TopNav/TopNavHeading.tsx +21 -14
  173. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  174. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  175. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  176. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  177. package/src/TopNav/TopNavMenu.tsx +14 -4
  178. package/src/TreeList/TreeList.doc.mjs +1 -0
  179. package/src/hooks/containerReveal.stylex.ts +140 -9
  180. package/src/hooks/index.ts +6 -1
  181. package/src/hooks/useContainerReveal.doc.mjs +11 -5
  182. package/src/hooks/useContainerReveal.test.tsx +70 -3
  183. package/src/hooks/useContainerReveal.ts +86 -7
  184. package/src/hooks/useFocusTrap.test.tsx +16 -0
  185. package/src/hooks/useFocusTrap.ts +20 -2
  186. package/src/hooks/useMenuHover.test.tsx +364 -0
  187. package/src/hooks/useMenuHover.ts +243 -47
  188. package/src/theme/defineTheme.test.ts +127 -0
  189. package/src/theme/defineTheme.ts +57 -51
  190. package/src/theme/derivedVarRegistry.test.ts +78 -12
  191. package/src/theme/derivedVarRegistry.ts +4 -0
  192. package/src/theme/expandColorScale.ts +1 -0
  193. package/src/theme/expandMotionScale.ts +1 -0
  194. package/src/theme/expandRadiusScale.ts +1 -0
  195. package/src/theme/expandTypeScale.ts +1 -0
  196. package/src/theme/mergeComponents.ts +59 -0
  197. package/src/theme/onMediaTokens.ts +9 -2
  198. package/src/theme/themingTargets.test.ts +152 -26
  199. package/src/theme/tokens.stylex.ts +3 -1
  200. package/src/theme/tokens.test.ts +12 -0
  201. package/src/theme/useTheme.test.tsx +18 -0
@@ -174,6 +174,15 @@ const styles = stylex.create({
174
174
  },
175
175
  trackContainerHorizontal: {
176
176
  height: THUMB_SIZE,
177
+ // The whole track is the tap target (a click anywhere on it moves the
178
+ // slider), but it is only THUMB_SIZE (20px) tall — under the WCAG 2.5.8 AA
179
+ // 24px minimum. Floor its block size to 24px on touch pointers only. The
180
+ // rail and thumb center on 50%, so they stay put; only the invisible
181
+ // tappable area grows. Desktop (fine pointer) is unchanged.
182
+ minBlockSize: {
183
+ default: null,
184
+ '@media (pointer: coarse)': '24px',
185
+ },
177
186
  width: '100%',
178
187
  cursor: 'pointer',
179
188
  },
@@ -366,6 +375,44 @@ function getPercent(val: number, min: number, max: number): number {
366
375
  return ((val - min) / (max - min)) * 100;
367
376
  }
368
377
 
378
+ /**
379
+ * Thumb travel is inset by half a thumb at each end — the geometry a native
380
+ * `input[type=range]` uses — so the thumb stays inside the component box at
381
+ * min and max instead of overhanging it by half its width (#5050). The fill
382
+ * and the marks map through the same inset, and `travelFraction` inverts it,
383
+ * so the thumb tracks the pointer that grabbed it. Both directions read
384
+ * THUMB_SIZE, so a theme cannot resize the thumb through CSS alone.
385
+ */
386
+ const THUMB_INSET = THUMB_SIZE / 2;
387
+
388
+ function insetPosition(percent: number): string {
389
+ return cssLength(percent, THUMB_INSET - (percent / 100) * THUMB_SIZE);
390
+ }
391
+
392
+ /** Distance between two inset positions; the inset itself cancels out. */
393
+ function insetSpan(fromPercent: number, toPercent: number): string {
394
+ const delta = toPercent - fromPercent;
395
+ return cssLength(delta, -(delta / 100) * THUMB_SIZE);
396
+ }
397
+
398
+ /** Inverse of `insetPosition`: an offset from the box start back to 0–1. */
399
+ function travelFraction(offset: number, size: number): number {
400
+ const travel = size - THUMB_SIZE;
401
+ if (travel > 0) {
402
+ return (offset - THUMB_INSET) / travel;
403
+ }
404
+ // Narrower than the thumb, so there is no travel to map onto: fall back to
405
+ // the raw fraction rather than dividing by zero.
406
+ return size > 0 ? offset / size : 0;
407
+ }
408
+
409
+ function cssLength(percent: number, px: number): string {
410
+ // Percentages of the box and step arithmetic both carry binary
411
+ // floating-point error into the DOM (`calc(33% + 3.3999999999999995px)`).
412
+ const round = (n: number) => Number(n.toFixed(3));
413
+ return `calc(${round(percent)}% ${px < 0 ? '-' : '+'} ${Math.abs(round(px))}px)`;
414
+ }
415
+
369
416
  // =============================================================================
370
417
  // Component
371
418
  // =============================================================================
@@ -483,17 +530,21 @@ export function Slider({ref, ...props}: SliderProps) {
483
530
  }
484
531
  const rect = track.getBoundingClientRect();
485
532
 
533
+ // Inverse of `insetPosition`: the pointer maps onto the thumb's travel
534
+ // (the box minus half a thumb at each end), so pressing on the thumb
535
+ // leaves it where it is instead of jumping.
486
536
  let percent: number;
487
537
  if (isHorizontal) {
488
538
  // In RTL the inline-start (value = min) is the right edge, so measure
489
539
  // the pointer fraction from the right instead of the left. Detected
490
540
  // from the track's computed direction (lazy, only on pointer move).
491
- percent = isRtlElement(track)
492
- ? (rect.right - clientX) / rect.width
493
- : (clientX - rect.left) / rect.width;
541
+ percent = travelFraction(
542
+ isRtlElement(track) ? rect.right - clientX : clientX - rect.left,
543
+ rect.width,
544
+ );
494
545
  } else {
495
546
  // Vertical: bottom = min, top = max
496
- percent = 1 - (clientY - rect.top) / rect.height;
547
+ percent = 1 - travelFraction(clientY - rect.top, rect.height);
497
548
  }
498
549
  percent = clamp(percent, 0, 1);
499
550
  const raw = min + percent * (max - min);
@@ -723,8 +774,8 @@ export function Slider({ref, ...props}: SliderProps) {
723
774
  const percent = getPercent(val, min, max);
724
775
 
725
776
  const positionStyle = isHorizontal
726
- ? {insetInlineStart: `${percent}%`}
727
- : {bottom: `${percent}%`, left: '50%'};
777
+ ? {insetInlineStart: insetPosition(percent)}
778
+ : {bottom: insetPosition(percent), left: '50%'};
728
779
 
729
780
  // In range mode each thumb keeps a short individual name that composes
730
781
  // with the group label (announced via the group's aria-labelledby), per
@@ -809,22 +860,23 @@ export function Slider({ref, ...props}: SliderProps) {
809
860
  return thumbElement;
810
861
  };
811
862
 
812
- // Filled track position
863
+ // Filled track position — ends at the thumb centre, so it uses the same
864
+ // inset mapping as the thumb.
813
865
  const filledStyle = (() => {
814
866
  if (isRange) {
815
867
  const [v0, v1] = values;
816
868
  const p0 = getPercent(v0, min, max);
817
869
  const p1 = getPercent(v1, min, max);
818
870
  if (isHorizontal) {
819
- return {insetInlineStart: `${p0}%`, width: `${p1 - p0}%`};
871
+ return {insetInlineStart: insetPosition(p0), width: insetSpan(p0, p1)};
820
872
  }
821
- return {bottom: `${p0}%`, height: `${p1 - p0}%`};
873
+ return {bottom: insetPosition(p0), height: insetSpan(p0, p1)};
822
874
  }
823
875
  const p = getPercent(values[0], min, max);
824
876
  if (isHorizontal) {
825
- return {insetInlineStart: '0%', width: `${p}%`};
877
+ return {insetInlineStart: '0%', width: insetPosition(p)};
826
878
  }
827
- return {bottom: '0%', height: `${p}%`};
879
+ return {bottom: '0%', height: insetPosition(p)};
828
880
  })();
829
881
 
830
882
  // Text value display
@@ -949,8 +1001,8 @@ export function Slider({ref, ...props}: SliderProps) {
949
1001
  {marks.map(mark => {
950
1002
  const percent = getPercent(mark.value, min, max);
951
1003
  const markPos = isHorizontal
952
- ? {insetInlineStart: `${percent}%`}
953
- : {bottom: `${percent}%`};
1004
+ ? {insetInlineStart: insetPosition(percent)}
1005
+ : {bottom: insetPosition(percent)};
954
1006
  return (
955
1007
  <div key={mark.value}>
956
1008
  <div
@@ -156,6 +156,26 @@ const styles = stylex.create({
156
156
  opacity: 0,
157
157
  cursor: 'pointer',
158
158
  zIndex: 1,
159
+ minInlineSize: {
160
+ default: null,
161
+ '@media (pointer: coarse)': '24px',
162
+ },
163
+ minBlockSize: {
164
+ default: null,
165
+ '@media (pointer: coarse)': '24px',
166
+ },
167
+ insetBlockStart: {
168
+ default: null,
169
+ '@media (pointer: coarse)': '50%',
170
+ },
171
+ insetInlineStart: {
172
+ default: null,
173
+ '@media (pointer: coarse)': '50%',
174
+ },
175
+ transform: {
176
+ default: null,
177
+ '@media (pointer: coarse)': 'translate(-50%, -50%)',
178
+ },
159
179
  },
160
180
  inputDisabled: {
161
181
  cursor: 'not-allowed',
@@ -22,6 +22,9 @@ export const docs = {
22
22
  {className: 'astryx-tab-menu-dropdown'},
23
23
  {className: 'astryx-tab-menu-item'},
24
24
  ],
25
+ vars: [
26
+ {name: '--_tab-indicator-bottom', description: 'Vertical offset of the selected-tab indicator from the tab bottom edge. A host that draws its own bottom divider (Toolbar) sets this so the indicator sits on the divider instead of above it.', default: '-1px', private: true},
27
+ ],
25
28
  },
26
29
  description: 'Nav wrapper that provides TabListContext (value, onChange, size) to Tab and TabMenu children.',
27
30
  props: [
@@ -15,7 +15,7 @@ export const docs = {
15
15
  },
16
16
  theming: {
17
17
  targets: [
18
- {className: 'astryx-heading', visualProps: ['level', 'color']},
18
+ {className: 'astryx-heading', visualProps: ['level', 'color', 'type']},
19
19
  {className: 'astryx-text', visualProps: ['type', 'size', 'color']},
20
20
  ],
21
21
  },
@@ -77,6 +77,9 @@ export const docs = {
77
77
  targets: [
78
78
  {className: 'astryx-thumbnail'},
79
79
  ],
80
+ vars: [
81
+ {name: '--_thumbnail-hit-inset', description: 'Outset of the remove button\u2019s invisible hit area, applied to a ::after overlay. 0 on a fine pointer; negative on a coarse one, which grows the 20px button to the 24px touch target without changing what is drawn.', default: '0px', private: true},
82
+ ],
80
83
  },
81
84
  usage: {
82
85
  description:
@@ -207,6 +207,7 @@ const styles = stylex.create({
207
207
  },
208
208
  removeButtonOverrides: {
209
209
  '--_button-radius': `calc(${radiusVars['--radius-element']} - ${spacingVars['--spacing-1']})`,
210
+ position: 'relative',
210
211
  height: 20,
211
212
  minWidth: 20,
212
213
  // Fixed colors instead of luminance-adapting theme: a translucent scrim
@@ -214,6 +215,15 @@ const styles = stylex.create({
214
215
  // without sampling pixel brightness.
215
216
  backgroundColor: colorVars['--color-overlay'],
216
217
  color: colorVars['--color-on-dark'],
218
+ '--_thumbnail-hit-inset': {
219
+ default: '0px',
220
+ '@media (pointer: coarse)': '-2px',
221
+ },
222
+ '::after': {
223
+ content: '""',
224
+ position: 'absolute',
225
+ inset: 'var(--_thumbnail-hit-inset)',
226
+ },
217
227
  },
218
228
  disabled: {
219
229
  opacity: 0.5,
@@ -16,6 +16,24 @@ import {formatTooltipLines} from './tooltipEntries';
16
16
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
17
17
  import {InternationalizationProvider} from '../i18n';
18
18
 
19
+ async function openTimestampHoverCard(): Promise<HTMLElement> {
20
+ const timestamp = document.querySelector('time');
21
+ if (timestamp == null) {
22
+ throw new Error('Expected Timestamp to render a <time> element');
23
+ }
24
+ const trigger = timestamp.parentElement;
25
+ if (trigger == null) {
26
+ throw new Error('Expected Timestamp to render a hover-card trigger');
27
+ }
28
+
29
+ fireEvent.mouseEnter(trigger);
30
+ await waitFor(() => {
31
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
32
+ });
33
+
34
+ return screen.getByRole('dialog', {hidden: true});
35
+ }
36
+
19
37
  describe('Timestamp', () => {
20
38
  // The hover card is loaded lazily (React.lazy + Suspense), so its chunk
21
39
  // resolves asynchronously the first time a card renders. Warm the module
@@ -646,14 +664,13 @@ describe('Timestamp', () => {
646
664
  );
647
665
  const el = screen.getByTestId('ts');
648
666
 
649
- // The card layer mounts inline and carries the full absolute time in
650
- // its visible form (short timezone abbreviation) as its single default
651
- // copyable row. The aria-label spells the timezone out in full
652
- // (WCAG 3.1.4), so the two strings intentionally differ — compare the
653
- // card against an independently formatted short-form string.
667
+ // The card carries the full absolute time in its visible form (short
668
+ // timezone abbreviation) as its single default copyable row. The
669
+ // aria-label spells the timezone out in full (WCAG 3.1.4), so the two
670
+ // strings intentionally differ — compare the card against an
671
+ // independently formatted short-form string after opening it.
654
672
  // Compare with normalized whitespace: Intl output can contain narrow
655
673
  // no-break spaces that jest-dom's matcher normalization would break on.
656
- const card = await screen.findByRole('dialog', {hidden: true});
657
674
  const normalize = (s: string) => s.replace(/\s+/g, ' ');
658
675
  const datetime = new Date(el.getAttribute('datetime') ?? '');
659
676
  const expected = new Intl.DateTimeFormat(undefined, {
@@ -665,14 +682,14 @@ describe('Timestamp', () => {
665
682
  second: '2-digit',
666
683
  timeZoneName: 'short',
667
684
  }).format(datetime);
668
- expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
669
-
670
685
  // Tab onto the timestamp — the only tab stop in the document.
671
686
  await user.tab();
672
687
  expect(el).toHaveFocus();
673
688
  await waitFor(() => {
674
689
  expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
675
690
  });
691
+ const card = screen.getByRole('dialog', {hidden: true});
692
+ expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
676
693
  });
677
694
 
678
695
  it('does not add a tab stop when the hover card is disabled', () => {
@@ -756,7 +773,7 @@ describe('Timestamp', () => {
756
773
  .queryAllByRole('tooltip', {hidden: true})
757
774
  .filter(el => !/^(Copy|Copied)$/.test(el.textContent?.trim() ?? '')),
758
775
  ).toHaveLength(0);
759
- const card = await screen.findByRole('dialog', {hidden: true});
776
+ const card = await openTimestampHoverCard();
760
777
  // The default card is the named details card, exactly as the configured
761
778
  // one is.
762
779
  expect(card).toHaveAttribute('aria-label', 'Timestamp details');
@@ -786,7 +803,7 @@ describe('Timestamp', () => {
786
803
 
787
804
  it("copies the default absolute row's value", async () => {
788
805
  render(<Timestamp value={Date.now() / 1000 - 3600} format="relative" />);
789
- const card = await screen.findByRole('dialog', {hidden: true});
806
+ const card = await openTimestampHoverCard();
790
807
  const rowValue = card.querySelector('dd')?.textContent ?? '';
791
808
  expect(rowValue).toBeTruthy();
792
809
  fireEvent.click(card.querySelector('button')!);
@@ -802,10 +819,11 @@ describe('Timestamp', () => {
802
819
 
803
820
  it("shows a 'Copy' tooltip on the copy button, flipping to 'Copied' after a copy", async () => {
804
821
  render(<Timestamp value={Date.now() / 1000 - 3600} format="relative" />);
805
- const card = await screen.findByRole('dialog', {hidden: true});
822
+ const card = await openTimestampHoverCard();
806
823
  const button = card.querySelector('button')!;
807
824
  // The visible tooltip content defaults to the short imperative 'Copy'
808
825
  // (the full "Copy <value>" string stays the button's aria-label).
826
+ fireEvent.mouseEnter(button);
809
827
  await waitFor(() =>
810
828
  expect(
811
829
  screen.getByText('Copy', {
@@ -872,7 +890,7 @@ describe('Timestamp', () => {
872
890
  .queryAllByRole('tooltip', {hidden: true})
873
891
  .filter(el => !/^(Copy|Copied)$/.test(el.textContent?.trim() ?? '')),
874
892
  ).toHaveLength(0);
875
- const card = await screen.findByRole('dialog', {hidden: true});
893
+ const card = await openTimestampHoverCard();
876
894
  expect(card).toHaveAttribute('aria-label', 'Timestamp details');
877
895
  });
878
896
 
@@ -888,7 +906,7 @@ describe('Timestamp', () => {
888
906
  ]}
889
907
  />,
890
908
  );
891
- const card = await screen.findByRole('dialog', {hidden: true});
909
+ const card = await openTimestampHoverCard();
892
910
  expect(card.querySelectorAll('dd')).toHaveLength(3);
893
911
  // Entries are read-only unless they opt in, so no copy buttons and no
894
912
  // trailing action column are rendered.
@@ -915,7 +933,7 @@ describe('Timestamp', () => {
915
933
  ]}
916
934
  />,
917
935
  );
918
- const card = await screen.findByRole('dialog', {hidden: true});
936
+ const card = await openTimestampHoverCard();
919
937
  // Three rows, but only the opted-in row carries a copy button.
920
938
  expect(card.querySelectorAll('dd')).toHaveLength(3);
921
939
  expect(card.querySelectorAll('button')).toHaveLength(1);
@@ -936,7 +954,7 @@ describe('Timestamp', () => {
936
954
  ]}
937
955
  />,
938
956
  );
939
- const card = await screen.findByRole('dialog', {hidden: true});
957
+ const card = await openTimestampHoverCard();
940
958
  const values = Array.from(card.querySelectorAll('dd')).map(
941
959
  el => el.textContent,
942
960
  );
@@ -960,7 +978,7 @@ describe('Timestamp', () => {
960
978
  ]}
961
979
  />,
962
980
  );
963
- const card = await screen.findByRole('dialog', {hidden: true});
981
+ const card = await openTimestampHoverCard();
964
982
  const [rowValue] = Array.from(card.querySelectorAll('dd')).map(
965
983
  el => el.textContent ?? '',
966
984
  );
@@ -991,7 +1009,7 @@ describe('Timestamp', () => {
991
1009
  />
992
1010
  </InternationalizationProvider>,
993
1011
  );
994
- const card = await screen.findByRole('dialog', {hidden: true});
1012
+ const card = await openTimestampHoverCard();
995
1013
  fireEvent.click(card.querySelector('button')!);
996
1014
  await waitFor(() => {
997
1015
  expect(
@@ -1011,7 +1029,7 @@ describe('Timestamp', () => {
1011
1029
  );
1012
1030
  // Without entries an absolute format has no hover surface at all;
1013
1031
  // configuring entries must not be silently ignored.
1014
- const card = await screen.findByRole('dialog', {hidden: true});
1032
+ const card = await openTimestampHoverCard();
1015
1033
  expect(card.textContent).toContain('UTC');
1016
1034
  // ...and the anchor becomes keyboard-reachable, as it is for relative.
1017
1035
  expect(screen.getByTestId('ts')).toHaveAttribute('tabindex', '0');
@@ -1120,7 +1138,7 @@ describe('Timestamp', () => {
1120
1138
  />,
1121
1139
  );
1122
1140
  // autoThreshold={0} forces the absolute branch regardless of the clock.
1123
- const card = await screen.findByRole('dialog', {hidden: true});
1141
+ const card = await openTimestampHoverCard();
1124
1142
  expect(card.textContent).toContain('2026-02-19 17:00:00');
1125
1143
  expect(screen.getByTestId('ts')).toHaveAttribute('tabindex', '0');
1126
1144
  });
@@ -1135,7 +1153,7 @@ describe('Timestamp', () => {
1135
1153
  ]}
1136
1154
  />,
1137
1155
  );
1138
- const card = await screen.findByRole('dialog', {hidden: true});
1156
+ const card = await openTimestampHoverCard();
1139
1157
  expect(card.textContent).toContain('2026-02-19 17:00:00');
1140
1158
  });
1141
1159
 
@@ -1151,7 +1169,7 @@ describe('Timestamp', () => {
1151
1169
  ]}
1152
1170
  />,
1153
1171
  );
1154
- const card = await screen.findByRole('dialog', {hidden: true});
1172
+ const card = await openTimestampHoverCard();
1155
1173
  // An unlabeled entry still emits its label cell, so the grid stays
1156
1174
  // aligned and the <dl> stays valid markup.
1157
1175
  expect(card.querySelectorAll('dt')).toHaveLength(3);
@@ -1189,7 +1207,7 @@ describe('Timestamp', () => {
1189
1207
  ]}
1190
1208
  />,
1191
1209
  );
1192
- const card = await screen.findByRole('dialog', {hidden: true});
1210
+ const card = await openTimestampHoverCard();
1193
1211
  const values = Array.from(card.querySelectorAll('dd')).map(
1194
1212
  el => el.textContent,
1195
1213
  );
@@ -72,11 +72,11 @@ describe('Toolbar', () => {
72
72
  expect(screen.getByTestId('end')).toBeInTheDocument();
73
73
 
74
74
  // Three-slot layout produces 3 child divs (plus the aria-hidden
75
- // keyboard-hint popover, which is excluded here as an implementation detail)
75
+ // keyboard-hint layer infrastructure, excluded as an implementation detail)
76
76
  const toolbar = screen.getByRole('toolbar');
77
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
78
- 3,
79
- );
77
+ expect(
78
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
79
+ ).toHaveLength(3);
80
80
  });
81
81
 
82
82
  it('renders two-slot layout without centerContent', () => {
@@ -91,11 +91,11 @@ describe('Toolbar', () => {
91
91
  expect(screen.getByTestId('end')).toBeInTheDocument();
92
92
 
93
93
  // Two-slot layout produces 2 child divs (plus the aria-hidden
94
- // keyboard-hint popover, excluded here)
94
+ // keyboard-hint layer infrastructure, excluded here)
95
95
  const toolbar = screen.getByRole('toolbar');
96
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
97
- 2,
98
- );
96
+ expect(
97
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
98
+ ).toHaveLength(2);
99
99
  });
100
100
 
101
101
  it('renders start-only layout', () => {
@@ -107,9 +107,9 @@ describe('Toolbar', () => {
107
107
  );
108
108
  expect(screen.getByTestId('start')).toBeInTheDocument();
109
109
  const toolbar = screen.getByRole('toolbar');
110
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
111
- 1,
112
- );
110
+ expect(
111
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
112
+ ).toHaveLength(1);
113
113
  });
114
114
 
115
115
  it('renders end-only layout', () => {
@@ -121,9 +121,9 @@ describe('Toolbar', () => {
121
121
  );
122
122
  expect(screen.getByTestId('end')).toBeInTheDocument();
123
123
  const toolbar = screen.getByRole('toolbar');
124
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
125
- 1,
126
- );
124
+ expect(
125
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
126
+ ).toHaveLength(1);
127
127
  });
128
128
 
129
129
  it('sets aria-orientation to horizontal by default', () => {
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import {describe, it, expect, vi} from 'vitest';
13
- import {fireEvent, render, screen} from '@testing-library/react';
13
+ import {fireEvent, render, screen, act} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {TopNav} from './TopNav';
16
16
  import {TopNavRenderContext} from './TopNavRenderContext';
@@ -660,3 +660,88 @@ describe('TopNavItem', () => {
660
660
  });
661
661
  });
662
662
  });
663
+
664
+ describe('TopNavHeading hover/click guard', () => {
665
+ // tabIndex={-1} matches real menu items; a bare role=menuitem div is not
666
+ // focusable.
667
+ const menuItems = (
668
+ <>
669
+ <div role="menuitem" tabIndex={-1}>
670
+ Alpha
671
+ </div>
672
+ <div role="menuitem" tabIndex={-1}>
673
+ Beta
674
+ </div>
675
+ </>
676
+ );
677
+
678
+ it('keeps the menu open when a hover-open is immediately clicked', async () => {
679
+ vi.useFakeTimers({shouldAdvanceTime: true});
680
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
681
+ render(<TopNavHeading heading="My App" menu={menuItems} />);
682
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
683
+
684
+ await user.hover(trigger);
685
+ act(() => {
686
+ vi.advanceTimersByTime(50);
687
+ });
688
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
689
+
690
+ await user.click(trigger);
691
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
692
+
693
+ vi.useRealTimers();
694
+ });
695
+
696
+ it('closes on a click that lands well after the hover-open', async () => {
697
+ vi.useFakeTimers({shouldAdvanceTime: true});
698
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
699
+ render(<TopNavHeading heading="My App" menu={menuItems} />);
700
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
701
+
702
+ await user.hover(trigger);
703
+ act(() => {
704
+ vi.advanceTimersByTime(50);
705
+ });
706
+ act(() => {
707
+ vi.advanceTimersByTime(1200);
708
+ });
709
+
710
+ await user.click(trigger);
711
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
712
+
713
+ vi.useRealTimers();
714
+ });
715
+
716
+ it('leaves focus on the trigger for a hover-open, and moves it in on click', async () => {
717
+ vi.useFakeTimers({shouldAdvanceTime: true});
718
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
719
+ render(<TopNavHeading heading="My App" menu={menuItems} />);
720
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
721
+
722
+ await user.hover(trigger);
723
+ act(() => {
724
+ vi.advanceTimersByTime(50);
725
+ });
726
+ const firstItem = screen.getAllByRole('menuitem', {hidden: true})[0];
727
+ expect(firstItem).not.toHaveFocus();
728
+
729
+ await user.click(trigger);
730
+ expect(firstItem).toHaveFocus();
731
+
732
+ vi.useRealTimers();
733
+ });
734
+
735
+ it('returns focus to the trigger on Escape', async () => {
736
+ const user = userEvent.setup();
737
+ render(<TopNavHeading heading="My App" menu={menuItems} />);
738
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
739
+
740
+ await user.click(trigger);
741
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
742
+
743
+ await user.keyboard('{Escape}');
744
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
745
+ expect(trigger).toHaveFocus();
746
+ });
747
+ });
@@ -335,23 +335,27 @@ export function TopNavHeading({
335
335
  hasCloseButton: false,
336
336
  });
337
337
 
338
- const closeMenuCtx = useMemo(
339
- () => ({closeMenu: popover.hide}),
340
- [popover.hide],
341
- );
338
+ const {
339
+ triggerProps,
340
+ contentProps,
341
+ menuRef,
342
+ setTriggerEl,
343
+ close: closeMenu,
344
+ } = useMenuHover<HTMLDivElement>({
345
+ show: popover.show,
346
+ hide: popover.hide,
347
+ isOpen: popover.isOpen,
348
+ isEnabled: !!menu,
349
+ showDelay: 0,
350
+ });
342
351
 
343
- const {triggerProps, contentProps, menuRef, setTriggerEl} =
344
- useMenuHover<HTMLDivElement>({
345
- show: popover.show,
346
- hide: popover.hide,
347
- isOpen: popover.isOpen,
348
- isEnabled: !!menu,
349
- showDelay: 0,
350
- });
352
+ const closeMenuCtx = useMemo(() => ({closeMenu}), [closeMenu]);
351
353
 
354
+ // setTriggerEl belongs on the chevron button, not this root: it is the
355
+ // focus-restore target and a <div> cannot take focus. triggerRef stays here
356
+ // because the panel anchors to the whole heading.
352
357
  const setRef = mergeRefs<HTMLElement>(
353
358
  rootRef,
354
- setTriggerEl,
355
359
  ref,
356
360
  menu ? popover.triggerRef : undefined,
357
361
  );
@@ -426,7 +430,8 @@ export function TopNavHeading({
426
430
  <button
427
431
  type="button"
428
432
  {...stylex.props(styles.popoverHeading)}
429
- onClick={triggerProps.onClick}>
433
+ // A close affordance, not the trigger: dismiss only.
434
+ onClick={closeMenu}>
430
435
  {logo && <span {...stylex.props(styles.logo)}>{logo}</span>}
431
436
  {renderTextContent(
432
437
  <Icon
@@ -505,6 +510,7 @@ export function TopNavHeading({
505
510
  // Stays a <button>: this box is the popover trigger, so it carries
506
511
  // the accessible name and handlers — only the glyph moves to Icon.
507
512
  <button
513
+ ref={setTriggerEl}
508
514
  type="button"
509
515
  aria-label={t('@astryx.topNav.heading.openMenu')}
510
516
  onClick={e => {
@@ -582,6 +588,7 @@ export function TopNavHeading({
582
588
  // carries the accessible name and handlers — only the glyph
583
589
  // moves to Icon.
584
590
  <button
591
+ ref={setTriggerEl}
585
592
  type="button"
586
593
  aria-label={t('@astryx.topNav.heading.openMenu')}
587
594
  onClick={e => {