@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
@@ -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
@@ -51,6 +51,48 @@ describe('parseInline', () => {
51
51
  expect(result).toEqual([{type: 'image', src: 'img.png', alt: 'alt'}]);
52
52
  });
53
53
 
54
+ it('rejects javascript: links as plain text (XSS prevention)', () => {
55
+ const result = parseInline('[click](javascript:alert(1))');
56
+ // Should be emitted as plain text, NOT as a link node
57
+ expect(result).toEqual([
58
+ {type: 'text', content: '[click](javascript:alert(1))'},
59
+ ]);
60
+ });
61
+
62
+ it('rejects javascript: with mixed case and whitespace (XSS prevention)', () => {
63
+ const result = parseInline('[click](JaVaScRiPt:alert(1))');
64
+ expect(result).toEqual([
65
+ {type: 'text', content: '[click](JaVaScRiPt:alert(1))'},
66
+ ]);
67
+ });
68
+
69
+ it('rejects vbscript: links (XSS prevention)', () => {
70
+ const result = parseInline('[click](vbscript:MsgBox(1))');
71
+ expect(result).toEqual([
72
+ {type: 'text', content: '[click](vbscript:MsgBox(1))'},
73
+ ]);
74
+ });
75
+
76
+ it('rejects data:text/html image src (XSS prevention)', () => {
77
+ const result = parseInline(
78
+ '![xss](data:text/html,<script>alert(1)</script>)',
79
+ );
80
+ expect(result).toEqual([
81
+ {
82
+ type: 'text',
83
+ content: '![xss](data:text/html,<script>alert(1)</script>)',
84
+ },
85
+ ]);
86
+ });
87
+
88
+ it('allows normal http/https links', () => {
89
+ const result = parseInline('[safe](https://example.com)');
90
+ expect(result[0].type).toBe('link');
91
+ if (result[0].type === 'link') {
92
+ expect(result[0].href).toBe('https://example.com');
93
+ }
94
+ });
95
+
54
96
  it('parses strikethrough', () => {
55
97
  const result = parseInline('~~deleted~~');
56
98
  expect(result[0].type).toBe('strikethrough');
@@ -727,6 +769,17 @@ describe('citation parsing', () => {
727
769
  });
728
770
  });
729
771
 
772
+ it('rejects <javascript:...> angle-bracket autolinks (XSS prevention)', () => {
773
+ const result = parseInline('see <javascript:alert(1)> ok', {
774
+ autolink: 'gfm',
775
+ });
776
+ // Should NOT produce a link node with javascript: href
777
+ const linkNodes = result.filter(
778
+ (n): n is Extract<typeof n, {type: 'link'}> => n.type === 'link',
779
+ );
780
+ expect(linkNodes).toHaveLength(0);
781
+ });
782
+
730
783
  it('parses <email> angle-bracket autolinks', () => {
731
784
  const result = parseInline('mail <user@example.com> please', {
732
785
  autolink: 'gfm',
@@ -275,7 +275,7 @@ function matchReferenceLink(
275
275
  // matches nothing.
276
276
  const label = rawLabel === '' ? linkText : rawLabel;
277
277
  const href = linkDefs.get(normalizeLinkLabel(label));
278
- if (href != null) {
278
+ if (href != null && isSafeUrl(href)) {
279
279
  return {
280
280
  node: {type: 'link', href, children: parseInlineImpl(linkText, opts)},
281
281
  end: labelClose + 1,
@@ -290,7 +290,7 @@ function matchReferenceLink(
290
290
  return null;
291
291
  }
292
292
  const href = linkDefs.get(normalizeLinkLabel(linkText));
293
- if (href == null) {
293
+ if (href == null || !isSafeUrl(href)) {
294
294
  return null;
295
295
  }
296
296
  return {
@@ -359,6 +359,31 @@ function isWordChar(ch: string | undefined): boolean {
359
359
  return /\w/.test(ch);
360
360
  }
361
361
 
362
+ // ---------------------------------------------------------------------------
363
+ // URL scheme sanitization
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /**
367
+ * Reject URLs with dangerous schemes (javascript:, vbscript:, data:) that
368
+ * could execute arbitrary code when rendered as link hrefs or image srcs.
369
+ * Returns true if the URL is safe to use, false otherwise.
370
+ */
371
+ function isSafeUrl(url: string): boolean {
372
+ // Trim and collapse whitespace/control chars that browsers tolerate but
373
+ // could bypass a naive prefix check (e.g. "java\nscript:alert(1)").
374
+ // eslint-disable-next-line no-control-regex -- control chars are the bypass
375
+ const normalized = url.replace(/[\x00-\x1f\x7f]/g, '').trim();
376
+ const lower = normalized.toLowerCase();
377
+ if (
378
+ lower.startsWith('javascript:') ||
379
+ lower.startsWith('vbscript:') ||
380
+ lower.startsWith('data:text/html')
381
+ ) {
382
+ return false;
383
+ }
384
+ return true;
385
+ }
386
+
362
387
  // ---------------------------------------------------------------------------
363
388
  // Inline parser
364
389
  // ---------------------------------------------------------------------------
@@ -478,11 +503,17 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
478
503
  if (altClose !== -1 && text[altClose + 1] === '(') {
479
504
  const srcClose = findClosingParen(text, altClose + 2);
480
505
  if (srcClose !== -1) {
481
- nodes.push({
482
- type: 'image',
483
- src: text.slice(altClose + 2, srcClose),
484
- alt: text.slice(i + 2, altClose),
485
- });
506
+ const src = text.slice(altClose + 2, srcClose);
507
+ if (!isSafeUrl(src)) {
508
+ // Dangerous scheme — emit as plain text.
509
+ nodes.push({type: 'text', content: text.slice(i, srcClose + 1)});
510
+ } else {
511
+ nodes.push({
512
+ type: 'image',
513
+ src,
514
+ alt: text.slice(i + 2, altClose),
515
+ });
516
+ }
486
517
  i = srcClose + 1;
487
518
  continue;
488
519
  }
@@ -515,11 +546,17 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
515
546
  if (textClose !== -1 && text[textClose + 1] === '(') {
516
547
  const urlClose = findClosingParen(text, textClose + 2);
517
548
  if (urlClose !== -1) {
518
- nodes.push({
519
- type: 'link',
520
- href: text.slice(textClose + 2, urlClose),
521
- children: parseInlineImpl(text.slice(i + 1, textClose), opts),
522
- });
549
+ const href = text.slice(textClose + 2, urlClose);
550
+ if (!isSafeUrl(href)) {
551
+ // Dangerous scheme — emit as plain text instead of a link.
552
+ nodes.push({type: 'text', content: text.slice(i, urlClose + 1)});
553
+ } else {
554
+ nodes.push({
555
+ type: 'link',
556
+ href,
557
+ children: parseInlineImpl(text.slice(i + 1, textClose), opts),
558
+ });
559
+ }
523
560
  i = urlClose + 1;
524
561
  continue;
525
562
  }
@@ -780,6 +817,10 @@ function scanAutolinksInText(text: string): AutolinkMatch[] {
780
817
  let m: RegExpExecArray | null;
781
818
  while ((m = re.exec(text)) !== null) {
782
819
  const url = m[1];
820
+ // Skip dangerous URL schemes (javascript:, vbscript:, data:text/html)
821
+ if (!isSafeUrl(url)) {
822
+ continue;
823
+ }
783
824
  matches.push({
784
825
  start: m.index,
785
826
  end: m.index + m[0].length,
@@ -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',