@astryxdesign/core 0.4.0 → 0.4.1-canary.1ce0b4c

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 (271) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +11 -9
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/HoverCard/HoverCard.d.ts +1 -1
  52. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  53. package/dist/HoverCard/HoverCard.js +5 -13
  54. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  55. package/dist/HoverCard/useHoverCard.js +6 -5
  56. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  57. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  58. package/dist/Icon/globalIconRegistry.js +5 -4
  59. package/dist/Indicator/index.d.ts +1 -1
  60. package/dist/Indicator/index.d.ts.map +1 -1
  61. package/dist/Indicator/types.d.ts +10 -0
  62. package/dist/Indicator/types.d.ts.map +1 -1
  63. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  64. package/dist/InputGroup/groupStyles.js +7 -2
  65. package/dist/Layer/layerHost.d.ts +24 -0
  66. package/dist/Layer/layerHost.d.ts.map +1 -0
  67. package/dist/Layer/layerHost.js +79 -0
  68. package/dist/Layer/useLayer.d.ts +14 -6
  69. package/dist/Layer/useLayer.d.ts.map +1 -1
  70. package/dist/Layer/useLayer.js +155 -29
  71. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  72. package/dist/Lightbox/Lightbox.js +24 -50
  73. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  74. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  75. package/dist/MultiSelector/MultiSelector.js +134 -102
  76. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  77. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  78. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  79. package/dist/Popover/usePopover.d.ts +15 -0
  80. package/dist/Popover/usePopover.d.ts.map +1 -1
  81. package/dist/Popover/usePopover.js +14 -2
  82. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  83. package/dist/ProgressBar/ProgressBar.js +2 -3
  84. package/dist/Section/Section.d.ts.map +1 -1
  85. package/dist/Section/Section.js +1 -2
  86. package/dist/Section/index.d.ts.map +1 -1
  87. package/dist/Section/index.js +1 -2
  88. package/dist/Selector/Selector.d.ts +10 -0
  89. package/dist/Selector/Selector.d.ts.map +1 -1
  90. package/dist/Selector/Selector.js +135 -95
  91. package/dist/SideNav/SideNav.d.ts +7 -9
  92. package/dist/SideNav/SideNav.d.ts.map +1 -1
  93. package/dist/SideNav/SideNav.js +32 -5
  94. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  95. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  96. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  97. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  98. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  99. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  100. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  101. package/dist/SideNav/SideNavHeading.js +70 -24
  102. package/dist/SideNav/SideNavItem.d.ts +7 -2
  103. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  104. package/dist/SideNav/SideNavItem.js +114 -75
  105. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  106. package/dist/SideNav/SideNavSection.js +7 -16
  107. package/dist/SideNav/index.d.ts +1 -1
  108. package/dist/SideNav/index.d.ts.map +1 -1
  109. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  110. package/dist/Skeleton/Skeleton.js +1 -2
  111. package/dist/Skeleton/index.d.ts.map +1 -1
  112. package/dist/Skeleton/index.js +1 -2
  113. package/dist/Slider/Slider.d.ts.map +1 -1
  114. package/dist/Slider/Slider.js +2 -6
  115. package/dist/Switch/Switch.d.ts.map +1 -1
  116. package/dist/Switch/Switch.js +3 -3
  117. package/dist/Table/TableRow.d.ts +1 -1
  118. package/dist/Table/TableRow.d.ts.map +1 -1
  119. package/dist/Table/TableRow.js +4 -2
  120. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  121. package/dist/TimeInput/TimeInput.js +7 -2
  122. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  123. package/dist/Timestamp/Timestamp.js +14 -3
  124. package/dist/Timestamp/formatInstant.d.ts +11 -1
  125. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  126. package/dist/Timestamp/formatInstant.js +3 -1
  127. package/dist/TopNav/TopNavMegaMenu.js +1 -1
  128. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  129. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  130. package/dist/TopNav/TopNavMenu.js +2 -2
  131. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  132. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  133. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  134. package/dist/VisuallyHidden/index.js +1 -2
  135. package/dist/astryx.css +23 -24
  136. package/dist/astryx.umd.js +48 -48
  137. package/dist/astryx.umd.js.map +4 -4
  138. package/dist/hooks/index.d.ts +1 -1
  139. package/dist/hooks/index.d.ts.map +1 -1
  140. package/dist/hooks/index.js +1 -1
  141. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  142. package/dist/hooks/useFocusTrap.js +16 -2
  143. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  144. package/dist/hooks/useInputStatusIcon.js +17 -4
  145. package/dist/theme/defineTheme.d.ts +2 -2
  146. package/dist/theme/defineTheme.d.ts.map +1 -1
  147. package/dist/theme/defineTheme.js +2 -1
  148. package/dist/theme/index.d.ts +1 -1
  149. package/dist/theme/index.d.ts.map +1 -1
  150. package/dist/theme/index.js +1 -1
  151. package/dist/theme/tokens.stylex.d.ts +12 -0
  152. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  153. package/dist/theme/tokens.stylex.js +23 -0
  154. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  155. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  156. package/dist/utils/focusOutline.stylex.js +46 -33
  157. package/dist/utils/index.d.ts +1 -0
  158. package/dist/utils/index.d.ts.map +1 -1
  159. package/dist/utils/index.js +5 -1
  160. package/dist/utils/interactionModality.d.ts +34 -0
  161. package/dist/utils/interactionModality.d.ts.map +1 -0
  162. package/dist/utils/interactionModality.js +70 -0
  163. package/locales/en.json +44 -0
  164. package/locales/pseudo.json +33 -0
  165. package/package.json +3 -3
  166. package/src/AppShell/AppShell.doc.mjs +3 -0
  167. package/src/AspectRatio/AspectRatio.tsx +0 -2
  168. package/src/AspectRatio/index.ts +0 -2
  169. package/src/Badge/Badge.tsx +0 -2
  170. package/src/Badge/index.ts +0 -2
  171. package/src/Banner/Banner.test.tsx +58 -1
  172. package/src/Banner/Banner.tsx +24 -5
  173. package/src/Blockquote/Blockquote.tsx +0 -2
  174. package/src/Blockquote/index.ts +0 -2
  175. package/src/Button/Button.doc.mjs +2 -2
  176. package/src/Button/Button.tsx +10 -7
  177. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  178. package/src/Card/Card.tsx +0 -2
  179. package/src/Card/index.ts +0 -2
  180. package/src/Center/Center.tsx +0 -2
  181. package/src/Center/index.ts +0 -2
  182. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  183. package/src/Code/Code.tsx +0 -2
  184. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  185. package/src/CommandPalette/CommandPalette.tsx +5 -0
  186. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  187. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  188. package/src/DateInput/DateInput.test.tsx +19 -0
  189. package/src/DateInput/DateInput.tsx +3 -6
  190. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  191. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  192. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  193. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  194. package/src/Field/PanelSearchInput.tsx +271 -0
  195. package/src/FileInput/FileInput.tsx +21 -8
  196. package/src/Grid/Grid.tsx +0 -2
  197. package/src/Grid/index.ts +0 -2
  198. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  199. package/src/HoverCard/HoverCard.test.tsx +283 -55
  200. package/src/HoverCard/HoverCard.tsx +5 -13
  201. package/src/HoverCard/useHoverCard.tsx +8 -11
  202. package/src/Icon/globalIconRegistry.tsx +5 -4
  203. package/src/Indicator/Indicator.doc.mjs +1 -1
  204. package/src/Indicator/index.ts +1 -0
  205. package/src/Indicator/types.ts +11 -0
  206. package/src/InputGroup/groupStyles.ts +7 -8
  207. package/src/Layer/layerHost.test.ts +99 -0
  208. package/src/Layer/layerHost.ts +141 -0
  209. package/src/Layer/useLayer.doc.mjs +14 -4
  210. package/src/Layer/useLayer.test.tsx +318 -7
  211. package/src/Layer/useLayer.tsx +229 -35
  212. package/src/Lightbox/Lightbox.tsx +2 -11
  213. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  214. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  215. package/src/MultiSelector/MultiSelector.tsx +170 -127
  216. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  217. package/src/Popover/Popover.doc.mjs +2 -0
  218. package/src/Popover/usePopover.tsx +37 -4
  219. package/src/ProgressBar/ProgressBar.tsx +2 -8
  220. package/src/Section/Section.tsx +0 -2
  221. package/src/Section/index.ts +0 -2
  222. package/src/Selector/Selector.doc.mjs +10 -0
  223. package/src/Selector/Selector.test.tsx +386 -2
  224. package/src/Selector/Selector.tsx +182 -122
  225. package/src/SideNav/SideNav.doc.mjs +13 -4
  226. package/src/SideNav/SideNav.test.tsx +771 -2
  227. package/src/SideNav/SideNav.tsx +37 -16
  228. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  229. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  230. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  231. package/src/SideNav/SideNavHeading.tsx +28 -10
  232. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  233. package/src/SideNav/SideNavItem.tsx +92 -84
  234. package/src/SideNav/SideNavSection.tsx +6 -20
  235. package/src/SideNav/index.ts +2 -0
  236. package/src/Skeleton/Skeleton.tsx +0 -2
  237. package/src/Skeleton/index.ts +0 -2
  238. package/src/Slider/Slider.tsx +2 -11
  239. package/src/Switch/Switch.tsx +10 -2
  240. package/src/Table/Table.test.tsx +31 -0
  241. package/src/Table/TableRow.tsx +6 -0
  242. package/src/Text/Text.doc.mjs +1 -1
  243. package/src/TimeInput/TimeInput.test.tsx +54 -0
  244. package/src/TimeInput/TimeInput.tsx +17 -2
  245. package/src/Timestamp/Timestamp.test.tsx +125 -30
  246. package/src/Timestamp/Timestamp.tsx +14 -2
  247. package/src/Timestamp/formatInstant.ts +16 -1
  248. package/src/Toolbar/Toolbar.test.tsx +14 -14
  249. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  250. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  251. package/src/TopNav/TopNavMegaMenu.tsx +4 -1
  252. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  253. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  254. package/src/TopNav/TopNavMenu.tsx +8 -2
  255. package/src/TreeList/TreeList.test.tsx +3 -2
  256. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  257. package/src/VisuallyHidden/index.ts +0 -2
  258. package/src/hooks/index.ts +5 -1
  259. package/src/hooks/useFocusTrap.test.tsx +16 -0
  260. package/src/hooks/useFocusTrap.ts +20 -2
  261. package/src/hooks/useInputStatusIcon.tsx +8 -8
  262. package/src/serverSafeComponents.test.ts +665 -0
  263. package/src/theme/defineTheme.ts +3 -0
  264. package/src/theme/generateThemeRules.test.ts +17 -0
  265. package/src/theme/index.ts +2 -0
  266. package/src/theme/themingTargets.test.ts +152 -26
  267. package/src/theme/tokens.stylex.ts +18 -0
  268. package/src/theme/tokens.test.ts +37 -0
  269. package/src/utils/focusOutline.stylex.ts +37 -23
  270. package/src/utils/index.ts +4 -0
  271. package/src/utils/interactionModality.ts +74 -0
@@ -25,7 +25,9 @@ import React, {
25
25
  } from 'react';
26
26
  import * as stylex from '@stylexjs/stylex';
27
27
  import type {StyleXStyles} from '@stylexjs/stylex';
28
+ import {createPortal} from 'react-dom';
28
29
  import {addAnchorName, removeAnchorName} from './anchorName';
30
+ import {resolveLayerPortalTarget} from './layerHost';
29
31
  import {typographyVars} from '../theme/tokens.stylex';
30
32
 
31
33
  const styles = stylex.create({
@@ -149,12 +151,11 @@ export interface ContextRenderProps {
149
151
  /**
150
152
  * HTML tag to render the popover container as.
151
153
  *
152
- * Defaults to `'div'`. Pass `'span'` when the layer must render inline-safe
153
- * markup — e.g. a `HoverCard` wrapping inline text inside a `<p>`. A `<span>`
154
- * is phrasing content, so it stays put in the DOM tree instead of being
155
- * reparented out of a paragraph by the HTML parser, which keeps server and
156
- * client markup identical. The Popover API and CSS anchor positioning work
157
- * the same on either tag.
154
+ * Defaults to `'div'`. Context layers render an inert `<template>` marker at
155
+ * the JSX position. The marker's parent is checked before the requested
156
+ * container mounts there or portals outside ancestors that cannot safely
157
+ * contain it. The marker remains available to detect a new parent if the
158
+ * render call moves. With `lazyMount`, the first check waits until `show()`.
158
159
  *
159
160
  * @default 'div'
160
161
  */
@@ -222,6 +223,15 @@ interface BaseLayerOptions {
222
223
  */
223
224
  export interface ContextLayerOptions extends BaseLayerOptions {
224
225
  mode: 'context';
226
+ /**
227
+ * Defer mounting the final layer and resolving its inline/portal position
228
+ * until `show()` is requested. Hiding unmounts it while the inert marker
229
+ * remains at the JSX position. Use this when rich content must never enter
230
+ * an unsafe ancestor, even briefly, and does not need to exist while closed.
231
+ *
232
+ * @default false
233
+ */
234
+ lazyMount?: boolean;
225
235
  }
226
236
 
227
237
  /**
@@ -314,16 +324,51 @@ function toCssLength(value: number | string): string {
314
324
  return typeof value === 'number' ? `${value}px` : value;
315
325
  }
316
326
 
327
+ interface ContextLayerMount {
328
+ /** Null means the marker's parent is safe and the layer stays inline. */
329
+ portalTarget: HTMLElement | null;
330
+ /** Logical writing context lost when moving outside an unsafe ancestor. */
331
+ portalStyle: React.CSSProperties;
332
+ }
333
+
334
+ function readPortalWritingContext(
335
+ element: HTMLElement,
336
+ portalTarget: HTMLElement,
337
+ ): React.CSSProperties {
338
+ const view = element.ownerDocument.defaultView;
339
+ if (!view) {
340
+ return {};
341
+ }
342
+ const sourceStyle = view.getComputedStyle(element);
343
+ const targetStyle = view.getComputedStyle(portalTarget);
344
+
345
+ // Do not snapshot custom properties here. The portal target is the closest
346
+ // safe ancestor, so theme variables continue to inherit and update there.
347
+ // These two properties can be set on the unsafe chain itself and directly
348
+ // affect the logical anchor-positioning keywords used by the layer. Only
349
+ // override values the portal would actually lose; matching values should
350
+ // keep inheriting from the target so later direction changes remain live.
351
+ return {
352
+ ...(sourceStyle.direction !== targetStyle.direction && {
353
+ direction: sourceStyle.direction as React.CSSProperties['direction'],
354
+ }),
355
+ ...(sourceStyle.writingMode !== targetStyle.writingMode && {
356
+ writingMode:
357
+ sourceStyle.writingMode as React.CSSProperties['writingMode'],
358
+ }),
359
+ };
360
+ }
361
+
317
362
  /**
318
363
  * Map logical placement/alignment to a CSS position-area value.
319
364
  *
320
365
  * Uses the self-* logical keyword family: the inline axis resolves against
321
- * the popover's own inherited direction (the layer renders inside the
322
- * trigger's subtree, so it inherits `direction` and mirrors in RTL with no
323
- * JS). The block axis is direction-neutral but must come from the same
324
- * keyword family — mixing physical `top` with `self-inline-*` produces an
325
- * invalid position-area (computes to `none`, which pins the popover to the
326
- * viewport corner because styles.base zeroes the UA margins).
366
+ * the popover's own direction (inherited inline or preserved when portaled),
367
+ * so it mirrors in RTL without placement-specific JS. The block axis is
368
+ * direction-neutral but must come from the same keyword family — mixing
369
+ * physical `top` with `self-inline-*` produces an invalid position-area
370
+ * (computes to `none`, which pins the popover to the viewport corner because
371
+ * styles.base zeroes the UA margins).
327
372
  *
328
373
  * Note the plain logical family (`inline-start`, no `self-`) is NOT a
329
374
  * substitute: it resolves against the containing block — the page root for
@@ -406,39 +451,122 @@ export function useLayer(
406
451
  options: ContextLayerOptions | FixedLayerOptions,
407
452
  ): ContextLayerReturn | FixedLayerReturn {
408
453
  const {mode, onShow, onHide, lightDismiss = false} = options;
454
+ const lazyMount = mode === 'context' ? (options.lazyMount ?? false) : false;
409
455
  const id = useId();
410
456
  const anchorId = `--astryx-layer-${id.replace(/:/g, '')}`;
411
457
 
412
458
  const [isOpen, setIsOpen] = useState(false);
413
459
  const popoverRef = useRef<HTMLElement | null>(null);
460
+ // The DOM element on which the current logical open state was applied.
461
+ // A portal target change replaces the popover element; retaining the old
462
+ // reference lets the ref callback recognize and reopen its replacement.
463
+ const openedPopoverRef = useRef<HTMLElement | null>(null);
414
464
  const triggerRef = useRef<HTMLElement | null>(null);
465
+ // Context layers place a persistent inert marker at their real JSX position.
466
+ // Its parent tells us whether the final layer can stay inline or needs a
467
+ // corrective portal, including after the render call moves.
468
+ const sentinelRef = useRef<HTMLTemplateElement | null>(null);
469
+ const contextMountRef = useRef<ContextLayerMount | null>(null);
470
+ const [contextMount, setContextMount] = useState<ContextLayerMount | null>(
471
+ null,
472
+ );
473
+ // A show() that arrives before the final layer mounts is replayed when its
474
+ // popover ref attaches.
475
+ const pendingShowRef = useRef(false);
415
476
 
416
477
  // Ref mirrors isOpen for synchronous reads inside show/hide.
417
478
  // State drives re-renders; the ref lets the imperative calls avoid
418
479
  // stale-closure reads of the previous isOpen value.
419
480
  const isOpenRef = useRef(false);
420
481
 
421
- const show = useCallback(() => {
422
- const popover = popoverRef.current;
423
- if (popover && !isOpenRef.current) {
424
- // Finding infra-4: the Popover API is unsupported on Safari <17 and
425
- // Firefox <125. On those browsers `showPopover` does not exist, so
426
- // calling it unconditionally throws a TypeError and the layer never
427
- // opens. Guard behind a feature check; when the API is missing, fall
428
- // back to plain visibility (the [popover] attribute is inert there, so
429
- // the element sits in normal flow) so the layer still becomes visible.
430
- if (typeof popover.showPopover === 'function') {
431
- popover.showPopover();
432
- } else {
433
- popover.style.display = 'block';
482
+ const showPopoverElement = useCallback((popover: HTMLElement) => {
483
+ // Finding infra-4: the Popover API is unsupported on Safari <17 and
484
+ // Firefox <125. On those browsers `showPopover` does not exist, so fall
485
+ // back to plain visibility instead of throwing.
486
+ if (typeof popover.showPopover === 'function') {
487
+ // The trigger is passed as the popover's invoker `source`: a layer
488
+ // hosted away from its trigger then still takes its sequential focus
489
+ // order (and its popover nesting) from the trigger rather than from its
490
+ // own DOM position. Browsers without the option ignore it.
491
+ popover.showPopover({source: triggerRef.current ?? undefined});
492
+ } else {
493
+ popover.style.display = 'block';
494
+ }
495
+ openedPopoverRef.current = popover;
496
+ }, []);
497
+
498
+ const isCurrentContextPopover = useCallback(
499
+ (popover: HTMLElement): boolean => {
500
+ if (mode !== 'context') {
501
+ return true;
434
502
  }
503
+ const mount = contextMountRef.current;
504
+ if (mount === null) {
505
+ return false;
506
+ }
507
+ const expectedParent =
508
+ mount.portalTarget ?? sentinelRef.current?.parentElement ?? null;
509
+ return popover.parentElement === expectedParent;
510
+ },
511
+ [mode],
512
+ );
513
+
514
+ const requestContextMount = useCallback(() => {
515
+ if (mode !== 'context') {
516
+ return;
517
+ }
518
+
519
+ const sentinel = sentinelRef.current;
520
+ const inlineParent = sentinel?.parentElement ?? null;
521
+ if (!sentinel || !inlineParent) {
522
+ return;
523
+ }
524
+
525
+ const portalTarget = resolveLayerPortalTarget(inlineParent);
526
+ const mount: ContextLayerMount = {
527
+ portalTarget,
528
+ portalStyle: portalTarget
529
+ ? readPortalWritingContext(sentinel, portalTarget)
530
+ : {},
531
+ };
532
+ contextMountRef.current = mount;
533
+ setContextMount(mount);
534
+ }, [mode]);
535
+
536
+ const clearContextMount = useCallback(() => {
537
+ if (mode !== 'context' || !lazyMount) {
538
+ return;
539
+ }
540
+ contextMountRef.current = null;
541
+ setContextMount(null);
542
+ }, [mode, lazyMount]);
543
+
544
+ const show = useCallback(() => {
545
+ // A context popover left over until React commits a previous hide must not
546
+ // be reopened. The synchronous mount ref is the source of truth.
547
+ const candidate = popoverRef.current;
548
+ const popover =
549
+ candidate && isCurrentContextPopover(candidate) ? candidate : null;
550
+ if (!popover) {
551
+ pendingShowRef.current = true;
552
+ requestContextMount();
553
+ return;
554
+ }
555
+ if (!isOpenRef.current) {
556
+ showPopoverElement(popover);
435
557
  isOpenRef.current = true;
436
558
  setIsOpen(true);
437
559
  onShow?.();
438
560
  }
439
- }, [onShow]);
561
+ }, [
562
+ onShow,
563
+ requestContextMount,
564
+ showPopoverElement,
565
+ isCurrentContextPopover,
566
+ ]);
440
567
 
441
568
  const hide = useCallback(() => {
569
+ pendingShowRef.current = false;
442
570
  if (isOpenRef.current) {
443
571
  const el = popoverRef.current;
444
572
  // See finding infra-4 note in `show`: mirror the same guard on hide so
@@ -450,11 +578,13 @@ export function useLayer(
450
578
  el.style.display = 'none';
451
579
  }
452
580
  }
581
+ openedPopoverRef.current = null;
453
582
  isOpenRef.current = false;
454
583
  setIsOpen(false);
455
584
  onHide?.();
456
585
  }
457
- }, [onHide]);
586
+ clearContextMount();
587
+ }, [onHide, clearContextMount]);
458
588
 
459
589
  // Ref for trigger element (context mode only)
460
590
  const ref: RefCallback<HTMLElement> | undefined =
@@ -488,12 +618,14 @@ export function useLayer(
488
618
  (e: Event) => {
489
619
  const toggleEvent = e as ToggleEvent;
490
620
  if (toggleEvent.newState === 'closed' && isOpenRef.current) {
621
+ openedPopoverRef.current = null;
491
622
  isOpenRef.current = false;
492
623
  setIsOpen(false);
493
624
  onHide?.();
625
+ clearContextMount();
494
626
  }
495
627
  },
496
- [onHide],
628
+ [onHide, clearContextMount],
497
629
  );
498
630
 
499
631
  // Ref callback for popover element — sets up the `toggle` listener.
@@ -531,8 +663,40 @@ export function useLayer(
531
663
  (el: HTMLElement | null) => {
532
664
  popoverRef.current = el;
533
665
  bindToggleListener(el, handleToggle);
666
+ if (el && pendingShowRef.current) {
667
+ pendingShowRef.current = false;
668
+ show();
669
+ } else if (
670
+ el &&
671
+ isOpenRef.current &&
672
+ openedPopoverRef.current !== el &&
673
+ isCurrentContextPopover(el)
674
+ ) {
675
+ // Changing a portal target remounts the popover. Preserve the logical
676
+ // open state without firing onShow again for the replacement element.
677
+ showPopoverElement(el);
678
+ }
679
+ },
680
+ [
681
+ handleToggle,
682
+ bindToggleListener,
683
+ show,
684
+ showPopoverElement,
685
+ isCurrentContextPopover,
686
+ ],
687
+ );
688
+
689
+ const sentinelRefCallback = useCallback(
690
+ (el: HTMLTemplateElement | null) => {
691
+ sentinelRef.current = el;
692
+ if (el && (!lazyMount || pendingShowRef.current || isOpenRef.current)) {
693
+ // The render call may have moved while the hook stayed mounted.
694
+ // Resolve again from the newly attached marker rather than reusing a
695
+ // portal target from its previous JSX position.
696
+ requestContextMount();
697
+ }
534
698
  },
535
- [handleToggle, bindToggleListener],
699
+ [lazyMount, requestContextMount],
536
700
  );
537
701
 
538
702
  // Re-bind when the handler identity changes while the element stays mounted,
@@ -556,6 +720,15 @@ export function useLayer(
556
720
  // Render function for context mode
557
721
  const renderContext = useCallback(
558
722
  (children: ReactNode, props?: ContextRenderProps) => {
723
+ // Keep the marker mounted after resolving the layer. Apart from giving
724
+ // us the real JSX parent on first show, this lets its ref report when a
725
+ // persistent hook's render call moves to a different host.
726
+ const sentinel = <template ref={sentinelRefCallback} />;
727
+
728
+ if (contextMount === null) {
729
+ return <>{sentinel}</>;
730
+ }
731
+
559
732
  const {
560
733
  placement = 'above',
561
734
  alignment = 'center',
@@ -598,10 +771,10 @@ export function useLayer(
598
771
  ? `${extraClassName} ${stylexResult.className ?? ''}`
599
772
  : stylexResult.className;
600
773
 
601
- // Render as the requested tag. A `span` keeps the layer phrasing content
602
- // so it is valid (and stays put on hydration) inside inline contexts like
603
- // a `<p>`; `div` remains the default for block layers.
604
- return (
774
+ // The marker gives us the actual JSX parent without mounting arbitrary
775
+ // children there. Safe positions preserve the existing DOM order and
776
+ // cascade; unsafe positions use the nearest corrective portal target.
777
+ const layer = (
605
778
  <Container
606
779
  ref={popoverRefCallback}
607
780
  id={id}
@@ -609,14 +782,35 @@ export function useLayer(
609
782
  aria-label={ariaLabel}
610
783
  popover={lightDismiss ? 'auto' : 'manual'}
611
784
  className={combinedClassName}
612
- style={{...stylexResult.style, ...anchorStyle, ...extraStyle}}
785
+ style={{
786
+ ...stylexResult.style,
787
+ ...anchorStyle,
788
+ ...contextMount.portalStyle,
789
+ ...extraStyle,
790
+ }}
613
791
  onMouseEnter={onMouseEnter}
614
792
  onMouseLeave={onMouseLeave}>
615
793
  {children}
616
794
  </Container>
617
795
  );
796
+
797
+ return (
798
+ <>
799
+ {sentinel}
800
+ {contextMount.portalTarget
801
+ ? createPortal(layer, contextMount.portalTarget)
802
+ : layer}
803
+ </>
804
+ );
618
805
  },
619
- [anchorId, id, lightDismiss, popoverRefCallback],
806
+ [
807
+ anchorId,
808
+ contextMount,
809
+ id,
810
+ lightDismiss,
811
+ popoverRefCallback,
812
+ sentinelRefCallback,
813
+ ],
620
814
  );
621
815
 
622
816
  // Render function for fixed mode
@@ -35,6 +35,7 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
35
35
  import {mergeProps, mergeRefs, rtlStyles} from '../utils';
36
36
  import type {BaseProps} from '../BaseProps';
37
37
  import {themeProps} from '../utils/themeProps';
38
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
38
39
  import {useTranslator} from '../i18n';
39
40
 
40
41
  /**
@@ -158,16 +159,6 @@ const styles = stylex.create({
158
159
  '@media (hover: hover)': 'zoom-in',
159
160
  },
160
161
  },
161
- zoomTarget: {
162
- outline: {
163
- default: 'none',
164
- ':focus-visible': `2px solid ${colorVars['--color-accent']}`,
165
- },
166
- outlineOffset: {
167
- default: '0',
168
- ':focus-visible': '2px',
169
- },
170
- },
171
162
  imageWrapperZoomed: {
172
163
  cursor: 'grab',
173
164
  },
@@ -641,7 +632,7 @@ export function Lightbox({
641
632
  aria-label={isZoomTarget ? t('@astryx.lightbox.zoom') : undefined}
642
633
  {...stylex.props(
643
634
  styles.imageWrapper,
644
- isZoomTarget && styles.zoomTarget,
635
+ isZoomTarget && focusOutlineStyles.focusVisible,
645
636
  !isVideo && hasZoom && !isZoomed && styles.imageWrapperZoomable,
646
637
  !isVideo && isZoomed && styles.imageWrapperZoomed,
647
638
  !isVideo && isDragging && styles.imageWrapperDragging,
@@ -27,6 +27,8 @@ export const docs = {
27
27
  },
28
28
  {className: 'astryx-multi-selector-clear-icon', deprecatedFor: 'input-clear-icon'},
29
29
  {className: 'astryx-multi-selector-empty-state'},
30
+ {className: 'astryx-multi-selector-search'},
31
+ {className: 'astryx-multi-selector-section-heading'},
30
32
  {
31
33
  className: 'astryx-multi-selector-indicator-icon',
32
34
  states: ['state'],
@@ -192,6 +194,13 @@ export const docs = {
192
194
  description:
193
195
  'Custom render function for each selectable option in the dropdown. Not called for dividers, sections, or the select-all row.',
194
196
  },
197
+ {
198
+ name: 'indicatorPosition',
199
+ type: "'start' | 'end'",
200
+ description:
201
+ 'Which edge of the option row carries the checkbox. end pushes it to the far edge of the row, including on the select-all row.',
202
+ default: "'start'",
203
+ },
195
204
  {
196
205
  name: 'width',
197
206
  type: 'SizeValue',
@@ -1774,7 +1774,134 @@ describe('MultiSelector indicator (chevron) icon theme target', () => {
1774
1774
  });
1775
1775
  });
1776
1776
 
1777
+ describe('MultiSelector list structure', () => {
1778
+ it('does not draw a divider under select-all', async () => {
1779
+ const user = userEvent.setup();
1780
+ render(
1781
+ <MultiSelector
1782
+ label="Fruit"
1783
+ options={['Apple', 'Banana', 'Orange']}
1784
+ value={[]}
1785
+ onChange={() => {}}
1786
+ hasSelectAll
1787
+ />,
1788
+ );
1789
+ await user.click(screen.getByRole('combobox'));
1790
+ // Select-all is the first row of the list, not a section of its own. No
1791
+ // option here declares a divider and there is no search row, so the panel
1792
+ // should contain no rule at all.
1793
+ expect(document.querySelectorAll('[role="separator"]')).toHaveLength(0);
1794
+ const [first] = screen.getAllByRole('option', h);
1795
+ expect(first).toHaveTextContent('Select all');
1796
+ });
1797
+
1798
+ it('renders a section title as a plain heading inside the group, not a divider', async () => {
1799
+ const user = userEvent.setup();
1800
+ render(
1801
+ <MultiSelector
1802
+ label="Fruit"
1803
+ options={[
1804
+ {
1805
+ type: 'section',
1806
+ title: 'Citrus',
1807
+ options: [
1808
+ {value: 'orange', label: 'Orange'},
1809
+ {value: 'lemon', label: 'Lemon'},
1810
+ ],
1811
+ },
1812
+ ]}
1813
+ value={[]}
1814
+ onChange={() => {}}
1815
+ />,
1816
+ );
1817
+ await user.click(screen.getByRole('combobox'));
1818
+
1819
+ // A labeled Divider used to stand in for the heading; it rendered a
1820
+ // role="separator" as a direct child of the listbox and stacked a second
1821
+ // rule under the search row's own.
1822
+ expect(document.querySelectorAll('[role="separator"]')).toHaveLength(0);
1823
+
1824
+ const group = screen.getByRole('group', {name: 'Citrus', ...h});
1825
+ const heading = group.querySelector(
1826
+ '.astryx-multi-selector-section-heading',
1827
+ );
1828
+ expect(heading).toBeTruthy();
1829
+ expect(heading).toHaveTextContent('Citrus');
1830
+ // The group already carries the title as its accessible name, so the
1831
+ // visible heading must not announce it a second time.
1832
+ expect(heading).toHaveAttribute('aria-hidden', 'true');
1833
+ // ...and it precedes the options it heads.
1834
+ const [firstOption] = within(group).getAllByRole('option', h);
1835
+ expect(
1836
+ heading!.compareDocumentPosition(firstOption) &
1837
+ Node.DOCUMENT_POSITION_FOLLOWING,
1838
+ ).toBeTruthy();
1839
+ });
1840
+ });
1841
+
1777
1842
  describe('MultiSelector search affordances', () => {
1843
+ it('renders the search row seamlessly — no nested input box, a divider under it', async () => {
1844
+ const user = userEvent.setup();
1845
+ render(
1846
+ <MultiSelector
1847
+ label="Fruit"
1848
+ options={['Apple', 'Banana', 'Orange']}
1849
+ value={[]}
1850
+ onChange={() => {}}
1851
+ hasSearch
1852
+ />,
1853
+ );
1854
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
1855
+
1856
+ const search = screen.getByRole('combobox', h);
1857
+ // The row is the outer gutter; the input sits inside the rounded field.
1858
+ const row = search.closest('.astryx-multi-selector-search');
1859
+ const field = search.parentElement;
1860
+ if (!row || !field) {
1861
+ throw new Error('search row not found');
1862
+ }
1863
+ // The panel is already a bordered surface: the field inside it must not be
1864
+ // a second bordered box (this used to render a TextInput).
1865
+ expect(row).not.toHaveClass('astryx-text-input');
1866
+ expect(search.closest('.astryx-text-input')).toBeNull();
1867
+ // The field is a rounded box inset from the panel edge, shaped like the
1868
+ // option rows under it — not a full-bleed header strip.
1869
+ expect(field).not.toBe(row);
1870
+ // ...and a divider separates it from the options.
1871
+ const separator = document.querySelector('[role="separator"]');
1872
+ if (!separator) {
1873
+ throw new Error('divider not found');
1874
+ }
1875
+ // Order: row, then divider, then the listbox.
1876
+ expect(
1877
+ row.compareDocumentPosition(separator) & Node.DOCUMENT_POSITION_FOLLOWING,
1878
+ ).toBeTruthy();
1879
+ const listbox = screen.getByRole('listbox', h);
1880
+ expect(
1881
+ separator.compareDocumentPosition(listbox) &
1882
+ Node.DOCUMENT_POSITION_FOLLOWING,
1883
+ ).toBeTruthy();
1884
+ });
1885
+
1886
+ it('keeps the search row outside the scrolling option list', async () => {
1887
+ const user = userEvent.setup();
1888
+ render(
1889
+ <MultiSelector
1890
+ label="Fruit"
1891
+ options={['Apple', 'Banana', 'Orange']}
1892
+ value={[]}
1893
+ onChange={() => {}}
1894
+ hasSearch
1895
+ />,
1896
+ );
1897
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
1898
+ // The options scroll under the header rather than carrying it away, so the
1899
+ // field stays reachable in a long list.
1900
+ const search = screen.getByRole('combobox', h);
1901
+ const listbox = screen.getByRole('listbox', h);
1902
+ expect(listbox.contains(search)).toBe(false);
1903
+ });
1904
+
1778
1905
  it('renders a decorative (aria-hidden) magnifier icon whenever hasSearch is on', async () => {
1779
1906
  const user = userEvent.setup();
1780
1907
  render(
@@ -1788,8 +1915,7 @@ describe('MultiSelector search affordances', () => {
1788
1915
  );
1789
1916
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
1790
1917
  const search = screen.getByRole('combobox', h);
1791
- // The search field is a TextInput; the magnifier is its startIcon, so it
1792
- // sits inside the input container as a sibling of the <input>.
1918
+ // The magnifier leads the search row, as a sibling of the <input>.
1793
1919
  const wrapper = search.parentElement;
1794
1920
  const magnifier = wrapper?.querySelector('.astryx-icon');
1795
1921
  expect(magnifier).toBeTruthy();
@@ -2088,7 +2214,7 @@ describe('MultiSelector dropdown option theme target', () => {
2088
2214
  });
2089
2215
 
2090
2216
  describe('MultiSelector popup theme target', () => {
2091
- it('puts astryx-multi-selector-popup on the dropdown box inside the layer', async () => {
2217
+ it('puts astryx-multi-selector-popup on the surface that paints, not the list inside it', async () => {
2092
2218
  const user = userEvent.setup();
2093
2219
  render(
2094
2220
  <MultiSelector
@@ -2100,13 +2226,17 @@ describe('MultiSelector popup theme target', () => {
2100
2226
  );
2101
2227
  await user.click(screen.getByRole('combobox', {name: /Fruit/}));
2102
2228
 
2103
- const layer = document.querySelector('[popover]') as HTMLElement;
2104
2229
  const popup = document.querySelector(
2105
2230
  '.astryx-multi-selector-popup',
2106
2231
  ) as HTMLElement;
2107
2232
  expect(popup).not.toBeNull();
2233
+ expect(popup).toHaveClass('astryx-popover-surface');
2234
+ // The scrolling list is a descendant, not the target itself.
2235
+ expect(popup.querySelector('[role="listbox"]')).not.toBeNull();
2236
+ expect(popup.getAttribute('role')).toBeNull();
2237
+
2238
+ const layer = document.querySelector('[popover]') as HTMLElement;
2108
2239
  expect(popup).not.toBe(layer);
2109
2240
  expect(layer.contains(popup)).toBe(true);
2110
- expect(popup.querySelector('[role="listbox"]')).not.toBeNull();
2111
2241
  });
2112
2242
  });