@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
@@ -25,8 +25,10 @@ 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';
29
- import {typographyVars} from '../theme/tokens.stylex';
30
+ import {resolveLayerPortalTarget} from './layerHost';
31
+ import {typeScaleVars, typographyVars} from '../theme/tokens.stylex';
30
32
 
31
33
  const styles = stylex.create({
32
34
  // Base reset for all layers
@@ -42,7 +44,12 @@ const styles = stylex.create({
42
44
  borderWidth: 0,
43
45
  borderStyle: 'none',
44
46
  overflow: 'visible',
47
+ // A layer is hosted wherever its trigger happens to sit, so type that is
48
+ // inherited rather than declared makes the same component render at a
49
+ // different size in different callers.
45
50
  fontFamily: typographyVars['--font-family-body'],
51
+ fontSize: typeScaleVars['--text-body-size'],
52
+ lineHeight: typeScaleVars['--text-body-leading'],
46
53
  // Override browser default [popover] background (canvas color)
47
54
  backgroundColor: 'transparent',
48
55
  },
@@ -149,12 +156,11 @@ export interface ContextRenderProps {
149
156
  /**
150
157
  * HTML tag to render the popover container as.
151
158
  *
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.
159
+ * Defaults to `'div'`. Context layers render an inert `<template>` marker at
160
+ * the JSX position. The marker's parent is checked before the requested
161
+ * container mounts there or portals outside ancestors that cannot safely
162
+ * contain it. The marker remains available to detect a new parent if the
163
+ * render call moves. With `lazyMount`, the first check waits until `show()`.
158
164
  *
159
165
  * @default 'div'
160
166
  */
@@ -222,6 +228,15 @@ interface BaseLayerOptions {
222
228
  */
223
229
  export interface ContextLayerOptions extends BaseLayerOptions {
224
230
  mode: 'context';
231
+ /**
232
+ * Defer mounting the final layer and resolving its inline/portal position
233
+ * until `show()` is requested. Hiding unmounts it while the inert marker
234
+ * remains at the JSX position. Use this when rich content must never enter
235
+ * an unsafe ancestor, even briefly, and does not need to exist while closed.
236
+ *
237
+ * @default false
238
+ */
239
+ lazyMount?: boolean;
225
240
  }
226
241
 
227
242
  /**
@@ -314,16 +329,51 @@ function toCssLength(value: number | string): string {
314
329
  return typeof value === 'number' ? `${value}px` : value;
315
330
  }
316
331
 
332
+ interface ContextLayerMount {
333
+ /** Null means the marker's parent is safe and the layer stays inline. */
334
+ portalTarget: HTMLElement | null;
335
+ /** Logical writing context lost when moving outside an unsafe ancestor. */
336
+ portalStyle: React.CSSProperties;
337
+ }
338
+
339
+ function readPortalWritingContext(
340
+ element: HTMLElement,
341
+ portalTarget: HTMLElement,
342
+ ): React.CSSProperties {
343
+ const view = element.ownerDocument.defaultView;
344
+ if (!view) {
345
+ return {};
346
+ }
347
+ const sourceStyle = view.getComputedStyle(element);
348
+ const targetStyle = view.getComputedStyle(portalTarget);
349
+
350
+ // Do not snapshot custom properties here. The portal target is the closest
351
+ // safe ancestor, so theme variables continue to inherit and update there.
352
+ // These two properties can be set on the unsafe chain itself and directly
353
+ // affect the logical anchor-positioning keywords used by the layer. Only
354
+ // override values the portal would actually lose; matching values should
355
+ // keep inheriting from the target so later direction changes remain live.
356
+ return {
357
+ ...(sourceStyle.direction !== targetStyle.direction && {
358
+ direction: sourceStyle.direction as React.CSSProperties['direction'],
359
+ }),
360
+ ...(sourceStyle.writingMode !== targetStyle.writingMode && {
361
+ writingMode:
362
+ sourceStyle.writingMode as React.CSSProperties['writingMode'],
363
+ }),
364
+ };
365
+ }
366
+
317
367
  /**
318
368
  * Map logical placement/alignment to a CSS position-area value.
319
369
  *
320
370
  * 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).
371
+ * the popover's own direction (inherited inline or preserved when portaled),
372
+ * so it mirrors in RTL without placement-specific JS. The block axis is
373
+ * direction-neutral but must come from the same keyword family — mixing
374
+ * physical `top` with `self-inline-*` produces an invalid position-area
375
+ * (computes to `none`, which pins the popover to the viewport corner because
376
+ * styles.base zeroes the UA margins).
327
377
  *
328
378
  * Note the plain logical family (`inline-start`, no `self-`) is NOT a
329
379
  * substitute: it resolves against the containing block — the page root for
@@ -406,39 +456,122 @@ export function useLayer(
406
456
  options: ContextLayerOptions | FixedLayerOptions,
407
457
  ): ContextLayerReturn | FixedLayerReturn {
408
458
  const {mode, onShow, onHide, lightDismiss = false} = options;
459
+ const lazyMount = mode === 'context' ? (options.lazyMount ?? false) : false;
409
460
  const id = useId();
410
461
  const anchorId = `--astryx-layer-${id.replace(/:/g, '')}`;
411
462
 
412
463
  const [isOpen, setIsOpen] = useState(false);
413
464
  const popoverRef = useRef<HTMLElement | null>(null);
465
+ // The DOM element on which the current logical open state was applied.
466
+ // A portal target change replaces the popover element; retaining the old
467
+ // reference lets the ref callback recognize and reopen its replacement.
468
+ const openedPopoverRef = useRef<HTMLElement | null>(null);
414
469
  const triggerRef = useRef<HTMLElement | null>(null);
470
+ // Context layers place a persistent inert marker at their real JSX position.
471
+ // Its parent tells us whether the final layer can stay inline or needs a
472
+ // corrective portal, including after the render call moves.
473
+ const sentinelRef = useRef<HTMLTemplateElement | null>(null);
474
+ const contextMountRef = useRef<ContextLayerMount | null>(null);
475
+ const [contextMount, setContextMount] = useState<ContextLayerMount | null>(
476
+ null,
477
+ );
478
+ // A show() that arrives before the final layer mounts is replayed when its
479
+ // popover ref attaches.
480
+ const pendingShowRef = useRef(false);
415
481
 
416
482
  // Ref mirrors isOpen for synchronous reads inside show/hide.
417
483
  // State drives re-renders; the ref lets the imperative calls avoid
418
484
  // stale-closure reads of the previous isOpen value.
419
485
  const isOpenRef = useRef(false);
420
486
 
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';
487
+ const showPopoverElement = useCallback((popover: HTMLElement) => {
488
+ // Finding infra-4: the Popover API is unsupported on Safari <17 and
489
+ // Firefox <125. On those browsers `showPopover` does not exist, so fall
490
+ // back to plain visibility instead of throwing.
491
+ if (typeof popover.showPopover === 'function') {
492
+ // The trigger is passed as the popover's invoker `source`: a layer
493
+ // hosted away from its trigger then still takes its sequential focus
494
+ // order (and its popover nesting) from the trigger rather than from its
495
+ // own DOM position. Browsers without the option ignore it.
496
+ popover.showPopover({source: triggerRef.current ?? undefined});
497
+ } else {
498
+ popover.style.display = 'block';
499
+ }
500
+ openedPopoverRef.current = popover;
501
+ }, []);
502
+
503
+ const isCurrentContextPopover = useCallback(
504
+ (popover: HTMLElement): boolean => {
505
+ if (mode !== 'context') {
506
+ return true;
434
507
  }
508
+ const mount = contextMountRef.current;
509
+ if (mount === null) {
510
+ return false;
511
+ }
512
+ const expectedParent =
513
+ mount.portalTarget ?? sentinelRef.current?.parentElement ?? null;
514
+ return popover.parentElement === expectedParent;
515
+ },
516
+ [mode],
517
+ );
518
+
519
+ const requestContextMount = useCallback(() => {
520
+ if (mode !== 'context') {
521
+ return;
522
+ }
523
+
524
+ const sentinel = sentinelRef.current;
525
+ const inlineParent = sentinel?.parentElement ?? null;
526
+ if (!sentinel || !inlineParent) {
527
+ return;
528
+ }
529
+
530
+ const portalTarget = resolveLayerPortalTarget(inlineParent);
531
+ const mount: ContextLayerMount = {
532
+ portalTarget,
533
+ portalStyle: portalTarget
534
+ ? readPortalWritingContext(sentinel, portalTarget)
535
+ : {},
536
+ };
537
+ contextMountRef.current = mount;
538
+ setContextMount(mount);
539
+ }, [mode]);
540
+
541
+ const clearContextMount = useCallback(() => {
542
+ if (mode !== 'context' || !lazyMount) {
543
+ return;
544
+ }
545
+ contextMountRef.current = null;
546
+ setContextMount(null);
547
+ }, [mode, lazyMount]);
548
+
549
+ const show = useCallback(() => {
550
+ // A context popover left over until React commits a previous hide must not
551
+ // be reopened. The synchronous mount ref is the source of truth.
552
+ const candidate = popoverRef.current;
553
+ const popover =
554
+ candidate && isCurrentContextPopover(candidate) ? candidate : null;
555
+ if (!popover) {
556
+ pendingShowRef.current = true;
557
+ requestContextMount();
558
+ return;
559
+ }
560
+ if (!isOpenRef.current) {
561
+ showPopoverElement(popover);
435
562
  isOpenRef.current = true;
436
563
  setIsOpen(true);
437
564
  onShow?.();
438
565
  }
439
- }, [onShow]);
566
+ }, [
567
+ onShow,
568
+ requestContextMount,
569
+ showPopoverElement,
570
+ isCurrentContextPopover,
571
+ ]);
440
572
 
441
573
  const hide = useCallback(() => {
574
+ pendingShowRef.current = false;
442
575
  if (isOpenRef.current) {
443
576
  const el = popoverRef.current;
444
577
  // See finding infra-4 note in `show`: mirror the same guard on hide so
@@ -450,11 +583,13 @@ export function useLayer(
450
583
  el.style.display = 'none';
451
584
  }
452
585
  }
586
+ openedPopoverRef.current = null;
453
587
  isOpenRef.current = false;
454
588
  setIsOpen(false);
455
589
  onHide?.();
456
590
  }
457
- }, [onHide]);
591
+ clearContextMount();
592
+ }, [onHide, clearContextMount]);
458
593
 
459
594
  // Ref for trigger element (context mode only)
460
595
  const ref: RefCallback<HTMLElement> | undefined =
@@ -488,12 +623,14 @@ export function useLayer(
488
623
  (e: Event) => {
489
624
  const toggleEvent = e as ToggleEvent;
490
625
  if (toggleEvent.newState === 'closed' && isOpenRef.current) {
626
+ openedPopoverRef.current = null;
491
627
  isOpenRef.current = false;
492
628
  setIsOpen(false);
493
629
  onHide?.();
630
+ clearContextMount();
494
631
  }
495
632
  },
496
- [onHide],
633
+ [onHide, clearContextMount],
497
634
  );
498
635
 
499
636
  // Ref callback for popover element — sets up the `toggle` listener.
@@ -531,8 +668,40 @@ export function useLayer(
531
668
  (el: HTMLElement | null) => {
532
669
  popoverRef.current = el;
533
670
  bindToggleListener(el, handleToggle);
671
+ if (el && pendingShowRef.current) {
672
+ pendingShowRef.current = false;
673
+ show();
674
+ } else if (
675
+ el &&
676
+ isOpenRef.current &&
677
+ openedPopoverRef.current !== el &&
678
+ isCurrentContextPopover(el)
679
+ ) {
680
+ // Changing a portal target remounts the popover. Preserve the logical
681
+ // open state without firing onShow again for the replacement element.
682
+ showPopoverElement(el);
683
+ }
684
+ },
685
+ [
686
+ handleToggle,
687
+ bindToggleListener,
688
+ show,
689
+ showPopoverElement,
690
+ isCurrentContextPopover,
691
+ ],
692
+ );
693
+
694
+ const sentinelRefCallback = useCallback(
695
+ (el: HTMLTemplateElement | null) => {
696
+ sentinelRef.current = el;
697
+ if (el && (!lazyMount || pendingShowRef.current || isOpenRef.current)) {
698
+ // The render call may have moved while the hook stayed mounted.
699
+ // Resolve again from the newly attached marker rather than reusing a
700
+ // portal target from its previous JSX position.
701
+ requestContextMount();
702
+ }
534
703
  },
535
- [handleToggle, bindToggleListener],
704
+ [lazyMount, requestContextMount],
536
705
  );
537
706
 
538
707
  // Re-bind when the handler identity changes while the element stays mounted,
@@ -556,6 +725,15 @@ export function useLayer(
556
725
  // Render function for context mode
557
726
  const renderContext = useCallback(
558
727
  (children: ReactNode, props?: ContextRenderProps) => {
728
+ // Keep the marker mounted after resolving the layer. Apart from giving
729
+ // us the real JSX parent on first show, this lets its ref report when a
730
+ // persistent hook's render call moves to a different host.
731
+ const sentinel = <template ref={sentinelRefCallback} />;
732
+
733
+ if (contextMount === null) {
734
+ return <>{sentinel}</>;
735
+ }
736
+
559
737
  const {
560
738
  placement = 'above',
561
739
  alignment = 'center',
@@ -598,10 +776,10 @@ export function useLayer(
598
776
  ? `${extraClassName} ${stylexResult.className ?? ''}`
599
777
  : stylexResult.className;
600
778
 
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 (
779
+ // The marker gives us the actual JSX parent without mounting arbitrary
780
+ // children there. Safe positions preserve the existing DOM order and
781
+ // cascade; unsafe positions use the nearest corrective portal target.
782
+ const layer = (
605
783
  <Container
606
784
  ref={popoverRefCallback}
607
785
  id={id}
@@ -609,14 +787,35 @@ export function useLayer(
609
787
  aria-label={ariaLabel}
610
788
  popover={lightDismiss ? 'auto' : 'manual'}
611
789
  className={combinedClassName}
612
- style={{...stylexResult.style, ...anchorStyle, ...extraStyle}}
790
+ style={{
791
+ ...stylexResult.style,
792
+ ...anchorStyle,
793
+ ...contextMount.portalStyle,
794
+ ...extraStyle,
795
+ }}
613
796
  onMouseEnter={onMouseEnter}
614
797
  onMouseLeave={onMouseLeave}>
615
798
  {children}
616
799
  </Container>
617
800
  );
801
+
802
+ return (
803
+ <>
804
+ {sentinel}
805
+ {contextMount.portalTarget
806
+ ? createPortal(layer, contextMount.portalTarget)
807
+ : layer}
808
+ </>
809
+ );
618
810
  },
619
- [anchorId, id, lightDismiss, popoverRefCallback],
811
+ [
812
+ anchorId,
813
+ contextMount,
814
+ id,
815
+ lightDismiss,
816
+ popoverRefCallback,
817
+ sentinelRefCallback,
818
+ ],
620
819
  );
621
820
 
622
821
  // Render function for fixed mode
@@ -78,6 +78,13 @@ export const docs = {
78
78
  name: 'MobileNavToggle',
79
79
  displayName: 'Mobile Nav Toggle',
80
80
  description: 'Hamburger button that opens/closes the mobile nav drawer. Reads open state from AppShell context automatically: does NOT accept isOpen or onOpenChange props. Renders nothing above the mobile breakpoint.',
81
+ // The toggle renders null unless AppShell mobile context reports an
82
+ // enabled mobile viewport — the default context outside AppShell never
83
+ // does, so the Properties preview was an empty stage. appShellMobile
84
+ // has the preview simulate that context instead (#4983).
85
+ playground: {
86
+ appShellMobile: true,
87
+ },
81
88
  props: [
82
89
  {
83
90
  name: 'children',
@@ -11,8 +11,13 @@
11
11
  * disabled state) so all nav-like components stay in sync — especially important
12
12
  * when TopNav items render inside MobileNav drawers alongside SideNav items.
13
13
  *
14
- * Individual components layer their own overrides (e.g. collapsed mode, indentation,
15
- * focus outlines) via stylex.props composition.
14
+ * Individual components layer their own overrides (e.g. collapsed mode,
15
+ * indentation) via stylex.props composition.
16
+ *
17
+ * The focus ring is not defined here. Compose
18
+ * `focusOutlineProps.focusVisible` (utils/focusOutline.stylex.ts) at the call
19
+ * site, on whichever element actually takes focus — in a split-action row that
20
+ * is the link and the toggle, not the row that contains them.
16
21
  */
17
22
 
18
23
  export type NavItemSize = 'sm' | 'md' | 'lg';
@@ -79,15 +84,36 @@ export const navItemStyles = stylex.create({
79
84
 
80
85
  /** Selected/active page indicator — deemphasized background, medium weight */
81
86
  selected: {
82
- backgroundColor: colorVars['--color-neutral'],
87
+ // Forced colors flatten `--color-neutral` away, leaving the current page
88
+ // unmarked; Highlight/HighlightText is the platform convention, as in
89
+ // ToggleButton and SegmentedControlItem. No `forced-color-adjust: none`
90
+ // here — a nav row is not a native control, so the keywords land without
91
+ // it, and it would inherit into `endContent` and pin a Badge's own fill.
92
+ backgroundColor: {
93
+ default: colorVars['--color-neutral'],
94
+ '@media (forced-colors: active)': 'Highlight',
95
+ },
96
+ color: {
97
+ default: null,
98
+ '@media (forced-colors: active)': 'HighlightText',
99
+ },
83
100
  fontWeight: fontWeightVars['--font-weight-medium'],
84
101
  ':hover': {
85
102
  '@media (hover: hover)': {
86
- backgroundColor: colorVars['--color-neutral'],
103
+ backgroundColor: {
104
+ default: colorVars['--color-neutral'],
105
+ // Nested, not a sibling `(hover: hover) and (forced-colors:
106
+ // active)` block: as siblings `item`'s hover overlay ties on
107
+ // specificity and wins on source order, erasing the fill.
108
+ '@media (forced-colors: active)': 'Highlight',
109
+ },
87
110
  },
88
111
  },
89
112
  ':active': {
90
- backgroundColor: colorVars['--color-neutral'],
113
+ backgroundColor: {
114
+ default: colorVars['--color-neutral'],
115
+ '@media (forced-colors: active)': 'Highlight',
116
+ },
91
117
  },
92
118
  },
93
119
 
@@ -51,6 +51,26 @@ const styles = stylex.create({
51
51
  opacity: 0,
52
52
  cursor: 'pointer',
53
53
  zIndex: 1,
54
+ minInlineSize: {
55
+ default: null,
56
+ '@media (pointer: coarse)': '24px',
57
+ },
58
+ minBlockSize: {
59
+ default: null,
60
+ '@media (pointer: coarse)': '24px',
61
+ },
62
+ insetBlockStart: {
63
+ default: null,
64
+ '@media (pointer: coarse)': '50%',
65
+ },
66
+ insetInlineStart: {
67
+ default: null,
68
+ '@media (pointer: coarse)': '50%',
69
+ },
70
+ transform: {
71
+ default: null,
72
+ '@media (pointer: coarse)': 'translate(-50%, -50%)',
73
+ },
54
74
  },
55
75
  inputDisabled: {
56
76
  cursor: 'not-allowed',
@@ -21,7 +21,7 @@ export const docs = {
21
21
  targets: [
22
22
  {className: 'astryx-side-nav', visualProps: ['mode']},
23
23
  {className: 'astryx-side-nav-heading'},
24
- {className: 'astryx-side-nav-item', visualProps: ['size'], states: ['selected']},
24
+ {className: 'astryx-side-nav-item', visualProps: ['size'], states: ['selected', 'disabled']},
25
25
  {className: 'astryx-side-nav-section'},
26
26
  ],
27
27
  },
@@ -85,7 +85,7 @@ export const docs = {
85
85
  {
86
86
  name: 'footerIcons',
87
87
  type: 'ReactNode',
88
- description: 'Footer icon bar.',
88
+ description: "Footer icon bar. The row cascades a 'sm' size to the interactive children it contains, so its icons and the built-in collapse button come out one height; pass an explicit size on a child to opt out.",
89
89
  slotElements: [
90
90
  {
91
91
  __element: 'Icon',
@@ -99,7 +99,7 @@ export const docs = {
99
99
  {
100
100
  name: 'collapsible',
101
101
  type: 'boolean | { defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapsedChange?: (isCollapsed: boolean) => void; hasButton?: boolean; buttonLabel?: string }',
102
- description: 'Enables collapse behavior. true for uncontrolled with default toggle button, or an object for controlled mode and advanced config (defaultIsCollapsed, isCollapsed + onCollapsedChange, hasButton, buttonLabel).',
102
+ description: 'Enables collapse behavior. true for uncontrolled with default toggle button, or an object for controlled mode and advanced config (defaultIsCollapsed, isCollapsed + onCollapsedChange, hasButton, buttonLabel). A controlled config can also be passed to a SideNavCollapseButton rendered outside this SideNav, so both share one state.',
103
103
  default: 'false',
104
104
  },
105
105
  {
@@ -111,7 +111,7 @@ export const docs = {
111
111
  {
112
112
  name: 'handleRef',
113
113
  type: 'Ref<SideNavImperativeCollapseHandle>',
114
- description: 'Imperative collapse handle for SideNavCollapseButton instances rendered outside this SideNav. Separate from `ref`, which continues to expose the root HTMLElement.',
114
+ description: 'Deprecated. Imperative collapse handle for SideNavCollapseButton instances rendered outside this SideNav; hand both the same controlled collapsible config instead. Separate from `ref`, which continues to expose the root HTMLElement.',
115
115
  },
116
116
  {
117
117
  name: 'xstyle',
@@ -131,6 +131,9 @@ export const docs = {
131
131
  bestPractices: [
132
132
  {guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
133
133
  {guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
134
+ {guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
135
+ {guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
136
+ {guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
134
137
  {guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
135
138
  {guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
136
139
  ],
@@ -150,6 +153,9 @@ export const docsZh = {
150
153
  bestPractices: [
151
154
  {guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
152
155
  {guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
156
+ {guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
157
+ {guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
158
+ {guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
153
159
  {guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
154
160
  {guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
155
161
  ],
@@ -169,6 +175,9 @@ export const docsDense = {
169
175
  bestPractices: [
170
176
  {guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
171
177
  {guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
178
+ {guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
179
+ {guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
180
+ {guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
172
181
  {guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
173
182
  {guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
174
183
  ],