@astryxdesign/core 0.7.2-canary.fbc03f7 → 0.7.2-canary.fdd384f

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 (58) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +40 -1
  3. package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
  4. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  5. package/dist/CommandPalette/CommandPaletteInput.js +37 -5
  6. package/dist/Heading/Heading.d.ts.map +1 -1
  7. package/dist/Heading/Heading.js +4 -2
  8. package/dist/MobileNav/MobileNav.d.ts +9 -0
  9. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  10. package/dist/MobileNav/MobileNav.js +30 -3
  11. package/dist/SideNav/SideNavItem.d.ts +8 -1
  12. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  13. package/dist/SideNav/SideNavItem.js +71 -9
  14. package/dist/Text/Text.d.ts.map +1 -1
  15. package/dist/Text/Text.js +4 -2
  16. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  17. package/dist/TopNav/TopNavMegaMenu.js +3 -3
  18. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  19. package/dist/TopNav/TopNavMenu.js +3 -3
  20. package/dist/TreeList/TreeList.d.ts.map +1 -1
  21. package/dist/TreeList/TreeList.js +1 -0
  22. package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
  23. package/dist/TreeList/TreeListBranches.js +2 -2
  24. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  25. package/dist/TreeList/TreeListItem.js +57 -9
  26. package/dist/astryx.css +11 -1
  27. package/package.json +3 -3
  28. package/src/AppShell/AppShell.tsx +45 -1
  29. package/src/Breadcrumbs/Breadcrumbs.test.tsx +30 -1
  30. package/src/Card/Card.doc.mjs +1 -1
  31. package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
  32. package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
  33. package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
  34. package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +292 -0
  35. package/src/ContextMenu/ContextMenu.doc.mjs +2 -3
  36. package/src/ContextMenu/__tests__/ContextMenuAudit.a11y.chromium.spec.ts +438 -0
  37. package/src/DropdownMenu/DropdownMenu.test.tsx +41 -1
  38. package/src/DropdownMenu/DropdownMenuDivider.spec.md +192 -0
  39. package/src/DropdownMenu/__tests__/DropdownMenuDividerAudit.a11y.chromium.spec.ts +280 -0
  40. package/src/Heading/Heading.test.tsx +57 -0
  41. package/src/Heading/Heading.tsx +6 -2
  42. package/src/MobileNav/MobileNav.tsx +30 -3
  43. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +173 -15
  44. package/src/SideNav/SideNav.test.tsx +179 -0
  45. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  46. package/src/SideNav/SideNavItem.tsx +96 -17
  47. package/src/Text/Text.test.tsx +57 -0
  48. package/src/Text/Text.tsx +6 -2
  49. package/src/TopNav/TopNavMegaMenu.test.tsx +22 -0
  50. package/src/TopNav/TopNavMegaMenu.tsx +7 -3
  51. package/src/TopNav/TopNavMenu.test.tsx +19 -0
  52. package/src/TopNav/TopNavMenu.tsx +11 -3
  53. package/src/TreeList/TreeList.doc.mjs +1 -0
  54. package/src/TreeList/TreeList.test.tsx +327 -6
  55. package/src/TreeList/TreeList.tsx +5 -1
  56. package/src/TreeList/TreeListBranches.tsx +6 -0
  57. package/src/TreeList/TreeListItem.tsx +88 -13
  58. package/src/theme/derivedVarRegistry.test.ts +4 -0
@@ -13,13 +13,14 @@
13
13
  * - /packages/core/src/TreeList/TreeList.tsx
14
14
  * - /packages/cli/assets/templates/blocks/components/TreeList/ (showcase blocks)
15
15
  */
16
- import { useId, useMemo } from 'react';
16
+ import { useId, useMemo, useRef } from 'react';
17
17
  import * as stylex from '@stylexjs/stylex';
18
18
  import "../theme/tokens.stylex.js";
19
19
  import { colorVars, radiusVars, spacingVars, durationVars, easeVars, typeScaleVars } from "../theme/tokens.stylex.js";
20
20
  import { focusOutlineProps } from "../utils/focusOutline.stylex.js";
21
21
  import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js";
22
22
  import { usePressFeedback } from "../hooks/usePressFeedback.js";
23
+ import { hasInteractiveAncestor, useClickableContainer } from "../hooks/useClickableContainer.js";
23
24
  import { Icon } from "../Icon/index.js";
24
25
  import { mergeProps } from "../utils/index.js";
25
26
  import { useLinkComponent } from "../Link/useLinkComponent.js";
@@ -38,6 +39,7 @@ const styles = {
38
39
  kmVPX3: "x1717udv",
39
40
  kVAEAm: "x1n2onr6",
40
41
  kzqmXN: "xh8yej3",
42
+ "--_tree-focus-z": "x70muob x1sk5w8o",
41
43
  kI3sdo: "x1a2a7pz",
42
44
  $$css: true
43
45
  },
@@ -51,7 +53,10 @@ const styles = {
51
53
  kVAEAm: "x1n2onr6",
52
54
  kB7OPa: "x9f619",
53
55
  k9WMMc: "x1yc453h",
54
- keTefX: "x12vn87o",
56
+ $$css: true
57
+ },
58
+ focusLift: {
59
+ kY2c9j: "x6tapwc x3x83r7",
55
60
  $$css: true
56
61
  },
57
62
  interactive: {
@@ -177,6 +182,7 @@ export function TreeListItem({
177
182
  const labelId = useId();
178
183
  const descriptionId = useId();
179
184
  const LinkComponent = useLinkComponent();
185
+ const linkRef = useRef(null);
180
186
  const isInteractive = onClick != null || href != null;
181
187
  const handleToggle = useMemo(() => hasChildren && onToggle != null ? e => {
182
188
  e.stopPropagation();
@@ -188,8 +194,10 @@ export function TreeListItem({
188
194
  if (isDisabled) {
189
195
  return;
190
196
  }
191
- const el = e.target;
192
- if (el.closest('button, a, input, select, textarea')) {
197
+ // Controls inside the row, native or role-based, keep their own
198
+ // activation (FR7): the same rule link rows get from the shared
199
+ // enlarged-surface hook below.
200
+ if (hasInteractiveAncestor(e.target, e.currentTarget)) {
193
201
  return;
194
202
  }
195
203
  if (onClick != null) {
@@ -202,6 +210,38 @@ export function TreeListItem({
202
210
  return undefined;
203
211
  }, [onClick, hasChildren, onToggle, id, isDisabled]);
204
212
 
213
+ // A link row (an `href` and no other row action) is an enlarged surface for
214
+ // its link: the indent is part of the row, so a click anywhere in it must
215
+ // follow the link, as Enter already does. Built on the shared
216
+ // enlarged-surface hook (family:navigation-destinations), which skips
217
+ // clicks on nested controls, native or role-based, and text selections.
218
+ // - Clicks are delegated: the hook re-dispatches the click on the anchor
219
+ // with its modifier keys, so the link component and the browser handle
220
+ // same-tab, new-tab and new-window activation, and stops the original, so
221
+ // ancestors see one click per press.
222
+ // - Middle-click has no synthetic equivalent, so it takes the hook's href
223
+ // path, which opens the destination in a new tab after the shared
224
+ // navigation rule.
225
+ // The container ref is attached to link rows only, so every other row stays
226
+ // a plain element and both hook instances stay inert on it.
227
+ const forwardsToLink = href != null && handleClick == null;
228
+ const linkRowRef = useRef(null);
229
+ const {
230
+ onClick: delegateToLink
231
+ } = useClickableContainer({
232
+ containerRef: linkRowRef,
233
+ interactiveRef: linkRef,
234
+ disabled: isDisabled || !forwardsToLink
235
+ });
236
+ const {
237
+ onMouseUp: openLinkOnMiddleClick
238
+ } = useClickableContainer({
239
+ containerRef: linkRowRef,
240
+ href,
241
+ target,
242
+ disabled: isDisabled || !forwardsToLink
243
+ });
244
+
205
245
  // Per-level indent distance. The per-level step is the public, themeable
206
246
  // `--tree-list-indent` lever (default `--spacing-4`, set on the tree-list
207
247
  // root). A leaf adds a fixed chevron-column offset (chevron width + gap) so
@@ -211,8 +251,8 @@ export function TreeListItem({
211
251
  // every row sits flush, like a parent at that level. That offset is tied to
212
252
  // the chevron's own dimensions, not the indent step, so it does not scale
213
253
  // with the lever. Published as the private `--_tree-indent` and consumed by
214
- // `contentWrapper`'s stylesheet `margin-inline-start` (kept out of the inline
215
- // style so the theme layer can override it — see #4308).
254
+ // `indentedContent`'s stylesheet `padding-inline-start`; the step stays
255
+ // themeable through `--tree-list-indent` (see #4308).
216
256
  const reservesChevronColumn = !hasChildren && hasExpandableItems;
217
257
  const indentDistance = reservesChevronColumn ? `calc(${nestedLevel} * var(--tree-list-indent) + ${spacingVars['--spacing-4']} + ${spacingVars['--spacing-2']})` : `calc(${nestedLevel} * var(--tree-list-indent))`;
218
258
  const indentStyle = {
@@ -308,6 +348,7 @@ export function TreeListItem({
308
348
  },
309
349
  children: startContent
310
350
  }), href != null ? /*#__PURE__*/_jsx(LinkComponent, {
351
+ ref: linkRef,
311
352
  href: href,
312
353
  target: target,
313
354
  "aria-disabled": isDisabled || undefined,
@@ -389,7 +430,7 @@ export function TreeListItem({
389
430
  density,
390
431
  selected: isSelected ? 'selected' : null,
391
432
  disabled: isDisabled ? 'disabled' : null
392
- }), isInteractive || hasChildren && onClick == null ? focusOutlineProps.focusWithinOrPublished(styles.contentWrapper, densityStyles[density], styles.interactive, interactionOverlayStyles.backgroundImage, isDisabled && styles.disabled, isSelected && styles.selected, xstyle) : stylex.props(styles.contentWrapper, densityStyles[density], isDisabled && styles.disabled, isSelected && styles.selected, xstyle),
433
+ }), isInteractive || hasChildren && onClick == null ? focusOutlineProps.focusWithinOrPublished(styles.contentWrapper, styles.focusLift, densityStyles[density], styles.interactive, interactionOverlayStyles.backgroundImage, isDisabled && styles.disabled, isSelected && styles.selected, xstyle) : stylex.props(styles.contentWrapper, densityStyles[density], isDisabled && styles.disabled, isSelected && styles.selected, xstyle),
393
434
  // Consumer row props are merged last so useContainerReveal can
394
435
  // publish both its classes and inline custom properties. Seed the
395
436
  // private indent first, then preserve the standard inline-style
@@ -398,8 +439,15 @@ export function TreeListItem({
398
439
  ...indentStyle,
399
440
  ...style
400
441
  }),
401
- onClick: handleClick,
402
- children: innerContent
442
+ ref: forwardsToLink ? linkRowRef : undefined,
443
+ onClick: forwardsToLink ? delegateToLink : handleClick,
444
+ onMouseUp: forwardsToLink ? openLinkOnMiddleClick : undefined,
445
+ children: /*#__PURE__*/_jsx("div", {
446
+ ...{
447
+ className: "x78zum5 x98rzlu xeuugli x1h91t0o x14kbac9 xw2xrtw"
448
+ },
449
+ children: innerContent
450
+ })
403
451
  })
404
452
  }), isExpanded && renderedChildren != null && /*#__PURE__*/_jsx("ul", {
405
453
  role: "group",
package/dist/astryx.css CHANGED
@@ -174,6 +174,7 @@
174
174
  .x1ur4yav{--_thumbnail-hit-inset:0px}
175
175
  .x87gnkx{--_toast-slide-y:calc(-1 * var(--spacing-2))}
176
176
  .x1heqljd{--_toast-slide-y:var(--spacing-2)}
177
+ .x70muob{--_tree-focus-z:auto}
177
178
  .x16bblx1{--button-focus-offset:var(--focus-outline-offset)}
178
179
  .x1v15ycx{--button-icon-only-aspect:1 / 1}
179
180
  .x1mtvhk1{--color-syntax-background:transparent}
@@ -416,6 +417,7 @@
416
417
  .x1mjsb9v:focus-within{--_reveal-delay:0s,0s}
417
418
  .xi33vf3:focus-within{--_reveal-opacity:1}
418
419
  .xoa21yc:focus-within{--_reveal-position:static}
420
+ .x1sk5w8o:focus-visible{--_tree-focus-z:2}
419
421
  .x11woleg:focus-visible{--indicator-color:var(--color-accent)}
420
422
  .x1i79oho.x1i79oho:hover:where(:not(:disabled,[aria-disabled="true"])){--indicator-color:var(--resize-hint-gate,var(--color-accent))}
421
423
  .x1qs65gq:has(> [data-size="lg"]){--_field-status-overlap:calc(var(--size-element-lg) / 2)}
@@ -683,6 +685,7 @@
683
685
  .xpqajaz:not(#\#):not(#\#):not(#\#){align-items:end}
684
686
  .xuk3077:not(#\#):not(#\#):not(#\#){align-items:flex-end}
685
687
  .x1cy8zhl:not(#\#):not(#\#):not(#\#){align-items:flex-start}
688
+ .x1h91t0o:not(#\#):not(#\#):not(#\#){align-items:inherit}
686
689
  .xaz6w31:not(#\#):not(#\#):not(#\#){align-items:last baseline}
687
690
  .x7a106z:not(#\#):not(#\#):not(#\#){align-items:start}
688
691
  .x1qjc9v5:not(#\#):not(#\#):not(#\#){align-items:stretch}
@@ -1141,7 +1144,6 @@
1141
1144
  .xpdk1p0:not(#\#):not(#\#):not(#\#){margin-inline-start:calc(calc(var(--spacing-4) + max(var(--astryx-layer-inset-inline-start, 0px), var(--astryx-layer-inset-inline-end, 0px))));margin-inline-start:calc(calc(max(var(--spacing-4), env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)) + max(var(--astryx-layer-inset-inline-start, 0px), var(--astryx-layer-inset-inline-end, 0px))))}
1142
1145
  .xch736x:not(#\#):not(#\#):not(#\#){margin-inline-start:max(var(--container-padding-inline-start,0px) - var(--_item-inset-inline),0px)}
1143
1146
  .x13hpdyo:not(#\#):not(#\#):not(#\#){margin-inline-start:var(--_avatar-group-overlap)}
1144
- .x12vn87o:not(#\#):not(#\#):not(#\#){margin-inline-start:var(--_tree-indent,0px)}
1145
1147
  .xladpz9:not(#\#):not(#\#):not(#\#){margin-inline-start:var(--spacing-0-5)}
1146
1148
  .x17lnmll:not(#\#):not(#\#):not(#\#){margin-inline-start:var(--spacing-1-5)}
1147
1149
  .x11g1kdw:not(#\#):not(#\#):not(#\#){margin-inline-start:var(--spacing-1)}
@@ -1228,6 +1230,7 @@
1228
1230
  .xxjme2g:not(#\#):not(#\#):not(#\#){padding-inline-start:max(var(--container-padding-inline-start,0px),calc(100cqi - var(--layout-alignment-width) + var(--container-padding-inline-start,0px)))}
1229
1231
  .xua885n:not(#\#):not(#\#):not(#\#){padding-inline-start:max(var(--container-padding-inline-start,0px),var(--_item-inset-inline))}
1230
1232
  .xf4btlh:not(#\#):not(#\#):not(#\#){padding-inline-start:max(var(--spacing-4),env(safe-area-inset-left,0px))}
1233
+ .xw2xrtw:not(#\#):not(#\#):not(#\#){padding-inline-start:var(--_tree-indent,0px)}
1231
1234
  .xqmdmw:not(#\#):not(#\#):not(#\#){padding-inline-start:var(--astryx-dialog-padding-inline-start,var(--astryx-dialog-padding-inline,var(--astryx-dialog-padding,max(var(--spacing-4),env(safe-area-inset-left,0px)))))}
1232
1235
  .x1jni19p:not(#\#):not(#\#):not(#\#){padding-inline-start:var(--astryx-number-input-padding-inline-start,var(--astryx-number-input-padding-inline,var(--astryx-number-input-padding,var(--spacing-2))))}
1233
1236
  .x1c35znw:not(#\#):not(#\#):not(#\#){padding-inline-start:var(--container-padding-inline-start)}
@@ -1355,6 +1358,8 @@
1355
1358
  .xsqj5wx:not(#\#):not(#\#):not(#\#){transform:var(--x-transform)}
1356
1359
  .xd00j3c:not(#\#):not(#\#):not(#\#){transition-behavior:allow-discrete}
1357
1360
  .x5h36tt:not(#\#):not(#\#):not(#\#){transition-delay:.15s}
1361
+ .xf1rc6:not(#\#):not(#\#):not(#\#){transition-delay:0s,0s}
1362
+ .xostmdb:not(#\#):not(#\#):not(#\#){transition-delay:0s,var(--duration-medium)}
1358
1363
  .x1ahifba:not(#\#):not(#\#):not(#\#){transition-delay:0s}
1359
1364
  .x4avkj2:not(#\#):not(#\#):not(#\#){transition-delay:var(--_fade-delay,0s)}
1360
1365
  .x1rq8cwj:not(#\#):not(#\#):not(#\#){transition-delay:var(--_reveal-delay,0s,0s)}
@@ -1370,6 +1375,7 @@
1370
1375
  .x1tw3moq:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast),0s}
1371
1376
  .xuedmi6:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast)}
1372
1377
  .xgneliz:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium-min)}
1378
+ .x1eizr8u:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium),0s}
1373
1379
  .x80gvsz:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium)}
1374
1380
  .x1eaenvl:not(#\#):not(#\#):not(#\#){transition-property:background-color,border-color,color}
1375
1381
  .xts7igz:not(#\#):not(#\#):not(#\#){transition-property:background-color,border-color}
@@ -1392,6 +1398,7 @@
1392
1398
  .x1mpt4pi:not(#\#):not(#\#):not(#\#){transition-property:color,text-decoration}
1393
1399
  .xt3l3uh:not(#\#):not(#\#):not(#\#){transition-property:color}
1394
1400
  .xrgi2yo:not(#\#):not(#\#):not(#\#){transition-property:display}
1401
+ .x6kj92:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows,overflow}
1395
1402
  .xjshnde:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows,padding}
1396
1403
  .x1qn9uv2:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows}
1397
1404
  .x157bb6t:not(#\#):not(#\#):not(#\#){transition-property:mask-image}
@@ -1446,6 +1453,7 @@
1446
1453
  .xzkaem6:not(#\#):not(#\#):not(#\#){z-index:3}
1447
1454
  .x6cuj84:not(#\#):not(#\#):not(#\#){z-index:500}
1448
1455
  .x1q2oy4v:not(#\#):not(#\#):not(#\#){z-index:9999}
1456
+ .x6tapwc:not(#\#):not(#\#):not(#\#){z-index:var(--_tree-focus-z,auto)}
1449
1457
  .x1wphrdb[data-astryx-press="fading"]:not(#\#):not(#\#):not(#\#){--_press-overlay-transition:0s}
1450
1458
  .xbb2ue6[data-astryx-press="on"]:not(#\#):not(#\#):not(#\#){--_press-overlay-transition:0s}
1451
1459
  .x11z29ks[data-astryx-press="on"]:not(#\#):not(#\#):not(#\#){--astryx-press-alpha:1}
@@ -1541,6 +1549,7 @@
1541
1549
  .xxdn8bs:has(> div > .astryx-layout-content):not(#\#):not(#\#):not(#\#){padding-inline-end:max(0px,calc((100% - var(--layout-content-width)) / 2))}
1542
1550
  .xmnmr5y:has(.astryx-nav-icon):not(#\#):not(#\#):not(#\#){padding-inline-start:0}
1543
1551
  .x1vvd0s7:has(> div > .astryx-layout-content):not(#\#):not(#\#):not(#\#){padding-inline-start:max(0px,calc((100% - var(--layout-content-width)) / 2))}
1552
+ .x3x83r7:has(:focus-visible):not(#\#):not(#\#):not(#\#){z-index:2}
1544
1553
  .xbw113y:dir(rtl):not(#\#):not(#\#):not(#\#){transform:translateX(-100%)}
1545
1554
  .x1eqdj8u:dir(rtl):not(#\#):not(#\#):not(#\#){transform:translateX(100%)}
1546
1555
  .x1xrp5p4:first-child:not(#\#):not(#\#):not(#\#){border-end-start-radius:var(--radius-element)}
@@ -1602,6 +1611,7 @@
1602
1611
  @media (pointer: coarse){.x1bjav06.x1bjav06:not(#\#):not(#\#):not(#\#){pointer-events:none}}
1603
1612
  @media (prefers-reduced-motion: reduce){.xg83hb1.xg83hb1:not(#\#):not(#\#):not(#\#){scroll-behavior:auto}}
1604
1613
  @media (forced-colors: active){.xrih18v.xrih18v:not(#\#):not(#\#):not(#\#){scrollbar-color:auto}}
1614
+ @media (prefers-reduced-motion: reduce){.x1k6wr6r.x1k6wr6r:not(#\#):not(#\#):not(#\#){transition-delay:0s,0s}}
1605
1615
  @media (prefers-reduced-motion: reduce){.xwgk2z2.xwgk2z2:not(#\#):not(#\#):not(#\#){transition-delay:var(--_reveal-delay-reduced,0s,0s)}}
1606
1616
  @media (prefers-reduced-motion: reduce){.xsagj69.xsagj69:not(#\#):not(#\#):not(#\#){transition-duration:.01ms}}
1607
1617
  @media (prefers-reduced-motion: reduce){.xzg1mie.xzg1mie:not(#\#):not(#\#):not(#\#){transition-duration:.01s}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.2-canary.fbc03f7",
3
+ "version": "0.7.2-canary.fdd384f",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,8 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
- "@astryxdesign/a11y-spec": "0.7.2-canary.fbc03f7",
796
- "@astryxdesign/cli": "0.7.2-canary.fbc03f7",
795
+ "@astryxdesign/a11y-spec": "0.7.2-canary.fdd384f",
796
+ "@astryxdesign/cli": "0.7.2-canary.fdd384f",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -43,6 +43,7 @@ import {LayoutHeader} from '../Layout/LayoutHeader';
43
43
  import {LayoutPanel} from '../Layout/LayoutPanel';
44
44
  import {LayoutContent} from '../Layout/LayoutContent';
45
45
  import {MobileNavToggle} from '../MobileNav/MobileNavToggle';
46
+ import {MAX_CLOSE_DELAY_MS as MOBILE_NAV_MAX_CLOSE_DELAY_MS} from '../MobileNav/MobileNav';
46
47
  import {SideNavRenderContext} from '../SideNav/SideNavRenderContext';
47
48
  import {TopNavRenderContext} from '../TopNav/TopNavRenderContext';
48
49
  import {TopNavMobileContentContext} from '../TopNav/TopNavMobileContentContext';
@@ -81,6 +82,20 @@ const ActivityWrapper = HasActivity
81
82
 
82
83
  const MAIN_CONTENT_ID = 'astryx-app-shell-main';
83
84
 
85
+ /**
86
+ * Extra time, on top of MobileNav's own worst-case close delay, that
87
+ * AppShell waits before flipping its `Activity` boundary to `'hidden'`.
88
+ *
89
+ * React's `Activity` hides its subtree with `display: none !important`
90
+ * synchronously in the same commit as the mode flip, and re-runs the
91
+ * cleanup of every effect inside it — which would otherwise clear
92
+ * MobileNav's own scheduled `dialog.close()` before it fires, or cut off
93
+ * its slide-out CSS transition before it plays (#5701). Waiting strictly
94
+ * longer than MobileNav's own cap, rather than the same duration, means
95
+ * this never turns into a race decided by browser timer-firing order.
96
+ */
97
+ const ACTIVITY_HIDE_BUFFER_MS = 50;
98
+
84
99
  // =============================================================================
85
100
  // Types
86
101
  // =============================================================================
@@ -525,6 +540,34 @@ export function AppShell({
525
540
  const [uncontrolledMobileOpen, setUncontrolledMobileOpen] = useState(false);
526
541
  const isMobileNavOpen = mobileNavConfig?.isOpen ?? uncontrolledMobileOpen;
527
542
 
543
+ // Holds the mobile nav's `Activity` boundary visible for a beat after a
544
+ // close, so MobileNav's own close effect gets a real chance to run its
545
+ // slide-out transition and schedule its delayed `dialog.close()` before
546
+ // Activity hides the subtree (#5701). Only the CLOSE is deferred: opening
547
+ // reads `isMobileNavOpen` directly below, so a controlled open is visible in
548
+ // the commit that carries it instead of one effect later.
549
+ const [isMobileNavCloseHeld, setIsMobileNavCloseHeld] =
550
+ useState(isMobileNavOpen);
551
+ // Arm the hold during render, from the open state itself, so no Effect has
552
+ // to copy props into state; the Effect below only owns the release timer.
553
+ if (isMobileNavOpen && !isMobileNavCloseHeld) {
554
+ setIsMobileNavCloseHeld(true);
555
+ }
556
+ useEffect(() => {
557
+ if (!HasActivity || isMobileNavOpen) {
558
+ return;
559
+ }
560
+ const delayMs = window.matchMedia('(prefers-reduced-motion: reduce)')
561
+ .matches
562
+ ? 0
563
+ : MOBILE_NAV_MAX_CLOSE_DELAY_MS + ACTIVITY_HIDE_BUFFER_MS;
564
+ const timeout = setTimeout(() => {
565
+ setIsMobileNavCloseHeld(false);
566
+ }, delayMs);
567
+ return () => clearTimeout(timeout);
568
+ }, [isMobileNavOpen]);
569
+ const isMobileNavActivityVisible = isMobileNavOpen || isMobileNavCloseHeld;
570
+
528
571
  const mobileNavOnOpenChange = mobileNavConfig?.onOpenChange;
529
572
  const setMobileNavOpen = useCallback(
530
573
  (open: boolean) => {
@@ -864,7 +907,8 @@ export function AppShell({
864
907
  !mobileNavDisabled &&
865
908
  mobileNavReactNode == null &&
866
909
  !mobileNavConfigContent && (
867
- <ActivityWrapper mode={isMobileNavOpen ? 'visible' : 'hidden'}>
910
+ <ActivityWrapper
911
+ mode={isMobileNavActivityVisible ? 'visible' : 'hidden'}>
868
912
  {/* SideNav drawer — always mounted so presence detection works.
869
913
  Hidden when TopNav owns the drawer (combined mode passes
870
914
  sideNav via TopNav's mobile content context instead). */}
@@ -7,12 +7,13 @@ import {Breadcrumbs} from './Breadcrumbs';
7
7
  import {BreadcrumbItem} from './BreadcrumbItem';
8
8
  import {
9
9
  BreadcrumbMenuItem,
10
+ BreadcrumbMenuDivider,
10
11
  BreadcrumbMenuGroup,
11
12
  BreadcrumbMenuCheckboxItem,
12
13
  BreadcrumbMenuRadioGroup,
13
14
  BreadcrumbMenuRadioItem,
14
15
  } from './index';
15
- import type {BreadcrumbMenuOption} from './index';
16
+ import type {BreadcrumbMenuDividerProps, BreadcrumbMenuOption} from './index';
16
17
  import {LinkProvider} from '../Link/LinkProvider';
17
18
 
18
19
  function CustomLink({
@@ -771,6 +772,34 @@ describe('BreadcrumbItem menu', () => {
771
772
  expect(separators).toHaveLength(3);
772
773
  });
773
774
 
775
+ it('renders the separator and props types through the BreadcrumbMenuDivider aliases', async () => {
776
+ const user = userEvent.setup();
777
+ const dividerProps: BreadcrumbMenuDividerProps = {
778
+ 'data-testid': 'breadcrumb-menu-divider',
779
+ };
780
+ render(
781
+ <Breadcrumbs>
782
+ <BreadcrumbItem
783
+ menu={
784
+ <>
785
+ <BreadcrumbMenuItem label="Overview" />
786
+ <BreadcrumbMenuDivider {...dividerProps} />
787
+ <BreadcrumbMenuItem label="Settings" />
788
+ </>
789
+ }>
790
+ Project
791
+ </BreadcrumbItem>
792
+ <BreadcrumbItem isCurrent>Current</BreadcrumbItem>
793
+ </Breadcrumbs>,
794
+ );
795
+ await user.click(screen.getByRole('button', {name: 'Project'}));
796
+
797
+ expect(screen.getByTestId('breadcrumb-menu-divider')).toHaveAttribute(
798
+ 'role',
799
+ 'separator',
800
+ );
801
+ });
802
+
774
803
  it('carries the group semantics through the BreadcrumbMenuGroup alias', async () => {
775
804
  // The alias must bring the semantics, not just the component: a group
776
805
  // that renders but is not named leaves a screen reader with a run of
@@ -83,7 +83,7 @@ export const docs = {
83
83
  code: `
84
84
  import {Card} from '@astryxdesign/core/Card';
85
85
  import {Grid} from '@astryxdesign/core/Grid';
86
- import {VStack} from '@astryxdesign/core/Layout';
86
+ import {VStack} from '@astryxdesign/core/VStack';
87
87
  import {Heading} from '@astryxdesign/core/Heading';
88
88
  import {Text} from '@astryxdesign/core/Text';
89
89
 
@@ -8,12 +8,19 @@
8
8
  */
9
9
 
10
10
  import {createRef} from 'react';
11
- import {describe, it, expect, vi} from 'vitest';
12
- import {render, screen, fireEvent} from '@testing-library/react';
11
+ import {describe, it, expect, vi, beforeEach} from 'vitest';
12
+ import {render, screen, fireEvent, act} from '@testing-library/react';
13
+ import * as stylex from '@stylexjs/stylex';
13
14
  import {CommandPaletteInput} from './CommandPaletteInput';
14
15
  import {CommandPaletteContext} from './CommandPaletteContext';
15
16
  import {Dialog} from '../Dialog';
16
17
  import type {CommandPaletteContextValue} from './CommandPaletteContext';
18
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
19
+ import {__resetInteractionModalityForTest} from '../utils/interactionModality';
20
+
21
+ beforeEach(() => {
22
+ __resetInteractionModalityForTest();
23
+ });
17
24
 
18
25
  function makeContext(
19
26
  overrides: Partial<CommandPaletteContextValue> = {},
@@ -279,6 +286,45 @@ describe('CommandPaletteInput', () => {
279
286
  },
280
287
  );
281
288
 
289
+ it('draws the shared ring only when keyboard navigation acquired focus', () => {
290
+ render(<CommandPaletteInput hasAutoFocus={false} />);
291
+ const input = screen.getByRole('searchbox');
292
+ const ringClasses = stylex
293
+ .props(focusOutlineStyles.focusVisible)
294
+ .className!.split(' ');
295
+ const isRinged = () =>
296
+ ringClasses.every(className => input.classList.contains(className));
297
+
298
+ fireEvent.pointerDown(input);
299
+ fireEvent.focus(input);
300
+ expect(isRinged()).toBe(false);
301
+
302
+ fireEvent.blur(input);
303
+ fireEvent.keyDown(input, {key: 'Tab'});
304
+ fireEvent.focus(input);
305
+ expect(isRinged()).toBe(true);
306
+ });
307
+
308
+ it('preserves caller focus and blur handlers while tracking modality', () => {
309
+ const onFocus = vi.fn();
310
+ const onBlur = vi.fn();
311
+ render(
312
+ <CommandPaletteInput
313
+ hasAutoFocus={false}
314
+ onFocus={onFocus}
315
+ onBlur={onBlur}
316
+ />,
317
+ );
318
+ const input = screen.getByRole('searchbox');
319
+
320
+ fireEvent.keyDown(input, {key: 'Tab'});
321
+ fireEvent.focus(input);
322
+ fireEvent.blur(input);
323
+
324
+ expect(onFocus).toHaveBeenCalledTimes(1);
325
+ expect(onBlur).toHaveBeenCalledTimes(1);
326
+ });
327
+
282
328
  it('does not schedule focus when hasAutoFocus is false', async () => {
283
329
  const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus');
284
330
  render(<CommandPaletteInput hasAutoFocus={false} />);
@@ -481,8 +527,11 @@ describe('CommandPaletteInput popup context', () => {
481
527
  </CommandPaletteContext>,
482
528
  );
483
529
 
484
- await vi.waitFor(() => {
485
- expect(screen.getByRole('combobox')).toHaveFocus();
530
+ await act(async () => {
531
+ await new Promise<void>(resolve =>
532
+ requestAnimationFrame(() => resolve()),
533
+ );
486
534
  });
535
+ expect(screen.getByRole('combobox')).toHaveFocus();
487
536
  });
488
537
  });
@@ -13,7 +13,7 @@
13
13
  * - /packages/cli/assets/templates/blocks/components/CommandPalette/ (showcase blocks)
14
14
  */
15
15
 
16
- import {useCallback, useEffect, useRef, type ReactNode} from 'react';
16
+ import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
17
17
  import * as stylex from '@stylexjs/stylex';
18
18
  import {Icon} from '../Icon';
19
19
  import {Spinner} from '../Spinner';
@@ -28,6 +28,11 @@ import {useCommandPaletteContext} from './CommandPaletteContext';
28
28
  import {useDialogContext} from '../Dialog/DialogContext';
29
29
  import type {BaseProps} from '../BaseProps';
30
30
  import {themeProps} from '../utils/themeProps';
31
+ import {focusOutlineProps} from '../utils/focusOutline.stylex';
32
+ import {
33
+ getInteractionModality,
34
+ useInteractionModalityTracking,
35
+ } from '../utils/interactionModality';
31
36
  import {useTranslator} from '../i18n';
32
37
 
33
38
  import {useMergedRefs} from '../hooks/useMergedRefs';
@@ -71,7 +76,6 @@ const styles = stylex.create({
71
76
  minWidth: 0,
72
77
  borderWidth: 0,
73
78
  borderStyle: 'none',
74
- outline: 'none',
75
79
  backgroundColor: 'transparent',
76
80
  color: colorVars['--color-text-primary'],
77
81
  fontFamily: typographyVars['--font-family-body'],
@@ -169,6 +173,8 @@ export function CommandPaletteInput({
169
173
  endContent,
170
174
  onChange,
171
175
  onKeyDown,
176
+ onFocus,
177
+ onBlur,
172
178
  ref,
173
179
  xstyle,
174
180
  className,
@@ -184,6 +190,26 @@ export function CommandPaletteInput({
184
190
  const hasListbox = ctx ? ctx.selectableItems.length > 0 : true;
185
191
  const dialogContext = useDialogContext();
186
192
  const inputRef = useRef<HTMLInputElement>(null);
193
+ const [isKeyboardFocus, setIsKeyboardFocus] = useState(false);
194
+
195
+ // Text-entry controls match :focus-visible after pointer focus in Chromium.
196
+ // Keep the shared selector, but narrow its ring to focus acquired through
197
+ // keyboard navigation so a click or pointer-opened palette stays unringed.
198
+ useInteractionModalityTracking();
199
+ const handleFocus = useCallback(
200
+ (event: React.FocusEvent<HTMLInputElement>) => {
201
+ setIsKeyboardFocus(getInteractionModality() === 'keyboard');
202
+ onFocus?.(event);
203
+ },
204
+ [onFocus],
205
+ );
206
+ const handleBlur = useCallback(
207
+ (event: React.FocusEvent<HTMLInputElement>) => {
208
+ setIsKeyboardFocus(false);
209
+ onBlur?.(event);
210
+ },
211
+ [onBlur],
212
+ );
187
213
 
188
214
  // Use context values as fallback
189
215
  const value = controlledValue ?? ctx?.search;
@@ -259,7 +285,9 @@ export function CommandPaletteInput({
259
285
  <input
260
286
  {...props}
261
287
  ref={useMergedRefs(ref, inputRef)}
262
- {...stylex.props(styles.input)}
288
+ {...(isKeyboardFocus
289
+ ? focusOutlineProps.focusVisible(styles.input)
290
+ : focusOutlineProps.suppressed(styles.input))}
263
291
  type="text"
264
292
  role={ctx ? 'combobox' : standaloneRole}
265
293
  aria-expanded={
@@ -300,6 +328,8 @@ export function CommandPaletteInput({
300
328
  onChange?.(e);
301
329
  }}
302
330
  onKeyDown={handleKeyDown}
331
+ onFocus={handleFocus}
332
+ onBlur={handleBlur}
303
333
  />
304
334
  {(ctx?.isBusy || hasEndContent) && (
305
335
  <span {...stylex.props(styles.end)}>