@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
@@ -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
  {
@@ -0,0 +1,99 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file layerHost.test.ts
5
+ * @input Tests resolveLayerPortalTarget
6
+ * @output Coverage for when and where a layer is portaled
7
+ * @position Test for /packages/core/src/Layer/layerHost.ts
8
+ */
9
+
10
+ import {describe, it, expect, afterEach} from 'vitest';
11
+ import {resolveLayerPortalTarget} from './layerHost';
12
+
13
+ function mount(html: string): HTMLElement {
14
+ const root = document.createElement('div');
15
+ root.id = 'root';
16
+ root.innerHTML = html;
17
+ document.body.appendChild(root);
18
+ return root;
19
+ }
20
+
21
+ afterEach(() => {
22
+ document.getElementById('root')?.remove();
23
+ });
24
+
25
+ describe('resolveLayerPortalTarget', () => {
26
+ it('returns null without an inline parent', () => {
27
+ expect(resolveLayerPortalTarget(null)).toBeNull();
28
+ });
29
+
30
+ it('returns null when the intended inline position is already safe', () => {
31
+ const root = mount(
32
+ '<div id="host"><template id="marker"></template></div>',
33
+ );
34
+ const target = resolveLayerPortalTarget(root.querySelector('#host'));
35
+
36
+ expect(target).toBeNull();
37
+ });
38
+
39
+ it.each([
40
+ ['paragraph', '<p><template></template></p>'],
41
+ ['heading', '<h2><template></template></h2>'],
42
+ ['link', '<a href="#x"><template></template></a>'],
43
+ ['button', '<button><template></template></button>'],
44
+ ['label', '<label><template></template></label>'],
45
+ ['data', '<data><template></template></data>'],
46
+ ['definition', '<dfn><template></template></dfn>'],
47
+ ['meter', '<meter><template></template></meter>'],
48
+ ['output', '<output><template></template></output>'],
49
+ ['progress', '<progress><template></template></progress>'],
50
+ [
51
+ 'nested inline formatting',
52
+ '<p><em><b><template id="marker"></template></b></em></p>',
53
+ ],
54
+ ])('walks out of a %s', (_name, markup) => {
55
+ const root = mount(`<div id="host">${markup}</div>`);
56
+ const marker = root.querySelector('template');
57
+ const target = resolveLayerPortalTarget(marker?.parentElement ?? null);
58
+
59
+ expect(target).toBe(root.querySelector('#host'));
60
+ });
61
+
62
+ it('walks past a safe wrapper nested inside an unsafe ancestor', () => {
63
+ const root = mount(
64
+ '<div id="host"><a href="#x"><div id="inline"><template></template></div></a></div>',
65
+ );
66
+ const target = resolveLayerPortalTarget(root.querySelector('#inline'));
67
+
68
+ expect(target).toBe(root.querySelector('#host'));
69
+ });
70
+
71
+ it.each([
72
+ [
73
+ 'table row',
74
+ '<table><tbody><tr><template></template></tr></tbody></table>',
75
+ ],
76
+ ['list', '<ul><template></template></ul>'],
77
+ ['select', '<select><template></template></select>'],
78
+ ['heading group', '<hgroup><template></template></hgroup>'],
79
+ ])('walks out of a structural %s', (_name, markup) => {
80
+ const root = mount(`<div id="host">${markup}</div>`);
81
+ const marker = root.querySelector('template');
82
+ const target = resolveLayerPortalTarget(marker?.parentElement ?? null);
83
+
84
+ expect(target).toBe(root.querySelector('#host'));
85
+ });
86
+
87
+ it('stops at the nearest safe ancestor rather than the body', () => {
88
+ const root = mount(
89
+ '<section id="outer"><li id="host"><p><span id="t">t</span></p></li></section>',
90
+ );
91
+ const marker = root.querySelector('#t');
92
+ const target = resolveLayerPortalTarget(marker?.parentElement ?? null);
93
+
94
+ // The nearest safe ancestor keeps the layer inside the trigger's theme
95
+ // scope and next to it in the tab order.
96
+ expect(target).toBe(root.querySelector('#host'));
97
+ expect(target).not.toBe(document.body);
98
+ });
99
+ });
@@ -0,0 +1,141 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file layerHost.ts
5
+ * @input Uses the layer's intended inline parent element
6
+ * @output Exports resolveLayerPortalTarget, the corrective portal target
7
+ * @position Layer utility; used by useLayer to place the popover in the DOM
8
+ *
9
+ * SYNC: When modified, update:
10
+ * - /packages/core/src/Layer/layerHost.test.ts
11
+ */
12
+
13
+ /**
14
+ * Ancestors a layer must not be hosted inside.
15
+ *
16
+ * Two overlapping reasons, both verified in Chrome:
17
+ *
18
+ * 1. The HTML parser owns server markup. `<p>` and the heading-ish elements
19
+ * take phrasing content only, so a block element inside one makes the
20
+ * parser close the paragraph and reparent the rest — the layer's content
21
+ * ends up in the page instead of the popover. A nested `<a>` triggers the
22
+ * same tearing through the adoption agency algorithm.
23
+ * 2. Interactive ancestors capture the layer's own interactions. A card
24
+ * hosted inside an `<a>` or `<button>` puts its links and buttons inside
25
+ * that control: clicking one navigates the wrapping link, and every
26
+ * focusable in the card joins the control's own tab stop.
27
+ *
28
+ * Inline formatting elements are on the list for a third, milder reason: a
29
+ * layer hosted in one inherits its typography (font-size, text-align), so a
30
+ * card in a 13px centered paragraph renders 13px and centered.
31
+ */
32
+ const UNSAFE_HOSTS = new Set([
33
+ // Phrasing-content-only containers
34
+ 'p',
35
+ 'h1',
36
+ 'h2',
37
+ 'h3',
38
+ 'h4',
39
+ 'h5',
40
+ 'h6',
41
+ 'dt',
42
+ 'pre',
43
+ 'legend',
44
+ 'data',
45
+ 'dfn',
46
+ 'meter',
47
+ 'output',
48
+ 'progress',
49
+ 'option',
50
+ 'optgroup',
51
+ // Structural containers whose direct children are restricted. An inert
52
+ // <template> marker is valid script-supporting content in these positions,
53
+ // but the eventual div/span layer is not.
54
+ 'table',
55
+ 'thead',
56
+ 'tbody',
57
+ 'tfoot',
58
+ 'tr',
59
+ 'colgroup',
60
+ 'ul',
61
+ 'ol',
62
+ 'menu',
63
+ 'dl',
64
+ 'select',
65
+ 'datalist',
66
+ 'picture',
67
+ 'hgroup',
68
+ 'ruby',
69
+ 'rt',
70
+ 'rp',
71
+ // Interactive containers
72
+ 'a',
73
+ 'button',
74
+ 'label',
75
+ 'summary',
76
+ // Inline formatting context
77
+ 'span',
78
+ 'em',
79
+ 'strong',
80
+ 'b',
81
+ 'i',
82
+ 'u',
83
+ 's',
84
+ 'small',
85
+ 'mark',
86
+ 'code',
87
+ 'kbd',
88
+ 'samp',
89
+ 'var',
90
+ 'sub',
91
+ 'sup',
92
+ 'abbr',
93
+ 'cite',
94
+ 'q',
95
+ 'time',
96
+ 'bdi',
97
+ 'bdo',
98
+ 'ins',
99
+ 'del',
100
+ ]);
101
+
102
+ /**
103
+ * Return the corrective portal target for a layer's intended inline parent.
104
+ *
105
+ * A null result means the inline parent and all of its ancestors are safe, so
106
+ * the layer should stay at its JSX position. Otherwise the result is the
107
+ * nearest element outside every unsafe ancestor around that position.
108
+ *
109
+ * Walking up from the actual render position (rather than the trigger, or
110
+ * portaling to `document.body`) keeps the two things a layer inherits there:
111
+ *
112
+ * - **Theme.** Theme scopes live on DOM ancestors. Staying as close as
113
+ * possible to the intended render position preserves their component rules
114
+ * and custom properties; `document.body` may sit outside a nested scope.
115
+ * - **Tab order.** Sequential focus follows DOM order, so a host near the
116
+ * trigger keeps the layer's focusables next to it. (`show()` also passes
117
+ * the trigger as the popover's invoker `source`, which pins focus order to
118
+ * the invoker in browsers that support it.)
119
+ *
120
+ * The outermost unsafe ancestor matters. A safe div may itself sit inside an
121
+ * anchor; stopping at that div would still put the layer's buttons inside the
122
+ * link. Walking the whole chain ensures the target is outside both.
123
+ */
124
+ export function resolveLayerPortalTarget(
125
+ inlineParent: HTMLElement | null,
126
+ ): HTMLElement | null {
127
+ if (!inlineParent) {
128
+ return null;
129
+ }
130
+
131
+ let outermostUnsafe: HTMLElement | null = null;
132
+ let node: HTMLElement | null = inlineParent;
133
+ while (node) {
134
+ if (UNSAFE_HOSTS.has(node.tagName.toLowerCase())) {
135
+ outermostUnsafe = node;
136
+ }
137
+ node = node.parentElement;
138
+ }
139
+
140
+ return outermostUnsafe?.parentElement ?? null;
141
+ }
@@ -42,6 +42,13 @@ export const docs = {
42
42
  'Whether clicking outside should dismiss the layer using native popover light-dismiss behavior.',
43
43
  default: 'false',
44
44
  },
45
+ {
46
+ name: 'lazyMount',
47
+ type: 'boolean',
48
+ description:
49
+ 'Context mode only. Wait until show() to resolve the inline/portal position and mount content; hide unmounts the content while the inert marker remains.',
50
+ default: 'false',
51
+ },
45
52
  ],
46
53
  returns: [
47
54
  {
@@ -79,7 +86,7 @@ export const docs = {
79
86
  name: 'render',
80
87
  type: '(children: ReactNode, props: ContextRenderProps | FixedRenderProps) => ReactNode',
81
88
  description:
82
- 'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode. Placement/alignment are logical: they map to the self-* position-area keyword family, which resolves against the popover\'s own inherited direction, so RTL contexts mirror automatically in pure CSS. Pass `positioning: "custom"` in context mode to author position styles yourself via `style` (e.g. explicit anchor() insets or an anchor-size() cover): the hook keeps the popover behavior and position-anchor wiring but derives no position styles, including the automatic RTL mirroring, which becomes your responsibility. Pass `offset` (a CSS length; a number is px) in context mode for clearance from the anchor: it applies to both edges of the placement axis, so the gap survives a flip. Layers are flush by default. In context mode, pass `as: "span"` to render an inline-safe layer (e.g. inside a paragraph). The layer renders inline in the React tree; the Popover API promotes it to the top layer when shown, so it escapes ancestor clipping and stacking without a portal. When the layer would overflow the viewport, position-try fallbacks flip it to the opposite side; centered layers additionally slide along the alignment axis (span fallbacks) so they stay on-screen near viewport edges.',
89
+ 'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode. Placement/alignment are logical: they map to the self-* position-area keyword family, which resolves against the popover\'s own inherited direction, so RTL contexts mirror automatically in pure CSS. Pass `positioning: "custom"` in context mode to author position styles yourself via `style` (e.g. explicit anchor() insets or an anchor-size() cover): the hook keeps the popover behavior and position-anchor wiring but derives no position styles, including the automatic RTL mirroring, which becomes your responsibility. Pass `offset` (a CSS length; a number is px) in context mode for clearance from the anchor: it applies to both edges of the placement axis, so the gap survives a flip. Layers are flush by default. Context mode first renders an inert `<template>` marker in matching server and client markup. The final layer stays at that JSX position if its parent is safe; otherwise it is portaled to the nearest ancestor outside paragraphs, links, buttons, inline formatting, and structurally restricted containers. The nearest safe host keeps CSS custom properties inheriting live, while the layer preserves direction and writing mode from its JSX position. By default this resolution occurs after hydration so closed-layer DOM remains available; `lazyMount` defers it until `show()` and unmounts the content again on hide while the marker remains. The Popover API promotes the layer to the top layer when shown, so it escapes ancestor clipping and stacking wherever it is hosted. When the layer would overflow the viewport, position-try fallbacks flip it to the opposite side; centered layers additionally slide along the alignment axis (span fallbacks) so they stay on-screen near viewport edges.',
83
90
  },
84
91
  ],
85
92
  usage: {
@@ -99,7 +106,7 @@ export const docs = {
99
106
  {
100
107
  guidance: true,
101
108
  description:
102
- 'Rely on the Popover API top layer to escape ancestor clipping and stacking: render the layer inline (no portal) so it inherits the trigger\'s theme cascade and keeps a natural focus order. Use `as: "span"` when the layer must be valid inside inline contexts like a paragraph.',
109
+ "Rely on the Popover API top layer to escape ancestor clipping and stacking, and host the layer near its trigger rather than in the body so it inherits the trigger's theme cascade and keeps a natural focus order.",
103
110
  },
104
111
  {
105
112
  guidance: false,
@@ -123,6 +130,8 @@ export const docsDense = {
123
130
  onShow: 'fires when layer becomes visible.',
124
131
  onHide: 'fires when layer hides.',
125
132
  lightDismiss: 'whether native outside-click light-dismiss is enabled.',
133
+ lazyMount:
134
+ 'context only: defer position resolution/content mounting until show; unmount on hide.',
126
135
  },
127
136
  returnDescriptions: {
128
137
  ref: 'trigger ref for context mode; undefined in fixed mode.',
@@ -131,7 +140,8 @@ export const docsDense = {
131
140
  hide: 'hide layer.',
132
141
  isOpen: 'whether layer is open.',
133
142
  id: 'unique ARIA id.',
134
- render: 'renders popover element; pass placement/alignment or x/y. Placement/alignment logical: mapped to self-* position-area keywords resolved against the popover\'s inherited direction (RTL mirrors in pure CSS). `positioning: "custom"` (context mode) = author position styles yourself via `style`; keeps popover behavior + position-anchor wiring, derives nothing (incl. RTL mirroring, which becomes yours). `offset` (context mode) = clearance from the anchor as a CSS length (number = px), applied to both edges of the placement axis so it survives a flip; layers are flush by default. Context mode accepts `as: "span"` for inline-safe layers. Renders inline; the Popover API top layer escapes clipping/stacking without a portal. Viewport overflow: flips to opposite side; centered layers also slide along the alignment axis (span fallbacks).',
143
+ render:
144
+ 'renders popover element; pass placement/alignment or x/y. Placement/alignment logical: mapped to self-* position-area keywords resolved against the popover\'s inherited direction (RTL mirrors in pure CSS). `positioning: "custom"` (context mode) = author position styles yourself via `style`; keeps popover behavior + position-anchor wiring, derives nothing (incl. RTL mirroring, which becomes yours). `offset` (context mode) = clearance from the anchor as a CSS length (number = px), applied to both edges of the placement axis so it survives a flip; layers are flush by default. Context mode begins with an inert `<template>` marker for stable SSR/hydration, then keeps the final layer inline at a safe JSX position or portals it to the nearest safe ancestor; CSS custom properties keep inheriting from that host while direction and writing mode are preserved from the JSX position. `lazyMount` waits for show and unmounts content on hide while the marker remains. The Popover API top layer escapes clipping/stacking wherever it is hosted. Viewport overflow: flips to opposite side; centered layers also slide along the alignment axis (span fallbacks).',
135
145
  },
136
146
  usage: {
137
147
  description:
@@ -150,7 +160,7 @@ export const docsDense = {
150
160
  {
151
161
  guidance: true,
152
162
  description:
153
- 'Rely on the Popover API top layer to escape clipping/stacking: render inline (no portal) to inherit the trigger theme cascade and natural focus order. Use `as: "span"` when the layer must be valid in inline contexts like a paragraph.',
163
+ 'Rely on the Popover API top layer to escape clipping/stacking; host the layer near its trigger (not in the body) to inherit the trigger theme cascade and natural focus order.',
154
164
  },
155
165
  {
156
166
  guidance: false,