@astryxdesign/core 0.4.1 → 0.4.2-canary.01592f7

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 (217) 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/Chat/ChatTokenizedText.js +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  16. package/dist/CheckboxInput/CheckboxInput.js +5 -0
  17. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
  18. package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
  19. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  20. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  21. package/dist/ComplexSelector/ComplexSelector.js +96 -35
  22. package/dist/ComplexSelector/index.d.ts +2 -2
  23. package/dist/ComplexSelector/index.d.ts.map +1 -1
  24. package/dist/ComplexSelector/index.js +1 -1
  25. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  26. package/dist/DateTimeInput/DateTimeInput.js +2 -2
  27. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
  29. package/dist/HoverCard/HoverCard.d.ts +1 -1
  30. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  31. package/dist/HoverCard/HoverCard.js +5 -13
  32. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  33. package/dist/HoverCard/useHoverCard.js +6 -5
  34. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  35. package/dist/InputGroup/groupStyles.js +7 -2
  36. package/dist/Layer/layerHost.d.ts +24 -0
  37. package/dist/Layer/layerHost.d.ts.map +1 -0
  38. package/dist/Layer/layerHost.js +79 -0
  39. package/dist/Layer/useLayer.d.ts +14 -6
  40. package/dist/Layer/useLayer.d.ts.map +1 -1
  41. package/dist/Layer/useLayer.js +158 -30
  42. package/dist/Markdown/parser.d.ts.map +1 -1
  43. package/dist/Markdown/parser.js +55 -12
  44. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  45. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  46. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  47. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  48. package/dist/RadioList/RadioListItem.js +5 -0
  49. package/dist/SideNav/SideNav.d.ts +7 -9
  50. package/dist/SideNav/SideNav.d.ts.map +1 -1
  51. package/dist/SideNav/SideNav.js +32 -5
  52. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  53. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  54. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  55. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  56. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  57. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  58. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  59. package/dist/SideNav/SideNavHeading.js +89 -32
  60. package/dist/SideNav/SideNavItem.d.ts +7 -2
  61. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  62. package/dist/SideNav/SideNavItem.js +119 -75
  63. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  64. package/dist/SideNav/SideNavSection.js +7 -16
  65. package/dist/SideNav/index.d.ts +1 -1
  66. package/dist/SideNav/index.d.ts.map +1 -1
  67. package/dist/Slider/Slider.d.ts.map +1 -1
  68. package/dist/Slider/Slider.js +56 -15
  69. package/dist/Switch/Switch.d.ts.map +1 -1
  70. package/dist/Switch/Switch.js +5 -0
  71. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  72. package/dist/Thumbnail/Thumbnail.js +5 -0
  73. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  74. package/dist/TopNav/TopNavHeading.js +14 -6
  75. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  76. package/dist/TopNav/TopNavMegaMenu.js +25 -93
  77. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  78. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  79. package/dist/TopNav/TopNavMenu.js +10 -5
  80. package/dist/astryx.css +40 -8
  81. package/dist/astryx.umd.js +50 -50
  82. package/dist/astryx.umd.js.map +4 -4
  83. package/dist/hooks/containerReveal.stylex.d.ts +71 -4
  84. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
  85. package/dist/hooks/containerReveal.stylex.js +57 -5
  86. package/dist/hooks/index.d.ts +2 -2
  87. package/dist/hooks/index.d.ts.map +1 -1
  88. package/dist/hooks/index.js +1 -1
  89. package/dist/hooks/useContainerReveal.d.ts +60 -3
  90. package/dist/hooks/useContainerReveal.d.ts.map +1 -1
  91. package/dist/hooks/useContainerReveal.js +27 -5
  92. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  93. package/dist/hooks/useFocusTrap.js +16 -2
  94. package/dist/hooks/useMenuHover.d.ts +50 -5
  95. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  96. package/dist/hooks/useMenuHover.js +171 -51
  97. package/dist/theme/defineTheme.d.ts +16 -5
  98. package/dist/theme/defineTheme.d.ts.map +1 -1
  99. package/dist/theme/defineTheme.js +44 -48
  100. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  101. package/dist/theme/derivedVarRegistry.js +7 -0
  102. package/dist/theme/expandColorScale.d.ts.map +1 -1
  103. package/dist/theme/expandColorScale.js +1 -0
  104. package/dist/theme/expandMotionScale.d.ts +1 -0
  105. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  106. package/dist/theme/expandMotionScale.js +1 -0
  107. package/dist/theme/expandRadiusScale.d.ts +1 -0
  108. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  109. package/dist/theme/expandRadiusScale.js +1 -0
  110. package/dist/theme/expandTypeScale.d.ts +1 -0
  111. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  112. package/dist/theme/expandTypeScale.js +1 -0
  113. package/dist/theme/mergeComponents.d.ts +20 -0
  114. package/dist/theme/mergeComponents.d.ts.map +1 -0
  115. package/dist/theme/mergeComponents.js +56 -0
  116. package/dist/theme/onMediaTokens.d.ts +6 -1
  117. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  118. package/dist/theme/onMediaTokens.js +11 -3
  119. package/dist/theme/tokens.stylex.d.ts +3 -1
  120. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  121. package/dist/theme/tokens.stylex.js +3 -1
  122. package/locales/en.json +84 -0
  123. package/locales/pseudo.json +63 -0
  124. package/package.json +3 -3
  125. package/src/Avatar/Avatar.doc.mjs +5 -2
  126. package/src/Avatar/Avatar.test.tsx +51 -0
  127. package/src/Avatar/Avatar.tsx +30 -20
  128. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  129. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  130. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  131. package/src/Button/Button.tsx +5 -3
  132. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  133. package/src/Card/Card.doc.mjs +2 -0
  134. package/src/Chat/Chat.doc.mjs +4 -1
  135. package/src/Chat/ChatMessage.doc.mjs +3 -3
  136. package/src/Chat/ChatMessage.tsx +7 -0
  137. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  138. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  139. package/src/Chat/ChatMessageBubble.tsx +25 -0
  140. package/src/Chat/ChatTokenizedText.tsx +1 -1
  141. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  142. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  143. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  144. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  145. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  146. package/src/ComplexSelector/ComplexSelector.tsx +153 -32
  147. package/src/ComplexSelector/index.ts +3 -1
  148. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  149. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  150. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  151. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  152. package/src/HoverCard/HoverCard.test.tsx +283 -55
  153. package/src/HoverCard/HoverCard.tsx +5 -13
  154. package/src/HoverCard/useHoverCard.tsx +8 -11
  155. package/src/InputGroup/groupStyles.ts +7 -8
  156. package/src/Item/Item.doc.mjs +4 -0
  157. package/src/Layer/layerHost.test.ts +99 -0
  158. package/src/Layer/layerHost.ts +141 -0
  159. package/src/Layer/useLayer.doc.mjs +14 -4
  160. package/src/Layer/useLayer.test.tsx +332 -7
  161. package/src/Layer/useLayer.tsx +235 -36
  162. package/src/Markdown/parser.test.ts +53 -0
  163. package/src/Markdown/parser.ts +53 -12
  164. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  165. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  166. package/src/RadioList/RadioListItem.tsx +20 -0
  167. package/src/SideNav/SideNav.doc.mjs +13 -4
  168. package/src/SideNav/SideNav.test.tsx +858 -2
  169. package/src/SideNav/SideNav.tsx +37 -16
  170. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  171. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  172. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  173. package/src/SideNav/SideNavHeading.tsx +53 -25
  174. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  175. package/src/SideNav/SideNavItem.tsx +97 -84
  176. package/src/SideNav/SideNavSection.tsx +6 -20
  177. package/src/SideNav/index.ts +2 -0
  178. package/src/Slider/Slider.test.tsx +69 -33
  179. package/src/Slider/Slider.tsx +65 -13
  180. package/src/Switch/Switch.tsx +20 -0
  181. package/src/TabList/TabList.doc.mjs +3 -0
  182. package/src/Text/Text.doc.mjs +1 -1
  183. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  184. package/src/Thumbnail/Thumbnail.tsx +10 -0
  185. package/src/Timestamp/Timestamp.test.tsx +40 -22
  186. package/src/Toolbar/Toolbar.test.tsx +14 -14
  187. package/src/TopNav/TopNav.test.tsx +86 -1
  188. package/src/TopNav/TopNavHeading.tsx +21 -14
  189. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  190. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  191. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  192. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  193. package/src/TopNav/TopNavMenu.tsx +14 -4
  194. package/src/TreeList/TreeList.doc.mjs +1 -0
  195. package/src/hooks/containerReveal.stylex.ts +140 -9
  196. package/src/hooks/index.ts +6 -1
  197. package/src/hooks/useContainerReveal.doc.mjs +11 -5
  198. package/src/hooks/useContainerReveal.test.tsx +70 -3
  199. package/src/hooks/useContainerReveal.ts +86 -7
  200. package/src/hooks/useFocusTrap.test.tsx +16 -0
  201. package/src/hooks/useFocusTrap.ts +20 -2
  202. package/src/hooks/useMenuHover.test.tsx +364 -0
  203. package/src/hooks/useMenuHover.ts +243 -47
  204. package/src/theme/defineTheme.test.ts +127 -0
  205. package/src/theme/defineTheme.ts +57 -51
  206. package/src/theme/derivedVarRegistry.test.ts +78 -12
  207. package/src/theme/derivedVarRegistry.ts +4 -0
  208. package/src/theme/expandColorScale.ts +1 -0
  209. package/src/theme/expandMotionScale.ts +1 -0
  210. package/src/theme/expandRadiusScale.ts +1 -0
  211. package/src/theme/expandTypeScale.ts +1 -0
  212. package/src/theme/mergeComponents.ts +59 -0
  213. package/src/theme/onMediaTokens.ts +9 -2
  214. package/src/theme/themingTargets.test.ts +152 -26
  215. package/src/theme/tokens.stylex.ts +3 -1
  216. package/src/theme/tokens.test.ts +12 -0
  217. package/src/theme/useTheme.test.tsx +18 -0
@@ -8,7 +8,7 @@
8
8
 
9
9
  import {describe, it, expect, vi, beforeEach} from 'vitest';
10
10
  import {useState} from 'react';
11
- import {render, screen, waitFor, fireEvent} from '@testing-library/react';
11
+ import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
12
12
  import userEvent from '@testing-library/user-event';
13
13
  import {DropdownMenu} from './DropdownMenu';
14
14
  import {DropdownMenuItem} from './DropdownMenuItem';
@@ -708,3 +708,74 @@ describe('DropdownMenuSubMenu theming slots', () => {
708
708
  ).toBeInTheDocument();
709
709
  });
710
710
  });
711
+
712
+ describe('DropdownMenuSubMenu hover/click guard', () => {
713
+ it('keeps the flyout open when a hover-open is immediately clicked', async () => {
714
+ vi.useFakeTimers({shouldAdvanceTime: true});
715
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
716
+ render(<MoveMenu />);
717
+
718
+ await user.click(screen.getByRole('button', {name: /Actions/}));
719
+ const trigger = screen.getByRole('menuitem', {
720
+ name: /Move to/,
721
+ hidden: true,
722
+ });
723
+
724
+ await user.hover(trigger);
725
+ act(() => {
726
+ vi.advanceTimersByTime(300);
727
+ });
728
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
729
+
730
+ await user.click(trigger);
731
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
732
+ expect(
733
+ screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
734
+ ).toHaveFocus();
735
+
736
+ vi.useRealTimers();
737
+ });
738
+
739
+ it('closes on a click that lands well after the hover-open', async () => {
740
+ vi.useFakeTimers({shouldAdvanceTime: true});
741
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
742
+ render(<MoveMenu />);
743
+
744
+ await user.click(screen.getByRole('button', {name: /Actions/}));
745
+ const trigger = screen.getByRole('menuitem', {
746
+ name: /Move to/,
747
+ hidden: true,
748
+ });
749
+
750
+ await user.hover(trigger);
751
+ act(() => {
752
+ vi.advanceTimersByTime(300);
753
+ });
754
+ act(() => {
755
+ vi.advanceTimersByTime(1200);
756
+ });
757
+
758
+ await user.click(trigger);
759
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
760
+
761
+ vi.useRealTimers();
762
+ });
763
+
764
+ it('moves focus into the flyout synchronously on a click-open', async () => {
765
+ const user = userEvent.setup();
766
+ render(<MoveMenu />);
767
+
768
+ await user.click(screen.getByRole('button', {name: /Actions/}));
769
+ const trigger = screen.getByRole('menuitem', {
770
+ name: /Move to/,
771
+ hidden: true,
772
+ });
773
+
774
+ await user.click(trigger);
775
+
776
+ // No waitFor: a deferred (rAF) focus would fail here.
777
+ expect(
778
+ screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
779
+ ).toHaveFocus();
780
+ });
781
+ });
@@ -299,12 +299,16 @@ export function DropdownMenuSubMenu(
299
299
 
300
300
  // Hover-intent: entering the trigger opens after a short delay; leaving
301
301
  // either surface closes after a delay. Hover-open does not steal focus.
302
- const {triggerProps, contentProps} = useMenuHover<HTMLDivElement>({
303
- show: showLayer,
304
- hide: hideLayer,
305
- isOpen,
306
- isEnabled: canOpen,
307
- });
302
+ // Hover intent and the shared hover→click guard only: this level owns its own
303
+ // click handling, roving focus and typeahead. popover="manual", so the
304
+ // invoker wiring other consumers need does not apply.
305
+ const {triggerProps, contentProps, confirmHoverOpen} =
306
+ useMenuHover<HTMLDivElement>({
307
+ show: showLayer,
308
+ hide: hideLayer,
309
+ isOpen,
310
+ isEnabled: canOpen,
311
+ });
308
312
 
309
313
  const open = useCallback(
310
314
  (options?: {focusFirst?: boolean}) => {
@@ -313,18 +317,12 @@ export function DropdownMenuSubMenu(
313
317
  }
314
318
  layer.show();
315
319
  if (options?.focusFirst) {
316
- requestAnimationFrame(() => {
317
- // Move focus into the flyout. When it has no focusable items yet
318
- // (e.g. an async submenu showing only a disabled "Loading…" row via
319
- // hasSpinner), focusFirst() finds nothing — fall back to focusing the
320
- // flyout container itself so keyboard ownership still transfers off
321
- // the parent list. Otherwise the parent would keep focus, letting
322
- // arrow keys rove the parent while the empty flyout stays open.
323
- const focusedItem = focusFirst();
324
- if (!focusedItem) {
325
- menuRef.current?.focus();
326
- }
327
- });
320
+ // Synchronous by design — see the focus note in useMenuHover. A
321
+ // still-loading flyout has no focusable item, so fall back to the
322
+ // container: keyboard ownership must leave the parent list either way.
323
+ if (!focusFirst()) {
324
+ menuRef.current?.focus();
325
+ }
328
326
  }
329
327
  },
330
328
  [canOpen, layer, focusFirst, menuRef],
@@ -354,13 +352,19 @@ export function DropdownMenuSubMenu(
354
352
  if (isDisabled) {
355
353
  return;
356
354
  }
357
- // Click toggles the flyout, moving focus into it on open.
355
+ // Toggles, except for the click that follows a hover-open (#3121).
358
356
  if (isOpen) {
357
+ if (confirmHoverOpen()) {
358
+ if (!focusFirst()) {
359
+ menuRef.current?.focus();
360
+ }
361
+ return;
362
+ }
359
363
  close({focusTrigger: true});
360
364
  } else {
361
365
  open({focusFirst: true});
362
366
  }
363
- }, [isDisabled, isOpen, open, close]);
367
+ }, [isDisabled, isOpen, open, close, confirmHoverOpen, focusFirst, menuRef]);
364
368
 
365
369
  const handleTriggerKeyDown = useCallback(
366
370
  (e: React.KeyboardEvent) => {
@@ -114,7 +114,7 @@ export const docs = {
114
114
  { guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
115
115
  { guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
116
116
  { guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
117
- { guidance: false, description: 'Nest a HoverCard whose content has block elements directly inside phrasing-only contexts such as a <p>, <label>, or heading. The card renders inline, so block content there is invalid HTML the browser reparents. Wrap the surrounding text in a block element (e.g. a <div>) instead.' },
117
+ { guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
118
118
  ],
119
119
  anatomy: [
120
120
  {name: 'Trigger', required: true, description: 'The element that opens the hover card on hover or focus: a button, link, or inline text.'},
@@ -229,7 +229,7 @@ export const docsZh = {
229
229
  { guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
230
230
  { guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
231
231
  { guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
232
- { guidance: false, description: 'Nest a HoverCard whose content has block elements directly inside phrasing-only contexts such as a <p>, <label>, or heading. The card renders inline, so block content there is invalid HTML the browser reparents. Wrap the surrounding text in a block element (e.g. a <div>) instead.' },
232
+ { guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
233
233
  ],
234
234
  },
235
235
  };
@@ -247,7 +247,7 @@ export const docsDense = {
247
247
  { guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
248
248
  { guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
249
249
  { guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
250
- { guidance: false, description: 'Nest a block-content HoverCard directly inside phrasing-only contexts (<p>, <label>, heading); it renders inline so block content is invalid HTML there. Wrap surrounding text in a block element instead.' },
250
+ { guidance: true, description: 'Prefer a block context over a direct <p>, heading, or link placement. Those placements work when necessary through a corrective portal, but DOM/tab order may differ.' },
251
251
  ],
252
252
  },
253
253
  components: [
@@ -9,11 +9,21 @@
9
9
  * SYNC: When HoverCard.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect, vi, beforeAll, afterAll} from 'vitest';
12
+ import {
13
+ describe,
14
+ it,
15
+ expect,
16
+ vi,
17
+ beforeAll,
18
+ beforeEach,
19
+ afterAll,
20
+ } from 'vitest';
13
21
  import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
14
22
  import {renderToString} from 'react-dom/server';
15
23
  import {hydrateRoot} from 'react-dom/client';
16
24
  import {StrictMode} from 'react';
25
+ import {Button} from '../Button/Button';
26
+ import {Theme, defineTheme} from '../theme';
17
27
  import {HoverCard} from './HoverCard';
18
28
 
19
29
  // Store original matches to restore later
@@ -48,6 +58,11 @@ afterAll(() => {
48
58
  (HTMLElement.prototype as any).matches = originalMatches;
49
59
  });
50
60
 
61
+ beforeEach(() => {
62
+ vi.mocked(HTMLElement.prototype.showPopover).mockClear();
63
+ vi.mocked(HTMLElement.prototype.hidePopover).mockClear();
64
+ });
65
+
51
66
  describe('HoverCard', () => {
52
67
  it('renders trigger element', () => {
53
68
  render(
@@ -58,31 +73,40 @@ describe('HoverCard', () => {
58
73
  expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
59
74
  });
60
75
 
61
- it('exposes the floating layer as role="group" when no label is provided', () => {
76
+ it('exposes the floating layer as role="group" when no label is provided', async () => {
62
77
  render(
63
- <HoverCard content={<span>Card content</span>}>
78
+ <HoverCard content={<span>Card content</span>} delay={0}>
64
79
  <button type="button">Trigger</button>
65
80
  </HoverCard>,
66
81
  );
67
82
  // A group may validly be unnamed; an unnamed dialog may not. Without a
68
83
  // label the layer must not claim the dialog role.
69
- expect(screen.getByRole('group', {hidden: true})).toHaveTextContent(
70
- 'Card content',
71
- );
84
+ expect(screen.queryByRole('group', {hidden: true})).toBeNull();
72
85
  expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
86
+
87
+ fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
88
+
89
+ await waitFor(() => {
90
+ expect(screen.getByRole('group', {hidden: true})).toHaveTextContent(
91
+ 'Card content',
92
+ );
93
+ });
73
94
  });
74
95
 
75
- it('exposes the floating layer as a named dialog when label is provided', () => {
96
+ it('exposes the floating layer as a named dialog when label is provided', async () => {
76
97
  render(
77
- <HoverCard content={<span>Card content</span>} label="Profile preview">
98
+ <HoverCard
99
+ content={<span>Card content</span>}
100
+ label="Profile preview"
101
+ delay={0}>
78
102
  <button type="button">Trigger</button>
79
103
  </HoverCard>,
80
104
  );
81
- // The layer is hidden while closed, so assert the accessible name via the
82
- // aria-label attribute on the role-carrying element (same pattern as the
83
- // Popover dialogLabel test) — accname computation returns '' for hidden
84
- // elements.
85
- const dialog = screen.getByRole('dialog', {hidden: true});
105
+ expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
106
+
107
+ fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
108
+
109
+ const dialog = await screen.findByRole('dialog', {hidden: true});
86
110
  expect(dialog).toHaveAttribute('aria-label', 'Profile preview');
87
111
  expect(dialog).toHaveTextContent('Card content');
88
112
  expect(screen.queryByRole('group', {hidden: true})).toBeNull();
@@ -104,52 +128,236 @@ describe('HoverCard', () => {
104
128
 
105
129
  expect(trigger.parentElement?.tagName).toBe('SPAN');
106
130
  expect(paragraph?.querySelector('div')).toBeNull();
131
+ expect(paragraph?.querySelector('template')).not.toBeNull();
107
132
  });
108
133
 
109
- it('renders the floating layer with inline-safe markup (no block elements in a paragraph)', () => {
110
- // HoverCard renders its floating layer inline (no portal), so the layer
111
- // must be phrasing content to stay valid — and stay put on hydration —
112
- // inside a <p>. Assert the layer popover element is a <span> and that the
113
- // paragraph contains no <div> descendants at all.
134
+ it('portals block content before showing and restores the marker after hiding', async () => {
135
+ let contentWasPresentAtShow = false;
136
+ vi.mocked(HTMLElement.prototype.showPopover).mockImplementationOnce(
137
+ function (this: HTMLElement) {
138
+ contentWasPresentAtShow =
139
+ this.textContent?.includes('Block card content') ?? false;
140
+ popoverOpenState.set(this, true);
141
+ },
142
+ );
143
+ const consoleErrorSpy = vi
144
+ .spyOn(console, 'error')
145
+ .mockImplementation(() => {});
146
+
114
147
  const {container} = render(
115
148
  <p>
116
149
  Before{' '}
117
- <HoverCard content={<span>Card content</span>}>
118
- <a href="#trigger">Trigger</a>
150
+ <HoverCard
151
+ content={<div>Block card content</div>}
152
+ delay={0}
153
+ hideDelay={0}>
154
+ Trigger
119
155
  </HoverCard>{' '}
120
156
  after
121
157
  </p>,
122
158
  );
123
159
 
124
160
  const paragraph = container.querySelector('p');
125
- const layer = screen.getByText('Card content').closest('[popover]');
161
+ const trigger = screen.getByText('Trigger');
126
162
 
127
- expect(layer).not.toBeNull();
128
- expect(layer?.tagName).toBe('SPAN');
129
- // The whole layer subtree lives inside the paragraph with no block boxes.
130
- expect(paragraph?.contains(layer as Node)).toBe(true);
163
+ expect(screen.queryByText('Block card content')).toBeNull();
164
+ expect(paragraph?.querySelector('template')).not.toBeNull();
131
165
  expect(paragraph?.querySelector('div')).toBeNull();
166
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
167
+
168
+ fireEvent.mouseEnter(trigger);
169
+
170
+ await waitFor(() => {
171
+ const content = screen.getByText('Block card content');
172
+ const layer = content.closest('[popover]');
173
+
174
+ expect(layer?.tagName).toBe('DIV');
175
+ expect(layer?.parentElement).toBe(container);
176
+ expect(paragraph?.contains(content)).toBe(false);
177
+ expect(contentWasPresentAtShow).toBe(true);
178
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
179
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
180
+ });
181
+
182
+ fireEvent.mouseLeave(trigger);
183
+
184
+ await waitFor(() => {
185
+ expect(screen.queryByText('Block card content')).toBeNull();
186
+ expect(paragraph?.querySelector('template')).not.toBeNull();
187
+ });
188
+
189
+ consoleErrorSpy.mockRestore();
132
190
  });
133
191
 
134
- it('does not show content initially', () => {
192
+ it('hosts the floating layer outside a wrapping link', async () => {
193
+ // Interactive ancestors capture the layer's own interactions: a card left
194
+ // inside an <a> puts its links and buttons inside that link, so clicking
195
+ // one navigates.
135
196
  render(
197
+ <a href="#profile">
198
+ <HoverCard
199
+ content={
200
+ <span>
201
+ <a href="#inner">Inner link</a>
202
+ </span>
203
+ }
204
+ delay={0}>
205
+ Trigger
206
+ </HoverCard>
207
+ </a>,
208
+ );
209
+
210
+ const link = screen.getByRole('link', {name: /Trigger/});
211
+ expect(screen.queryByText('Inner link')).toBeNull();
212
+ fireEvent.mouseEnter(screen.getByText('Trigger'));
213
+
214
+ const layer = (await screen.findByText('Inner link')).closest('[popover]');
215
+
216
+ expect(layer).not.toBeNull();
217
+ expect(link.contains(layer as Node)).toBe(false);
218
+ });
219
+
220
+ it('keeps a safe layer inline at its JSX position', async () => {
221
+ const {container} = render(
222
+ <>
223
+ <HoverCard content={<div>Block card content</div>} delay={0}>
224
+ <button type="button">Trigger</button>
225
+ </HoverCard>
226
+ <button type="button">Following control</button>
227
+ </>,
228
+ );
229
+
230
+ expect(screen.queryByText('Block card content')).toBeNull();
231
+ fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
232
+
233
+ await waitFor(() => {
234
+ const layer = screen.getByText('Block card content').closest('[popover]');
235
+ const following = screen.getByRole('button', {name: 'Following control'});
236
+
237
+ expect(layer?.parentElement).toBe(container);
238
+ expect(layer?.nextElementSibling).toBe(following);
239
+ });
240
+ });
241
+
242
+ it('does not render content initially', () => {
243
+ const {container} = render(
136
244
  <HoverCard content={<span>Card content</span>}>
137
245
  <button type="button">Trigger</button>
138
246
  </HoverCard>,
139
247
  );
140
- // Content is in DOM (popover not open but element exists)
141
- const content = screen.queryByText('Card content');
142
- expect(content).toBeInTheDocument();
248
+ expect(screen.queryByText('Card content')).toBeNull();
249
+ expect(container.querySelector('[popover]')).toBeNull();
250
+ expect(container.querySelector('template')).not.toBeNull();
251
+ });
252
+
253
+ it('keeps a paragraph portal inside the nearest nested theme scope', async () => {
254
+ const outerTheme = defineTheme({name: 'hovercard-outer-test'});
255
+ const innerTheme = defineTheme({
256
+ name: 'hovercard-inner-test',
257
+ components: {
258
+ hovercard: {base: {borderWidth: '7px'}},
259
+ button: {base: {fontWeight: '700'}},
260
+ },
261
+ });
262
+
263
+ const {container} = render(
264
+ <Theme theme={outerTheme}>
265
+ <Theme theme={innerTheme}>
266
+ <p>
267
+ <HoverCard
268
+ content={<Button label="View profile">View profile</Button>}
269
+ delay={0}>
270
+ Trigger
271
+ </HoverCard>
272
+ </p>
273
+ </Theme>
274
+ </Theme>,
275
+ );
276
+
277
+ fireEvent.mouseEnter(screen.getByText('Trigger'));
278
+
279
+ await waitFor(() => {
280
+ const button = screen.getByText('View profile').closest('button');
281
+ const layer = button?.closest('[popover]') ?? null;
282
+ const innerThemeScope = container.querySelector(
283
+ '[data-astryx-theme="hovercard-inner-test"]',
284
+ );
285
+
286
+ expect(innerThemeScope).not.toBeNull();
287
+ expect(button).not.toBeNull();
288
+ expect(layer?.parentElement).toBe(innerThemeScope);
289
+ expect(innerThemeScope?.contains(layer)).toBe(true);
290
+ expect(container.querySelector('p')?.contains(layer)).toBe(false);
291
+ });
292
+ });
293
+
294
+ it('does not freeze computed CSS variables on a paragraph portal', async () => {
295
+ const variables = new Map([
296
+ ['--color-neutral', 'rgb(1, 2, 3)'],
297
+ ['--color-text-primary', 'rgb(250, 251, 252)'],
298
+ ]);
299
+ const getComputedStyleSpy = vi
300
+ .spyOn(window, 'getComputedStyle')
301
+ .mockImplementation(
302
+ element =>
303
+ ({
304
+ length: variables.size,
305
+ item: (index: number) => Array.from(variables.keys())[index] ?? '',
306
+ getPropertyValue: (property: string) =>
307
+ variables.get(property) ?? '',
308
+ direction:
309
+ element.tagName.toLowerCase() === 'template' ? 'rtl' : 'ltr',
310
+ writingMode:
311
+ element.tagName.toLowerCase() === 'template'
312
+ ? 'vertical-rl'
313
+ : 'horizontal-tb',
314
+ }) as CSSStyleDeclaration,
315
+ );
316
+
317
+ const {container} = render(
318
+ <p>
319
+ <HoverCard
320
+ content={<Button label="View profile">View profile</Button>}
321
+ delay={0}>
322
+ Trigger
323
+ </HoverCard>
324
+ </p>,
325
+ );
326
+
327
+ expect(screen.queryByRole('button', {name: 'View profile'})).toBeNull();
328
+ fireEvent.mouseEnter(screen.getByText('Trigger'));
329
+
330
+ await waitFor(() => {
331
+ const button = screen.getByRole('button', {name: 'View profile'});
332
+ const layer = button.closest('[popover]');
333
+
334
+ expect(layer?.parentElement).toBe(container);
335
+ expect(container.querySelector('p')?.contains(button)).toBe(false);
336
+ expect(layer).not.toBeNull();
337
+ expect(
338
+ (layer as HTMLElement).style.getPropertyValue('--color-neutral'),
339
+ ).toBe('');
340
+ expect(
341
+ (layer as HTMLElement).style.getPropertyValue('--color-text-primary'),
342
+ ).toBe('');
343
+ expect((layer as HTMLElement).style.direction).toBe('rtl');
344
+ expect((layer as HTMLElement).style.writingMode).toBe('vertical-rl');
345
+ });
346
+
347
+ getComputedStyleSpy.mockRestore();
143
348
  });
144
349
 
145
- it('applies the theme body font to the floating layer', () => {
350
+ it('applies the theme body font to the floating layer', async () => {
146
351
  render(
147
- <HoverCard content={<span>Card content</span>}>
352
+ <HoverCard content={<span>Card content</span>} delay={0}>
148
353
  <button type="button">Trigger</button>
149
354
  </HoverCard>,
150
355
  );
151
356
 
152
- const layer = screen.getByText('Card content').closest('[popover]');
357
+ fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
358
+ const layer = (await screen.findByText('Card content')).closest(
359
+ '[popover]',
360
+ );
153
361
  expect(layer).not.toBeNull();
154
362
  expect(getComputedStyle(layer as Element).fontFamily).toBe(
155
363
  'var(--font-family-body)',
@@ -435,41 +643,57 @@ describe('HoverCard', () => {
435
643
  // `typeof document !== 'undefined'` gate: the server rendered nothing while
436
644
  // the first client render emitted the portal, so the two trees disagreed.
437
645
  //
438
- // The layer is now rendered inline as inline-safe phrasing markup (a
439
- // `<span popover>`), identically on the server and the client, so there is
440
- // nothing for hydration to mismatch.
646
+ // Context layers now render the same inert <template> marker on the server
647
+ // and the first client render. Arbitrary content mounts only when the card
648
+ // is requested and its final inline/portal position is known.
441
649
 
442
- it('renders the floating layer in server markup (no document gate)', () => {
443
- const html = renderToString(
650
+ it('renders only the inert marker before and after hydration', async () => {
651
+ const tree = (
444
652
  <HoverCard content={<span>Card content</span>}>
445
653
  <button type="button">Trigger</button>
446
- </HoverCard>,
654
+ </HoverCard>
447
655
  );
448
656
 
449
- // The popover element is present in the server output...
450
- expect(html).toContain('popover="manual"');
451
- expect(html).toContain('Card content');
452
- // ...and it is a <span> (inline-safe), not a <div>.
453
- expect(html).toMatch(/<span[^>]*popover="manual"/);
657
+ const serverHTML = renderToString(tree);
658
+ expect(serverHTML).not.toContain('popover=');
659
+ expect(serverHTML).not.toContain('Card content');
660
+ expect(serverHTML).toContain('<template');
661
+
662
+ const container = document.createElement('div');
663
+ container.innerHTML = serverHTML;
664
+ document.body.appendChild(container);
665
+
666
+ let root: ReturnType<typeof hydrateRoot>;
667
+ await act(async () => {
668
+ root = hydrateRoot(container, tree);
669
+ });
670
+
671
+ expect(container.querySelector('[popover]')).toBeNull();
672
+ expect(container.querySelector('template')).not.toBeNull();
673
+
674
+ await act(async () => {
675
+ root.unmount();
676
+ });
677
+ container.remove();
454
678
  });
455
679
 
456
- it('keeps the floating layer inline-safe in server markup inside a paragraph', () => {
680
+ it('emits only a valid marker inside a paragraph', () => {
457
681
  const html = renderToString(
458
682
  <p>
459
683
  Before{' '}
460
- <HoverCard content={<span>Card content</span>}>
684
+ <HoverCard content={<div>Block card content</div>}>
461
685
  <a href="#trigger">Trigger</a>
462
686
  </HoverCard>{' '}
463
687
  after
464
688
  </p>,
465
689
  );
466
690
 
467
- // No <div> is emitted inside the paragraph — the layer and its wrappers
468
- // are all phrasing content, so the server string is valid <p> markup that
469
- // the browser parser will not reparent (which would itself desync
470
- // hydration).
691
+ // <template> is inert phrasing/script-supporting content, so the parser
692
+ // has no block layer or consumer content to reparent out of the <p>.
471
693
  expect(html).not.toContain('<div');
472
- expect(html).toMatch(/<span[^>]*popover="manual"/);
694
+ expect(html).not.toContain('popover=');
695
+ expect(html).not.toContain('Block card content');
696
+ expect(html).toContain('<template');
473
697
  });
474
698
 
475
699
  it('server markup matches the first client render (no hydration mismatch)', async () => {
@@ -477,7 +701,7 @@ describe('HoverCard', () => {
477
701
  <StrictMode>
478
702
  <p>
479
703
  Glossary:{' '}
480
- <HoverCard content={<span>Definition</span>}>
704
+ <HoverCard content={<div>Definition</div>}>
481
705
  <a href="#term">term</a>
482
706
  </HoverCard>
483
707
  .
@@ -515,6 +739,8 @@ describe('HoverCard', () => {
515
739
 
516
740
  expect(hydrationErrors).toEqual([]);
517
741
  expect(recoverableErrors).toEqual([]);
742
+ expect(container.querySelector('[popover]')).toBeNull();
743
+ expect(container.querySelector('template')).not.toBeNull();
518
744
 
519
745
  await act(async () => {
520
746
  root.unmount();
@@ -533,10 +759,10 @@ describe('HoverCard', () => {
533
759
  );
534
760
 
535
761
  const serverHTML = renderToString(tree);
536
- // isDefaultOpen must not leak the open state into SSR markup — the open
537
- // call happens in an effect after hydration, so the server output is the
538
- // same closed markup the first client render produces.
539
- expect(serverHTML).toContain('popover="manual"');
762
+ // isDefaultOpen must not leak the final layer into SSR markup. The marker
763
+ // hydrates first; the effect then requests and opens the real popover.
764
+ expect(serverHTML).not.toContain('popover=');
765
+ expect(serverHTML).toContain('<template');
540
766
 
541
767
  const container = document.createElement('div');
542
768
  container.innerHTML = serverHTML;
@@ -568,6 +794,8 @@ describe('HoverCard', () => {
568
794
  await waitFor(() => {
569
795
  expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
570
796
  });
797
+ expect(container.querySelector('[popover]')).not.toBeNull();
798
+ expect(container).toHaveTextContent('Default open');
571
799
 
572
800
  await act(async () => {
573
801
  root.unmount();