@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
@@ -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();
@@ -6,7 +6,7 @@
6
6
  * @file HoverCard.tsx
7
7
  * @input Uses React, useHoverCard hook
8
8
  * @output Exports HoverCard component for hover/focus triggered layers
9
- * @position Layer component; uses inline-safe trigger wrapper and renders the floating layer inline
9
+ * @position Layer component; inline-safe trigger wrapper, floating layer hosted by useLayer
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
12
12
  * - /packages/core/src/HoverCard/HoverCard.test.tsx
@@ -250,18 +250,10 @@ export function HoverCard({
250
250
  };
251
251
  }, [textOnly, hoverCard.ref, hoverCard.describedBy]);
252
252
 
253
- // Render the floating layer inline, in the same place on the server and the
254
- // client. The layer is a `popover` element opened via the Popover API, so the
255
- // browser promotes it to the top layer when shown — that already escapes
256
- // ancestor clipping, stacking, and transform containing-block traps, and CSS
257
- // anchor positioning resolves the trigger reference regardless of where the
258
- // element sits in the DOM, so no portal is needed to "escape" layout.
259
- //
260
- // The layer renders as inline-safe phrasing markup (a `<span>`, see
261
- // useHoverCard), which stays put inside a `<p>` instead of being reparented
262
- // by the HTML parser. That keeps the server markup and the first client
263
- // render identical, so there is no hydration mismatch — and it preserves the
264
- // inline-safety guarantee (no block elements injected into a paragraph).
253
+ // While closed, useLayer leaves only an inert <template> marker at this JSX
254
+ // position. When the card needs to open, it uses that marker to keep the
255
+ // final layer inline when the parent is safe or portal it outside a <p>,
256
+ // link, or other ancestor that cannot contain it safely.
265
257
  const renderedHoverCard = hoverCard.renderHoverCard(content, {
266
258
  xstyle,
267
259
  className,
@@ -43,11 +43,7 @@ const styles = stylex.create({
43
43
  borderRadius: 'var(--_hovercard-radius)',
44
44
  boxShadow: shadowVars['--shadow-med'],
45
45
  },
46
- // Position-based margin styles
47
- // Content wrapper for padding and mouse events.
48
- // `display: block` keeps the wrapper a block box even though it renders as a
49
- // `span` (the layer uses inline-safe phrasing markup so it is valid inside a
50
- // paragraph and produces identical server/client markup).
46
+ // Content wrapper for padding and interaction events.
51
47
  content: {
52
48
  display: 'block',
53
49
  paddingBlockStart: spacingVars['--spacing-3'],
@@ -254,6 +250,7 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
254
250
 
255
251
  const layer = useLayer({
256
252
  mode: 'context',
253
+ lazyMount: true,
257
254
  onShow,
258
255
  onHide,
259
256
  });
@@ -459,7 +456,7 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
459
456
  // Consumer surface style props land on the layer container — the
460
457
  // themed surface (bg/radius/shadow) where the theme class lives — so
461
458
  // customizing the card targets the same element as the theme. The inner
462
- // span keeps `styles.content` for padding.
459
+ // div keeps `styles.content` for padding.
463
460
  xstyle: [
464
461
  popoverXstyle,
465
462
  layerAnimations[renderPlacement],
@@ -469,13 +466,13 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
469
466
  ? `${themeClassName} ${props.className}`
470
467
  : themeClassName,
471
468
  style: props?.style,
472
- // Render the layer as inline-safe phrasing markup so HoverCard stays
473
- // valid (and hydration-stable) inside inline contexts like a `<p>`.
474
- as: 'span' as const,
469
+ // useLayer mounts only after it has verified or corrected the parent,
470
+ // so rich HoverCard content can use block-safe markup.
471
+ as: 'div' as const,
475
472
  };
476
473
 
477
474
  return layer.render(
478
- <span
475
+ <div
479
476
  {...stylex.props(styles.content)}
480
477
  onMouseEnter={() => {
481
478
  isHoveringContentRef.current = true;
@@ -517,7 +514,7 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
517
514
  scheduleHide();
518
515
  }}>
519
516
  {children}
520
- </span>,
517
+ </div>,
521
518
  renderProps,
522
519
  );
523
520
  },
@@ -10,6 +10,11 @@
10
10
  import * as stylex from '@stylexjs/stylex';
11
11
  import {radiusVars, borderVars} from '../theme/tokens.stylex';
12
12
 
13
+ // A grouped control may be followed by context-layer infrastructure rather
14
+ // than another control. Neither the inert marker nor the popover is a visual
15
+ // group member, so skip both when finding the trailing edge.
16
+ const IS_LAST_ITEM = ':not(:has(~ *:not([popover]):not(template)))';
17
+
13
18
  export const groupStyles = stylex.create({
14
19
  inGroup: {
15
20
  flex: 1,
@@ -29,17 +34,11 @@ export const groupStyles = stylex.create({
29
34
  },
30
35
  borderStartEndRadius: {
31
36
  default: 0,
32
- ':last-child': radiusVars['--radius-element'],
33
- ':has(+ [popover]:last-child)': radiusVars['--radius-element'],
34
- ':has(+ [popover] + [popover]:last-child)':
35
- radiusVars['--radius-element'],
37
+ [IS_LAST_ITEM]: radiusVars['--radius-element'],
36
38
  },
37
39
  borderEndEndRadius: {
38
40
  default: 0,
39
- ':last-child': radiusVars['--radius-element'],
40
- ':has(+ [popover]:last-child)': radiusVars['--radius-element'],
41
- ':has(+ [popover] + [popover]:last-child)':
42
- radiusVars['--radius-element'],
41
+ [IS_LAST_ITEM]: radiusVars['--radius-element'],
43
42
  },
44
43
  ':focus-within': {
45
44
  zIndex: 1,
@@ -19,6 +19,10 @@ export const docs = {
19
19
  targets: [
20
20
  {className: 'astryx-item', visualProps: ['density', 'align']},
21
21
  ],
22
+ vars: [
23
+ {name: '--_item-label-color', description: 'Color of the label line. Unset by default (the label uses the primary text token); a parent sets it to recolor the label it renders, as the destructive dropdown/context menu item does.', default: 'var(--color-text-primary)', private: true},
24
+ {name: '--_item-description-color', description: 'Companion to --_item-label-color for the secondary description line.', default: 'var(--color-text-secondary)', private: true},
25
+ ],
22
26
  },
23
27
  components: [
24
28
  {