@astryxdesign/core 0.7.2-canary.411cbed → 0.7.2-canary.4787e3f

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.
@@ -1 +1 @@
1
- {"version":3,"file":"Heading.d.ts","sourceRoot":"","sources":["../../src/Heading/Heading.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE7D,OAAO,KAAK,EACV,SAAS,EACT,WAAW,EACX,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACX,MAAM,gBAAgB,CAAC;AAkBxB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,UAAU,CAAC;AAG7C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,SAAS,CAAC;AAM5C;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAEjD;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,cAAc,CAAC;AAQ/C,MAAM,WAAW,YAAa,SAAQ,IAAI,CACxC,SAAS,CAAC,kBAAkB,CAAC,EAC7B,UAAU,CACX;IACC,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACpC;;;OAGG;IACH,KAAK,EAAE,YAAY,CAAC;IAEpB;;;;;;;;;;;;;OAaG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;IAEnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IAEpB;;;;;;;;;;;OAWG;IACH,kBAAkB,CAAC,EAAE,YAAY,CAAC;IAElC;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IAE9C;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;OAEG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;CACrB;AAWD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,OAAO,CAAC,EACtB,KAAK,EACL,IAAI,EACJ,MAAM,EACN,kBAAkB,EAClB,KAAiB,EACjB,OAAiB,EACjB,QAAY,EACZ,kBAAyB,EACzB,SAAS,EACT,QAAQ,EACR,OAAiB,EACjB,UAAkB,EAClB,gBAAwB,EACxB,MAAM,EACN,SAAS,EACT,KAAK,EACL,QAAQ,EACR,GAAG,EACH,GAAG,KAAK,EACT,EAAE,YAAY,+BAkGd;yBAtHe,OAAO"}
1
+ {"version":3,"file":"Heading.d.ts","sourceRoot":"","sources":["../../src/Heading/Heading.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE7D,OAAO,KAAK,EACV,SAAS,EACT,WAAW,EACX,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACX,MAAM,gBAAgB,CAAC;AAkBxB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,UAAU,CAAC;AAG7C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,SAAS,CAAC;AAM5C;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAEjD;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,cAAc,CAAC;AAQ/C,MAAM,WAAW,YAAa,SAAQ,IAAI,CACxC,SAAS,CAAC,kBAAkB,CAAC,EAC7B,UAAU,CACX;IACC,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACpC;;;OAGG;IACH,KAAK,EAAE,YAAY,CAAC;IAEpB;;;;;;;;;;;;;OAaG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;IAEnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IAEpB;;;;;;;;;;;OAWG;IACH,kBAAkB,CAAC,EAAE,YAAY,CAAC;IAElC;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IAE9C;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;OAEG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;CACrB;AAWD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,OAAO,CAAC,EACtB,KAAK,EACL,IAAI,EACJ,MAAM,EACN,kBAAkB,EAClB,KAAiB,EACjB,OAAiB,EACjB,QAAY,EACZ,kBAAyB,EACzB,SAAS,EACT,QAAQ,EACR,OAAiB,EACjB,UAAkB,EAClB,gBAAwB,EACxB,MAAM,EACN,SAAS,EACT,KAAK,EACL,QAAQ,EACR,GAAG,EACH,GAAG,KAAK,EACT,EAAE,YAAY,+BAsGd;yBA1He,OAAO"}
@@ -105,9 +105,11 @@ export function Heading({
105
105
  // the closed built-in StyleX maps with an unknown name.
106
106
  const builtinType = type && isBuiltinHeadingType(type) ? type : undefined;
107
107
 
108
- // Truncation detection
108
+ // Truncation detection. Only the tooltip reads the measured overflow, so
109
+ // with the tooltip off the text is clipped by CSS alone and nothing is
110
+ // measured or observed.
109
111
  const truncation = useTruncation({
110
- maxLines
112
+ maxLines: hasTruncateTooltip === false ? 0 : maxLines
111
113
  });
112
114
 
113
115
  // Tooltip for truncated text
@@ -13,6 +13,11 @@
13
13
  * `TopNavMenu` use). Hover is a progressive enhancement over the popover's
14
14
  * click behavior and is inert on coarse pointers.
15
15
  *
16
+ * Nested rows span the full width of the nav list, like top-level rows, so
17
+ * their hover and selected fill lines up with the rows above them. The
18
+ * nesting indent sits inside the row as extra inline-start padding, one step
19
+ * per level, tracked through a private depth context.
20
+ *
16
21
  * SYNC: When modified, update these files to stay in sync:
17
22
  * - /packages/core/src/SideNav/SideNav.doc.mjs
18
23
  * - /packages/core/src/SideNav/SideNav.test.tsx
@@ -84,7 +89,9 @@ export interface SideNavItemProps extends BaseProps<HTMLElement> {
84
89
  */
85
90
  actions?: ReactNode;
86
91
  /**
87
- * Sub-items for nesting.
92
+ * Sub-items for nesting. Nested items span the full width of the nav
93
+ * list, like top-level items; their content is indented one step per
94
+ * nesting level.
88
95
  */
89
96
  children?: ReactNode;
90
97
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"SideNavItem.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNavItem.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAYf,OAAO,EAAuB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAE5D,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAC;AAIrD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,EAAgB,KAAK,WAAW,EAAC,MAAM,iCAAiC,CAAC;AA4PhF,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,WAAW,CAAC;IAC9D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B;;;;OAIG;IACH,EAAE,CAAC,EAAE,iBAAiB,CAAC;IACvB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC5B;;OAEG;IACH,YAAY,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IACpC;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;OAEG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EACR,OAAO,GACP;QACE,kBAAkB,CAAC,EAAE,OAAO,CAAC;QAC7B,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,IAAI,CAAC;KACpD,CAAC;IACN;;;OAGG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,WAAW,CAAC,EAC1B,EAAE,EACF,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,UAAkB,EAClB,UAAkB,EAClB,IAAI,EACJ,OAAO,EACP,UAAU,EACV,OAAO,EACP,QAAQ,EACR,WAAW,EAAE,eAAe,EAC5B,IAAW,EACX,aAAa,EAAE,MAAM,EACrB,GAAG,EACH,MAAM,EACN,GAAG,IAAI,EACR,EAAE,gBAAgB,sCA4YlB;yBA9Ze,WAAW"}
1
+ {"version":3,"file":"SideNavItem.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNavItem.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAYf,OAAO,EAAuB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAE5D,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAC;AAIrD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,EAAgB,KAAK,WAAW,EAAC,MAAM,iCAAiC,CAAC;AA4ShF,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,WAAW,CAAC;IAC9D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B;;;;OAIG;IACH,EAAE,CAAC,EAAE,iBAAiB,CAAC;IACvB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC5B;;OAEG;IACH,YAAY,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IACpC;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EACR,OAAO,GACP;QACE,kBAAkB,CAAC,EAAE,OAAO,CAAC;QAC7B,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,IAAI,CAAC;KACpD,CAAC;IACN;;;OAGG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,WAAW,CAAC,EAC1B,EAAE,EACF,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,UAAkB,EAClB,UAAkB,EAClB,IAAI,EACJ,OAAO,EACP,UAAU,EACV,OAAO,EACP,QAAQ,EACR,WAAW,EAAE,eAAe,EAC5B,IAAW,EACX,aAAa,EAAE,MAAM,EACrB,GAAG,EACH,MAAM,EACN,GAAG,IAAI,EACR,EAAE,gBAAgB,sCAkalB;yBApbe,WAAW"}
@@ -17,6 +17,11 @@
17
17
  * `TopNavMenu` use). Hover is a progressive enhancement over the popover's
18
18
  * click behavior and is inert on coarse pointers.
19
19
  *
20
+ * Nested rows span the full width of the nav list, like top-level rows, so
21
+ * their hover and selected fill lines up with the rows above them. The
22
+ * nesting indent sits inside the row as extra inline-start padding, one step
23
+ * per level, tracked through a private depth context.
24
+ *
20
25
  * SYNC: When modified, update these files to stay in sync:
21
26
  * - /packages/core/src/SideNav/SideNav.doc.mjs
22
27
  * - /packages/core/src/SideNav/SideNav.test.tsx
@@ -24,7 +29,7 @@
24
29
  * - /apps/storybook/stories/SideNav.stories.tsx
25
30
  * - /packages/cli/assets/templates/blocks/components/SideNav/ (showcase blocks)
26
31
  */
27
- import { useCallback, useId, useMemo, useRef, useState } from 'react';
32
+ import { createContext, use, useCallback, useId, useMemo, useRef, useState } from 'react';
28
33
  import * as stylex from '@stylexjs/stylex';
29
34
  import "../theme/tokens.stylex.js";
30
35
  import { colorVars, spacingVars, sizeVars, fontWeightVars, typeScaleVars, durationVars, easeVars, radiusVars } from "../theme/tokens.stylex.js";
@@ -133,6 +138,47 @@ const styles = {
133
138
  }
134
139
  };
135
140
 
141
+ // Nesting indent, drawn inside the row: one `--spacing-6` step per nesting
142
+ // level. The row itself stays full width, so its hover and selected fill
143
+ // matches a top-level row's.
144
+ // - `sm` / `md` — the ordinary row, where the row element is the link or
145
+ // button: its own inline-start padding plus the steps.
146
+ // SYNC: the base padding matches navItemStyles.sm / .md / .lg.
147
+ // - `primary` — the row-wrapper path: the wrapper keeps its base padding and
148
+ // the steps go on the primary link or button, so the whole indent is part
149
+ // of the click target rather than dead space that still shows feedback.
150
+ const _temp = {
151
+ kZCmMZ: "xnvo3vl",
152
+ "$$css": true
153
+ };
154
+ const _temp2 = {
155
+ kZCmMZ: "xnvo3vl",
156
+ "$$css": true
157
+ };
158
+ const _temp3 = {
159
+ kZCmMZ: "xnvo3vl",
160
+ "$$css": true
161
+ };
162
+ const indentStyles = {
163
+ sm: depth => [_temp, {
164
+ "--x-paddingInlineStart": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(`calc(${spacingVars['--spacing-1']} + ${depth} * ${spacingVars['--spacing-6']})`)
165
+ }],
166
+ md: depth => [_temp2, {
167
+ "--x-paddingInlineStart": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(`calc(${spacingVars['--spacing-2']} + ${depth} * ${spacingVars['--spacing-6']})`)
168
+ }],
169
+ primary: depth => [_temp3, {
170
+ "--x-paddingInlineStart": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(`calc(${depth} * ${spacingVars['--spacing-6']})`)
171
+ }]
172
+ };
173
+
174
+ /**
175
+ * How many SideNavItems deep the current item sits: 0 at the top level,
176
+ * 1 inside one parent, and so on. A collapsed rail's flyout resets it to 0,
177
+ * since the flyout lists a parent's children as its own top level.
178
+ */
179
+ const SideNavItemDepthContext = /*#__PURE__*/createContext(0);
180
+ SideNavItemDepthContext.displayName = 'SideNavItemDepthContext';
181
+
136
182
  /**
137
183
  * Cascaded to the `actions` slot through `SizeContext` so a consumer's row
138
184
  * controls come out the same height as the built-in expand/collapse toggle,
@@ -259,6 +305,7 @@ export function SideNavItem({
259
305
  const id = useId();
260
306
  const hasChildren = !!children;
261
307
  const itemRef = useRef(null);
308
+ const depth = use(SideNavItemDepthContext);
262
309
 
263
310
  // Popover for collapsed items with children
264
311
  const popover = usePopover({
@@ -391,7 +438,10 @@ export function SideNavItem({
391
438
  children: label
392
439
  }), /*#__PURE__*/_jsx(SideNavCollapseContext, {
393
440
  value: EXPANDED_COLLAPSE_STATE,
394
- children: children
441
+ children: /*#__PURE__*/_jsx(SideNavItemDepthContext, {
442
+ value: 0,
443
+ children: children
444
+ })
395
445
  })]
396
446
  }), {
397
447
  placement: 'end',
@@ -457,6 +507,15 @@ export function SideNavItem({
457
507
  disabled: isDisabled ? 'disabled' : null
458
508
  });
459
509
  const itemStyleArgs = [navItemStyles.item, interactionOverlayStyles.backgroundColor, navItemStyles[size], isSelected && navItemStyles.selected, isDisabled && navItemStyles.disabled];
510
+ const rowIndent = depth > 0 && indentStyles[size === 'sm' ? 'sm' : 'md'](depth);
511
+ const primaryIndent = depth > 0 && indentStyles.primary(depth);
512
+
513
+ // The indent is a runtime value, so it reaches the element as an inline
514
+ // style. These props are spread after `rest`, so fold the caller's `style`
515
+ // back in (caller wins) rather than letting the indent replace it.
516
+ const callerStyle = {
517
+ style: rest.style
518
+ };
460
519
 
461
520
  // Three shapes of the same row appearance:
462
521
  // - `rowProps` — split-action path (toggle, no actions): presentational
@@ -471,7 +530,7 @@ export function SideNavItem({
471
530
  ...pressable
472
531
  };
473
532
  const focusableRowProps = {
474
- ...mergeProps(itemThemeProps, focusOutlineProps.focusVisible(...itemStyleArgs)),
533
+ ...mergeProps(itemThemeProps, mergeProps(focusOutlineProps.focusVisible(...itemStyleArgs, rowIndent), callerStyle)),
475
534
  ...pressable
476
535
  };
477
536
  const actionsRowProps = {
@@ -511,10 +570,10 @@ export function SideNavItem({
511
570
  onClick: handleClick,
512
571
  ...rest,
513
572
  ...rowPrimaryAriaProps,
514
- ...(hasActions ?
573
+ ...mergeProps(hasActions ?
515
574
  // The wrapper rings for this element; suppressing here is what
516
575
  // keeps the UA's own ring from painting inside that one.
517
- focusOutlineProps.suppressed(styles.splitAction) : focusOutlineProps.focusVisible(styles.splitAction)),
576
+ focusOutlineProps.suppressed(styles.splitAction, primaryIndent) : focusOutlineProps.focusVisible(styles.splitAction, primaryIndent), callerStyle),
518
577
  children: itemContent
519
578
  }), hasIndependentToggle && /*#__PURE__*/_jsx("button", {
520
579
  type: "button",
@@ -575,21 +634,24 @@ export function SideNavItem({
575
634
  inert: isItemCollapsed ? true : undefined,
576
635
  ...{
577
636
  0: {
578
- className: "xrvj5dj x1tu4anv x1qn9uv2 x80gvsz x12w9bfk xlr8y92"
637
+ className: "xrvj5dj x1tu4anv x1rea2x4 x6kj92 xd00j3c x1eizr8u xsr0bmt xostmdb x1k6wr6r xlr8y92"
579
638
  },
580
639
  1: {
581
- className: "xrvj5dj x1qn9uv2 x80gvsz x12w9bfk xlr8y92 xihq33y"
640
+ className: "xrvj5dj x6kj92 xd00j3c x1eizr8u xsr0bmt xlr8y92 xihq33y xb3r6kr xf1rc6"
582
641
  }
583
642
  }[!!isItemCollapsed << 0],
584
643
  children: /*#__PURE__*/_jsxs("div", {
585
644
  ...{
586
- className: "xb3r6kr x2lwn1j x31w388"
645
+ className: "x2lwn1j"
587
646
  },
588
647
  children: [/*#__PURE__*/_jsx("span", {
589
648
  id: `${id}-label`,
590
649
  hidden: true,
591
650
  children: label
592
- }), children]
651
+ }), /*#__PURE__*/_jsx(SideNavItemDepthContext, {
652
+ value: depth + 1,
653
+ children: children
654
+ })]
593
655
  })
594
656
  })]
595
657
  });
@@ -1 +1 @@
1
- {"version":3,"file":"Text.d.ts","sourceRoot":"","sources":["../../src/Text/Text.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE7D,OAAO,KAAK,EACV,QAAQ,EAER,QAAQ,EACR,SAAS,EACT,gBAAgB,EAChB,UAAU,EACV,WAAW,EACX,WAAW,EACX,SAAS,EACT,QAAQ,EACT,MAAM,gBAAgB,CAAC;AAkBxB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,UAAU,CAAC;AAG7C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAO5C,YAAY,EAAC,QAAQ,EAAE,QAAQ,EAAC,CAAC;AAEjC,MAAM,WAAW,SAAU,SAAQ,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC;IAC5D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B;;;OAGG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAC;IAEhB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAC;IAEhB;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB;;OAEG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IAE9C;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;OAEG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,KAAK,GAAG,OAAO,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAC1D;AA2BD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,gBAAgB,CAQpE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,IAAI,CAAC,EACnB,IAAa,EACb,IAAI,EACJ,KAAK,EACL,MAAM,EACN,OAAkB,EAClB,QAAY,EACZ,kBAAyB,EACzB,SAAS,EACT,QAAQ,EACR,OAAiB,EACjB,UAAkB,EAClB,gBAAwB,EACxB,iBAAyB,EACzB,MAAM,EACN,SAAS,EACT,KAAK,EACL,EAAE,EAAE,SAAkB,EACtB,QAAQ,EACR,GAAG,EACH,GAAG,KAAK,EACT,EAAE,SAAS,+BA0FX;yBA/Ge,IAAI"}
1
+ {"version":3,"file":"Text.d.ts","sourceRoot":"","sources":["../../src/Text/Text.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE7D,OAAO,KAAK,EACV,QAAQ,EAER,QAAQ,EACR,SAAS,EACT,gBAAgB,EAChB,UAAU,EACV,WAAW,EACX,WAAW,EACX,SAAS,EACT,QAAQ,EACT,MAAM,gBAAgB,CAAC;AAkBxB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,UAAU,CAAC;AAG7C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAO5C,YAAY,EAAC,QAAQ,EAAE,QAAQ,EAAC,CAAC;AAEjC,MAAM,WAAW,SAAU,SAAQ,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC;IAC5D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B;;;OAGG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAC;IAEhB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAC;IAEhB;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB;;OAEG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IAE9C;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;OAEG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,KAAK,GAAG,OAAO,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAC1D;AA2BD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,gBAAgB,CAQpE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,IAAI,CAAC,EACnB,IAAa,EACb,IAAI,EACJ,KAAK,EACL,MAAM,EACN,OAAkB,EAClB,QAAY,EACZ,kBAAyB,EACzB,SAAS,EACT,QAAQ,EACR,OAAiB,EACjB,UAAkB,EAClB,gBAAwB,EACxB,iBAAyB,EACzB,MAAM,EACN,SAAS,EACT,KAAK,EACL,EAAE,EAAE,SAAkB,EACtB,QAAQ,EACR,GAAG,EACH,GAAG,KAAK,EACT,EAAE,SAAS,+BA8FX;yBAnHe,IAAI"}
package/dist/Text/Text.js CHANGED
@@ -123,9 +123,11 @@ export function Text({
123
123
  // Resolve display - force block when maxLines > 0 or hasCapsize
124
124
  const resolvedDisplay = maxLines > 0 || hasCapsize ? 'block' : display;
125
125
 
126
- // Truncation detection
126
+ // Truncation detection. Only the tooltip reads the measured overflow, so
127
+ // with the tooltip off the text is clipped by CSS alone and nothing is
128
+ // measured or observed.
127
129
  const truncation = useTruncation({
128
- maxLines
130
+ maxLines: hasTruncateTooltip === false ? 0 : maxLines
129
131
  });
130
132
 
131
133
  // Tooltip for truncated text
@@ -1 +1 @@
1
- {"version":3,"file":"TopNavMegaMenu.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMegaMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAkBf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA4M5C,MAAM,WAAW,mBAAoB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACvE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CAC1C;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,wBAAgB,cAAc,CAAC,EAC7B,GAAG,EACH,KAAK,EACL,KAAK,EACL,QAAQ,EACR,KAAW,EACX,SAAe,EACf,YAAY,EACZ,GAAG,IAAI,EACR,EAAE,mBAAmB,4BAuCrB;yBAhDe,cAAc"}
1
+ {"version":3,"file":"TopNavMegaMenu.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMegaMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAkBf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAgN5C,MAAM,WAAW,mBAAoB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACvE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CAC1C;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,wBAAgB,cAAc,CAAC,EAC7B,GAAG,EACH,KAAK,EACL,KAAK,EACL,QAAQ,EACR,KAAW,EACX,SAAe,EACf,YAAY,EACZ,GAAG,IAAI,EACR,EAAE,mBAAmB,4BAuCrB;yBAhDe,cAAc"}
@@ -411,15 +411,15 @@ function DrawerMegaMenu({
411
411
  id: `${menuId}-items`,
412
412
  ...{
413
413
  0: {
414
- className: "xrvj5dj xihq33y x1qn9uv2 x80gvsz xlr8y92"
414
+ className: "xrvj5dj xihq33y xb3r6kr x6kj92 xd00j3c x1eizr8u xlr8y92"
415
415
  },
416
416
  1: {
417
- className: "xrvj5dj x1qn9uv2 x80gvsz xlr8y92 x1tu4anv"
417
+ className: "xrvj5dj x6kj92 xd00j3c x1eizr8u xlr8y92 x1tu4anv x1rea2x4 xostmdb"
418
418
  }
419
419
  }[!!isExpanded << 0],
420
420
  children: /*#__PURE__*/_jsxs("div", {
421
421
  ...{
422
- className: "xb3r6kr x2lwn1j"
422
+ className: "x2lwn1j"
423
423
  },
424
424
  children: [items, featured != null && /*#__PURE__*/_jsx("div", {
425
425
  ...{
@@ -1 +1 @@
1
- {"version":3,"file":"TopNavMenu.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AASf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA0M5C;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IAEjB;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAMD,MAAM,WAAW,eAAgB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACnE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,KAAK,EAAE,kBAAkB,EAAE,CAAC;IAE5B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,wBAAgB,UAAU,CAAC,EACzB,GAAG,EACH,KAAK,EACL,KAAK,EACL,KAAW,EACX,SAAe,EACf,MAAM,EACN,SAAS,EACT,KAAK,EACL,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,gBAAgB,EAC9B,YAAY,EAAE,gBAAgB,EAC9B,GAAG,IAAI,EACR,EAAE,eAAe,4BA4QjB;yBAzRe,UAAU"}
1
+ {"version":3,"file":"TopNavMenu.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AASf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAkN5C;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IAEjB;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAMD,MAAM,WAAW,eAAgB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACnE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,KAAK,EAAE,kBAAkB,EAAE,CAAC;IAE5B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,wBAAgB,UAAU,CAAC,EACzB,GAAG,EACH,KAAK,EACL,KAAK,EACL,KAAW,EACX,SAAe,EACf,MAAM,EACN,SAAS,EACT,KAAK,EACL,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,gBAAgB,EAC9B,YAAY,EAAE,gBAAgB,EAC9B,GAAG,IAAI,EACR,EAAE,eAAe,4BA4QjB;yBAzRe,UAAU"}
@@ -311,15 +311,15 @@ export function TopNavMenu({
311
311
  id: `${menuId}-items`,
312
312
  ...{
313
313
  0: {
314
- className: "xrvj5dj xihq33y x1qn9uv2 x80gvsz xlr8y92"
314
+ className: "xrvj5dj xihq33y xb3r6kr x6kj92 xd00j3c x1eizr8u xlr8y92"
315
315
  },
316
316
  1: {
317
- className: "xrvj5dj x1qn9uv2 x80gvsz xlr8y92 x1tu4anv"
317
+ className: "xrvj5dj x6kj92 xd00j3c x1eizr8u xlr8y92 x1tu4anv x1rea2x4 xostmdb"
318
318
  }
319
319
  }[!!drawerExpanded << 0],
320
320
  children: /*#__PURE__*/_jsx("div", {
321
321
  ...{
322
- className: "xb3r6kr x2lwn1j"
322
+ className: "x2lwn1j"
323
323
  },
324
324
  children: items.map(item => /*#__PURE__*/_jsxs(LinkComponent, {
325
325
  href: item.href,
package/dist/astryx.css CHANGED
@@ -1358,6 +1358,8 @@
1358
1358
  .xsqj5wx:not(#\#):not(#\#):not(#\#){transform:var(--x-transform)}
1359
1359
  .xd00j3c:not(#\#):not(#\#):not(#\#){transition-behavior:allow-discrete}
1360
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)}
1361
1363
  .x1ahifba:not(#\#):not(#\#):not(#\#){transition-delay:0s}
1362
1364
  .x4avkj2:not(#\#):not(#\#):not(#\#){transition-delay:var(--_fade-delay,0s)}
1363
1365
  .x1rq8cwj:not(#\#):not(#\#):not(#\#){transition-delay:var(--_reveal-delay,0s,0s)}
@@ -1373,6 +1375,7 @@
1373
1375
  .x1tw3moq:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast),0s}
1374
1376
  .xuedmi6:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast)}
1375
1377
  .xgneliz:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium-min)}
1378
+ .x1eizr8u:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium),0s}
1376
1379
  .x80gvsz:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium)}
1377
1380
  .x1eaenvl:not(#\#):not(#\#):not(#\#){transition-property:background-color,border-color,color}
1378
1381
  .xts7igz:not(#\#):not(#\#):not(#\#){transition-property:background-color,border-color}
@@ -1395,6 +1398,7 @@
1395
1398
  .x1mpt4pi:not(#\#):not(#\#):not(#\#){transition-property:color,text-decoration}
1396
1399
  .xt3l3uh:not(#\#):not(#\#):not(#\#){transition-property:color}
1397
1400
  .xrgi2yo:not(#\#):not(#\#):not(#\#){transition-property:display}
1401
+ .x6kj92:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows,overflow}
1398
1402
  .xjshnde:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows,padding}
1399
1403
  .x1qn9uv2:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows}
1400
1404
  .x157bb6t:not(#\#):not(#\#):not(#\#){transition-property:mask-image}
@@ -1607,6 +1611,7 @@
1607
1611
  @media (pointer: coarse){.x1bjav06.x1bjav06:not(#\#):not(#\#):not(#\#){pointer-events:none}}
1608
1612
  @media (prefers-reduced-motion: reduce){.xg83hb1.xg83hb1:not(#\#):not(#\#):not(#\#){scroll-behavior:auto}}
1609
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}}
1610
1615
  @media (prefers-reduced-motion: reduce){.xwgk2z2.xwgk2z2:not(#\#):not(#\#):not(#\#){transition-delay:var(--_reveal-delay-reduced,0s,0s)}}
1611
1616
  @media (prefers-reduced-motion: reduce){.xsagj69.xsagj69:not(#\#):not(#\#):not(#\#){transition-duration:.01ms}}
1612
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.411cbed",
3
+ "version": "0.7.2-canary.4787e3f",
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.411cbed",
796
- "@astryxdesign/cli": "0.7.2-canary.411cbed",
795
+ "@astryxdesign/a11y-spec": "0.7.2-canary.4787e3f",
796
+ "@astryxdesign/cli": "0.7.2-canary.4787e3f",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -269,3 +269,60 @@ describe('Heading', () => {
269
269
  expect(element).not.toHaveAttribute('data-type', 'editorial');
270
270
  });
271
271
  });
272
+
273
+ // With hasTruncateTooltip={false} nothing reads the measured overflow, so a
274
+ // long table of truncated cells must not pay a layout read per cell.
275
+ function countScrollWidthReads(run: () => void): number {
276
+ const proto = window.HTMLElement.prototype;
277
+ const original = Object.getOwnPropertyDescriptor(proto, 'scrollWidth');
278
+ let reads = 0;
279
+ Object.defineProperty(proto, 'scrollWidth', {
280
+ configurable: true,
281
+ get() {
282
+ reads += 1;
283
+ return 0;
284
+ },
285
+ });
286
+ try {
287
+ run();
288
+ } finally {
289
+ if (original) {
290
+ Object.defineProperty(proto, 'scrollWidth', original);
291
+ } else {
292
+ delete (proto as {scrollWidth?: number}).scrollWidth;
293
+ }
294
+ }
295
+ return reads;
296
+ }
297
+
298
+ describe('Heading maxLines without a truncation tooltip', () => {
299
+ it('takes no truncation measurement when hasTruncateTooltip is false', () => {
300
+ const reads = countScrollWidthReads(() => {
301
+ render(
302
+ <>
303
+ {Array.from({length: 20}, (_, index) => (
304
+ <Heading
305
+ level={3}
306
+ maxLines={1}
307
+ hasTruncateTooltip={false}
308
+ key={index}>
309
+ Cell {index} with text far wider than its column
310
+ </Heading>
311
+ ))}
312
+ </>,
313
+ );
314
+ });
315
+ expect(reads).toBe(0);
316
+ });
317
+
318
+ it('still measures when the tooltip is enabled', () => {
319
+ const reads = countScrollWidthReads(() => {
320
+ render(
321
+ <Heading level={3} maxLines={1}>
322
+ Text far wider than its column
323
+ </Heading>,
324
+ );
325
+ });
326
+ expect(reads).toBeGreaterThan(0);
327
+ });
328
+ });
@@ -253,8 +253,12 @@ export function Heading({
253
253
  // the closed built-in StyleX maps with an unknown name.
254
254
  const builtinType = type && isBuiltinHeadingType(type) ? type : undefined;
255
255
 
256
- // Truncation detection
257
- const truncation = useTruncation({maxLines});
256
+ // Truncation detection. Only the tooltip reads the measured overflow, so
257
+ // with the tooltip off the text is clipped by CSS alone and nothing is
258
+ // measured or observed.
259
+ const truncation = useTruncation({
260
+ maxLines: hasTruncateTooltip === false ? 0 : maxLines,
261
+ });
258
262
 
259
263
  // Tooltip for truncated text
260
264
  const tooltipPlacement: LayerPlacement =
@@ -828,6 +828,169 @@ describe('SideNavItem', () => {
828
828
  expect(screen.getByText('Security')).toBeInTheDocument();
829
829
  });
830
830
 
831
+ it('keeps nested rows full width and indents their content inside the row', () => {
832
+ render(
833
+ <SideNavItem label="Settings">
834
+ <SideNavItem label="General" href="/settings/general">
835
+ <SideNavItem label="Advanced" href="/settings/general/advanced" />
836
+ </SideNavItem>
837
+ </SideNavItem>,
838
+ );
839
+
840
+ // The children group adds no inset of its own, so a nested row's hover
841
+ // and selected fill spans the nav list like a top-level row's.
842
+ const groups = screen.getAllByRole('group', {hidden: true});
843
+ for (const group of groups) {
844
+ expect(declaredOn(group, 'padding-inline-start')).toEqual([]);
845
+ expect(
846
+ declaredOn(group.firstElementChild!, 'padding-inline-start'),
847
+ ).toEqual([]);
848
+ }
849
+
850
+ // The indent lives inside each row instead, one step per nesting level.
851
+ // An ordinary row is the link itself: its own padding plus the steps.
852
+ const top = screen.getByRole('button', {name: 'Settings'});
853
+ const general = screen.getByRole('link', {name: 'General'});
854
+ const advanced = screen.getByRole('link', {name: 'Advanced'});
855
+ expect(indentOf(top)).toBeNull();
856
+ expect(indentOf(advanced)).toBe(
857
+ 'calc(var(--spacing-2) + 2 * var(--spacing-6))',
858
+ );
859
+
860
+ // A split row (own href plus children) keeps the wrapper's base padding
861
+ // and puts the steps on the link, so the whole indent is clickable
862
+ // instead of dead space that still shows hover and press feedback.
863
+ expect(indentOf(general.parentElement!)).toBeNull();
864
+ expect(indentOf(general)).toBe('calc(1 * var(--spacing-6))');
865
+ });
866
+
867
+ it('clips the children group only while it is collapsed', async () => {
868
+ // Nested rows sit flush with the group, so a permanent clip would cut off
869
+ // their focus ring. The group clips for the collapse animation only.
870
+ const user = userEvent.setup();
871
+ render(
872
+ <SideNavItem label="Settings" collapsible>
873
+ <SideNavItem label="General" href="/settings/general" />
874
+ </SideNavItem>,
875
+ );
876
+ const group = () => screen.getByRole('group', {hidden: true});
877
+ expect(declaredOn(group(), 'overflow')).toEqual(['visible']);
878
+ expect(declaredOn(group().firstElementChild!, 'overflow')).toEqual([]);
879
+
880
+ await user.click(screen.getByRole('button', {name: 'Settings'}));
881
+ expect(declaredOn(group(), 'overflow')).toEqual(['hidden']);
882
+ });
883
+
884
+ it('puts the indent on the primary link of a nested actions row', () => {
885
+ render(
886
+ <SideNavItem label="Projects">
887
+ <SideNavItem
888
+ label="Alpha"
889
+ href="/projects/alpha"
890
+ actions={
891
+ <button type="button" aria-label="Alpha actions">
892
+ More
893
+ </button>
894
+ }
895
+ />
896
+ </SideNavItem>,
897
+ );
898
+ const alpha = screen.getByRole('link', {name: 'Alpha'});
899
+ expect(indentOf(alpha.parentElement!)).toBeNull();
900
+ expect(indentOf(alpha)).toBe('calc(1 * var(--spacing-6))');
901
+ });
902
+
903
+ it("composes a caller's style with the indent on a nested row", () => {
904
+ render(
905
+ <SideNavItem label="Settings">
906
+ <SideNavItem
907
+ label="General"
908
+ href="/settings/general"
909
+ style={{color: 'rebeccapurple'}}
910
+ />
911
+ <SideNavItem
912
+ label="Security"
913
+ href="/settings/security"
914
+ style={{color: 'rebeccapurple'}}>
915
+ <SideNavItem label="Keys" href="/settings/security/keys" />
916
+ </SideNavItem>
917
+ </SideNavItem>,
918
+ );
919
+ // Ordinary row: the link is the row.
920
+ const general = screen.getByRole('link', {name: 'General'});
921
+ expect(general.style.color).toBe('rebeccapurple');
922
+ expect(indentOf(general)).toBe(
923
+ 'calc(var(--spacing-2) + 1 * var(--spacing-6))',
924
+ );
925
+ // Split row: the caller's style still lands on the primary link.
926
+ const security = screen.getByRole('link', {name: 'Security'});
927
+ expect(security.style.color).toBe('rebeccapurple');
928
+ expect(indentOf(security)).toBe('calc(1 * var(--spacing-6))');
929
+ });
930
+
931
+ it("lets a caller's inline-start padding replace the indent", () => {
932
+ // The caller's `style` composes after the indent: a caller-set
933
+ // inline-start padding is an inline declaration, so it wins over the
934
+ // indent's class rule on the element that carries the indent, on both
935
+ // the ordinary and the split row path.
936
+ render(
937
+ <SideNavItem label="Settings">
938
+ <SideNavItem
939
+ label="General"
940
+ href="/settings/general"
941
+ style={{paddingInlineStart: '20px'}}
942
+ />
943
+ <SideNavItem
944
+ label="Security"
945
+ href="/settings/security"
946
+ style={{paddingInlineStart: '20px'}}>
947
+ <SideNavItem label="Keys" href="/settings/security/keys" />
948
+ </SideNavItem>
949
+ </SideNavItem>,
950
+ );
951
+ const general = screen.getByRole('link', {name: 'General'});
952
+ expect(general.style.paddingInlineStart).toBe('20px');
953
+ const security = screen.getByRole('link', {name: 'Security'});
954
+ expect(security.style.paddingInlineStart).toBe('20px');
955
+ // A row whose caller sets no padding keeps its indent.
956
+ expect(indentOf(screen.getByRole('link', {name: 'Keys'}))).toBe(
957
+ 'calc(var(--spacing-2) + 2 * var(--spacing-6))',
958
+ );
959
+ });
960
+
961
+ it('indents a small nested row from the small row padding', () => {
962
+ render(
963
+ <SideNavItem label="Settings">
964
+ <SideNavItem label="General" href="/settings/general" size="sm" />
965
+ </SideNavItem>,
966
+ );
967
+ expect(indentOf(screen.getByRole('link', {name: 'General'}))).toBe(
968
+ 'calc(var(--spacing-1) + 1 * var(--spacing-6))',
969
+ );
970
+ });
971
+
972
+ it('does not indent the first level of a collapsed rail flyout', async () => {
973
+ const user = userEvent.setup();
974
+ renderCollapsed(
975
+ <SideNavItem label="Settings" icon={StubIcon}>
976
+ <SideNavItem label="General" href="/settings/general">
977
+ <SideNavItem label="Advanced" href="/settings/general/advanced" />
978
+ </SideNavItem>
979
+ </SideNavItem>,
980
+ );
981
+ await user.click(screen.getByRole('button', {name: 'Settings'}));
982
+
983
+ // jsdom leaves the native popover `display: none`, hence `hidden: true`.
984
+ // The flyout lists the parent's children as its own top level.
985
+ const general = screen.getByRole('link', {name: 'General', hidden: true});
986
+ const advanced = screen.getByRole('link', {name: 'Advanced', hidden: true});
987
+ expect(indentOf(general)).toBeNull();
988
+ expect(indentOf(general.parentElement!)).toBeNull();
989
+ expect(indentOf(advanced)).toBe(
990
+ 'calc(var(--spacing-2) + 1 * var(--spacing-6))',
991
+ );
992
+ });
993
+
831
994
  it('passes data-testid', () => {
832
995
  render(<SideNavItem label="Dashboard" data-testid="nav-item" />);
833
996
  expect(screen.getByTestId('nav-item')).toBeInTheDocument();
@@ -2457,6 +2620,22 @@ function declaredOn(el: Element, prop: string): string[] {
2457
2620
  .filter(value => value != null);
2458
2621
  }
2459
2622
 
2623
+ /**
2624
+ * The nesting indent a row carries, or null for none. The indent is a
2625
+ * runtime StyleX value, so it rides the row's inline style as a custom
2626
+ * property; read the value rather than the generated property name.
2627
+ */
2628
+ function indentOf(el: HTMLElement): string | null {
2629
+ const {style} = el;
2630
+ for (let i = 0; i < style.length; i++) {
2631
+ const value = style.getPropertyValue(style[i]).trim();
2632
+ if (value.includes('var(--spacing-6)')) {
2633
+ return value;
2634
+ }
2635
+ }
2636
+ return null;
2637
+ }
2638
+
2460
2639
  /**
2461
2640
  * The selectors that actually paint the shared ring on `el`, read out of the
2462
2641
  * live stylesheet with StyleX's `:not(#\#)` specificity padding stripped so
@@ -90,7 +90,7 @@ export const docs = {
90
90
  {
91
91
  name: 'children',
92
92
  type: 'ReactNode',
93
- description: 'Sub-items for nesting.',
93
+ description: 'Sub-items for nesting. Nested items span the full width of the nav list, like top-level items; their content is indented one step per nesting level.',
94
94
  slotElements: [
95
95
  {
96
96
  __element: 'SideNavItem',
@@ -213,7 +213,7 @@ export const docsDense = {
213
213
  'Passive right-side content only (badges, counts). Interactive controls go in actions.',
214
214
  actions:
215
215
  'Row-level secondary controls (icon buttons, menus) rendered as siblings of the primary element, after the expand/collapse toggle and before nested children in DOM/focus order. Inherit the row control size via SizeContext (explicit size wins). Hidden when rail collapsed. Passive content goes in endContent; interactive content goes here.',
216
- children: 'Sub-items for nesting.',
216
+ children: 'Sub-items for nesting. Nested rows stay full width; content indents one step per level.',
217
217
  collapsible: 'Enables collapse for items w/ children. true=uncontrolled, object=controlled mode.',
218
218
  size: "Row size variant: 'sm' | 'md' | 'lg'.",
219
219
  },
@@ -17,6 +17,11 @@
17
17
  * `TopNavMenu` use). Hover is a progressive enhancement over the popover's
18
18
  * click behavior and is inert on coarse pointers.
19
19
  *
20
+ * Nested rows span the full width of the nav list, like top-level rows, so
21
+ * their hover and selected fill lines up with the rows above them. The
22
+ * nesting indent sits inside the row as extra inline-start padding, one step
23
+ * per level, tracked through a private depth context.
24
+ *
20
25
  * SYNC: When modified, update these files to stay in sync:
21
26
  * - /packages/core/src/SideNav/SideNav.doc.mjs
22
27
  * - /packages/core/src/SideNav/SideNav.test.tsx
@@ -26,6 +31,8 @@
26
31
  */
27
32
 
28
33
  import {
34
+ createContext,
35
+ use,
29
36
  useCallback,
30
37
  useId,
31
38
  useMemo,
@@ -108,26 +115,45 @@ const styles = stylex.create({
108
115
  gap: spacingVars['--spacing-1'],
109
116
  pointerEvents: 'auto',
110
117
  },
111
- children: {
112
- paddingInlineStart: spacingVars['--spacing-6'],
113
- },
118
+ // The group clips its rows only for the 0fr collapse animation. Nested rows
119
+ // sit flush with the group, so a permanent clip would cut off their focus
120
+ // ring, which paints outside the row. The expanded group therefore stops
121
+ // clipping once it has finished opening: `overflow` is discrete, so it rides
122
+ // the same transition with `allow-discrete` and a delay equal to the open
123
+ // duration, holding `hidden` until the rows' height has settled. Collapsing
124
+ // flips back to `hidden` at once (no delay on the collapsed state), so
125
+ // nothing spills while the group closes.
126
+ //
127
+ // Reduced fallback: a browser without `transition-behavior: allow-discrete`
128
+ // (the tier below current Baseline) cannot delay the switch, so the group
129
+ // stops clipping as soon as it starts opening and the opening rows briefly
130
+ // overlap the row below. The group still opens and the ring still shows.
114
131
  childrenCollapsible: {
115
132
  display: 'grid',
116
133
  gridTemplateRows: '1fr',
117
- transitionProperty: 'grid-template-rows',
134
+ overflow: 'visible',
135
+ transitionProperty: 'grid-template-rows, overflow',
136
+ transitionBehavior: 'allow-discrete',
118
137
  transitionDuration: {
119
- default: durationVars['--duration-medium'],
120
- '@media (prefers-reduced-motion: reduce)': '0s',
138
+ default: `${durationVars['--duration-medium']}, 0s`,
139
+ '@media (prefers-reduced-motion: reduce)': '0s, 0s',
140
+ },
141
+ transitionDelay: {
142
+ default: `0s, ${durationVars['--duration-medium']}`,
143
+ '@media (prefers-reduced-motion: reduce)': '0s, 0s',
121
144
  },
122
145
  transitionTimingFunction: easeVars['--ease-standard'],
123
146
  },
124
147
  childrenCollapsed: {
125
148
  gridTemplateRows: '0fr',
149
+ overflow: 'hidden',
150
+ transitionDelay: '0s, 0s',
126
151
  },
152
+ // No inline padding: nested rows fill the full width of the nav list, and
153
+ // each row carries its own indent (`indentStyles` below). `minHeight: 0`
154
+ // lets the 0fr track shrink this item to nothing; the group clips it.
127
155
  childrenInner: {
128
- overflow: 'hidden',
129
156
  minHeight: 0,
130
- paddingInlineStart: spacingVars['--spacing-6'],
131
157
  },
132
158
  expandChevron: {
133
159
  display: 'inline-flex',
@@ -232,6 +258,35 @@ const styles = stylex.create({
232
258
  },
233
259
  });
234
260
 
261
+ // Nesting indent, drawn inside the row: one `--spacing-6` step per nesting
262
+ // level. The row itself stays full width, so its hover and selected fill
263
+ // matches a top-level row's.
264
+ // - `sm` / `md` — the ordinary row, where the row element is the link or
265
+ // button: its own inline-start padding plus the steps.
266
+ // SYNC: the base padding matches navItemStyles.sm / .md / .lg.
267
+ // - `primary` — the row-wrapper path: the wrapper keeps its base padding and
268
+ // the steps go on the primary link or button, so the whole indent is part
269
+ // of the click target rather than dead space that still shows feedback.
270
+ const indentStyles = stylex.create({
271
+ sm: (depth: number) => ({
272
+ paddingInlineStart: `calc(${spacingVars['--spacing-1']} + ${depth} * ${spacingVars['--spacing-6']})`,
273
+ }),
274
+ md: (depth: number) => ({
275
+ paddingInlineStart: `calc(${spacingVars['--spacing-2']} + ${depth} * ${spacingVars['--spacing-6']})`,
276
+ }),
277
+ primary: (depth: number) => ({
278
+ paddingInlineStart: `calc(${depth} * ${spacingVars['--spacing-6']})`,
279
+ }),
280
+ });
281
+
282
+ /**
283
+ * How many SideNavItems deep the current item sits: 0 at the top level,
284
+ * 1 inside one parent, and so on. A collapsed rail's flyout resets it to 0,
285
+ * since the flyout lists a parent's children as its own top level.
286
+ */
287
+ const SideNavItemDepthContext = createContext(0);
288
+ SideNavItemDepthContext.displayName = 'SideNavItemDepthContext';
289
+
235
290
  /**
236
291
  * Cascaded to the `actions` slot through `SizeContext` so a consumer's row
237
292
  * controls come out the same height as the built-in expand/collapse toggle,
@@ -363,7 +418,9 @@ export interface SideNavItemProps extends BaseProps<HTMLElement> {
363
418
  */
364
419
  actions?: ReactNode;
365
420
  /**
366
- * Sub-items for nesting.
421
+ * Sub-items for nesting. Nested items span the full width of the nav
422
+ * list, like top-level items; their content is indented one step per
423
+ * nesting level.
367
424
  */
368
425
  children?: ReactNode;
369
426
  /**
@@ -455,6 +512,7 @@ export function SideNavItem({
455
512
  const id = useId();
456
513
  const hasChildren = !!children;
457
514
  const itemRef = useRef<HTMLDivElement>(null);
515
+ const depth = use(SideNavItemDepthContext);
458
516
 
459
517
  // Popover for collapsed items with children
460
518
  const popover = usePopover({
@@ -608,7 +666,9 @@ export function SideNavItem({
608
666
  onClick={() => popover.hide()}>
609
667
  <div {...stylex.props(styles.popoverHeader)}>{label}</div>
610
668
  <SideNavCollapseContext value={EXPANDED_COLLAPSE_STATE}>
611
- {children}
669
+ <SideNavItemDepthContext value={0}>
670
+ {children}
671
+ </SideNavItemDepthContext>
612
672
  </SideNavCollapseContext>
613
673
  </div>,
614
674
  {placement: 'end', alignment: 'start', xstyle: styles.popoverGap},
@@ -686,6 +746,14 @@ export function SideNavItem({
686
746
  isSelected && navItemStyles.selected,
687
747
  isDisabled && navItemStyles.disabled,
688
748
  ] as const;
749
+ const rowIndent =
750
+ depth > 0 && indentStyles[size === 'sm' ? 'sm' : 'md'](depth);
751
+ const primaryIndent = depth > 0 && indentStyles.primary(depth);
752
+
753
+ // The indent is a runtime value, so it reaches the element as an inline
754
+ // style. These props are spread after `rest`, so fold the caller's `style`
755
+ // back in (caller wins) rather than letting the indent replace it.
756
+ const callerStyle = {style: rest.style};
689
757
 
690
758
  // Three shapes of the same row appearance:
691
759
  // - `rowProps` — split-action path (toggle, no actions): presentational
@@ -702,7 +770,10 @@ export function SideNavItem({
702
770
  const focusableRowProps = {
703
771
  ...mergeProps(
704
772
  itemThemeProps,
705
- focusOutlineProps.focusVisible(...itemStyleArgs),
773
+ mergeProps(
774
+ focusOutlineProps.focusVisible(...itemStyleArgs, rowIndent),
775
+ callerStyle,
776
+ ),
706
777
  ),
707
778
  ...pressable,
708
779
  };
@@ -748,11 +819,17 @@ export function SideNavItem({
748
819
  onClick={handleClick}
749
820
  {...rest}
750
821
  {...rowPrimaryAriaProps}
751
- {...(hasActions
752
- ? // The wrapper rings for this element; suppressing here is what
753
- // keeps the UA's own ring from painting inside that one.
754
- focusOutlineProps.suppressed(styles.splitAction)
755
- : focusOutlineProps.focusVisible(styles.splitAction))}>
822
+ {...mergeProps(
823
+ hasActions
824
+ ? // The wrapper rings for this element; suppressing here is what
825
+ // keeps the UA's own ring from painting inside that one.
826
+ focusOutlineProps.suppressed(styles.splitAction, primaryIndent)
827
+ : focusOutlineProps.focusVisible(
828
+ styles.splitAction,
829
+ primaryIndent,
830
+ ),
831
+ callerStyle,
832
+ )}>
756
833
  {itemContent}
757
834
  </NavItemElement>
758
835
  {hasIndependentToggle && (
@@ -831,7 +908,9 @@ export function SideNavItem({
831
908
  <span id={`${id}-label`} hidden>
832
909
  {label}
833
910
  </span>
834
- {children}
911
+ <SideNavItemDepthContext value={depth + 1}>
912
+ {children}
913
+ </SideNavItemDepthContext>
835
914
  </div>
836
915
  </div>
837
916
  )}
@@ -346,3 +346,60 @@ describe('truncated text shows one tooltip, not two', () => {
346
346
  }
347
347
  });
348
348
  });
349
+
350
+ // With hasTruncateTooltip={false} nothing reads the measured overflow, so a
351
+ // long table of truncated cells must not pay a layout read per cell.
352
+ function countScrollWidthReads(run: () => void): number {
353
+ const proto = window.HTMLElement.prototype;
354
+ const original = Object.getOwnPropertyDescriptor(proto, 'scrollWidth');
355
+ let reads = 0;
356
+ Object.defineProperty(proto, 'scrollWidth', {
357
+ configurable: true,
358
+ get() {
359
+ reads += 1;
360
+ return 0;
361
+ },
362
+ });
363
+ try {
364
+ run();
365
+ } finally {
366
+ if (original) {
367
+ Object.defineProperty(proto, 'scrollWidth', original);
368
+ } else {
369
+ delete (proto as {scrollWidth?: number}).scrollWidth;
370
+ }
371
+ }
372
+ return reads;
373
+ }
374
+
375
+ describe('Text maxLines without a truncation tooltip', () => {
376
+ it('takes no truncation measurement when hasTruncateTooltip is false', () => {
377
+ const reads = countScrollWidthReads(() => {
378
+ render(
379
+ <>
380
+ {Array.from({length: 20}, (_, index) => (
381
+ <Text
382
+ type="body"
383
+ maxLines={1}
384
+ hasTruncateTooltip={false}
385
+ key={index}>
386
+ Cell {index} with text far wider than its column
387
+ </Text>
388
+ ))}
389
+ </>,
390
+ );
391
+ });
392
+ expect(reads).toBe(0);
393
+ });
394
+
395
+ it('still measures when the tooltip is enabled', () => {
396
+ const reads = countScrollWidthReads(() => {
397
+ render(
398
+ <Text type="body" maxLines={1}>
399
+ Text far wider than its column
400
+ </Text>,
401
+ );
402
+ });
403
+ expect(reads).toBeGreaterThan(0);
404
+ });
405
+ });
package/src/Text/Text.tsx CHANGED
@@ -260,8 +260,12 @@ export function Text({
260
260
  // Resolve display - force block when maxLines > 0 or hasCapsize
261
261
  const resolvedDisplay = maxLines > 0 || hasCapsize ? 'block' : display;
262
262
 
263
- // Truncation detection
264
- const truncation = useTruncation({maxLines});
263
+ // Truncation detection. Only the tooltip reads the measured overflow, so
264
+ // with the tooltip off the text is clipped by CSS alone and nothing is
265
+ // measured or observed.
266
+ const truncation = useTruncation({
267
+ maxLines: hasTruncateTooltip === false ? 0 : maxLines,
268
+ });
265
269
 
266
270
  // Tooltip for truncated text
267
271
  const tooltipPlacement: LayerPlacement =
@@ -769,6 +769,28 @@ describe('TopNavMegaMenu — drawer mode', () => {
769
769
  expect(handleClick).toHaveBeenCalledTimes(1);
770
770
  });
771
771
 
772
+ it('clips the drawer section only while it is collapsed', async () => {
773
+ // The ring paints outside the item, so a permanent clip would cut it off.
774
+ const user = userEvent.setup();
775
+ render(
776
+ <TopNavRenderContext value="drawer">
777
+ <TopNavMegaMenu
778
+ label="Products"
779
+ items={<TopNavMegaMenuItem title="Analytics" href="/analytics" />}
780
+ />
781
+ </TopNavRenderContext>,
782
+ );
783
+ const trigger = screen.getByRole('button', {name: 'Products'});
784
+ const section = document.getElementById(
785
+ trigger.getAttribute('aria-controls') ?? '',
786
+ )!;
787
+ expect(getComputedStyle(section).overflow).toBe('hidden');
788
+ expect(getComputedStyle(section.firstElementChild!).overflow).toBe('');
789
+
790
+ await user.click(trigger);
791
+ expect(getComputedStyle(section).overflow).toBe('visible');
792
+ });
793
+
772
794
  it('renders featured content when expanded', async () => {
773
795
  const user = userEvent.setup();
774
796
 
@@ -229,18 +229,22 @@ const styles = stylex.create({
229
229
  drawerChevronExpanded: {
230
230
  transform: 'rotate(180deg)',
231
231
  },
232
+ // Clips only while collapsed or animating; see TopNavMenu drawerStyles.items.
232
233
  drawerItems: {
233
234
  display: 'grid',
234
235
  gridTemplateRows: '0fr',
235
- transitionProperty: 'grid-template-rows',
236
- transitionDuration: durationVars['--duration-medium'],
236
+ overflow: 'hidden',
237
+ transitionProperty: 'grid-template-rows, overflow',
238
+ transitionBehavior: 'allow-discrete',
239
+ transitionDuration: `${durationVars['--duration-medium']}, 0s`,
237
240
  transitionTimingFunction: easeVars['--ease-standard'],
238
241
  },
239
242
  drawerItemsExpanded: {
240
243
  gridTemplateRows: '1fr',
244
+ overflow: 'visible',
245
+ transitionDelay: `0s, ${durationVars['--duration-medium']}`,
241
246
  },
242
247
  drawerItemsInner: {
243
- overflow: 'hidden',
244
248
  minHeight: 0,
245
249
  },
246
250
 
@@ -241,6 +241,25 @@ describe('TopNavMenu — drawer focus ring', () => {
241
241
  );
242
242
  expectSharedFocusRing(screen.getByRole('link', {name: /Analytics/}));
243
243
  });
244
+
245
+ it('clips the drawer section only while it is collapsed', async () => {
246
+ // The ring paints outside the item, so a permanent clip would cut it off.
247
+ const user = userEvent.setup();
248
+ render(
249
+ <TopNavRenderContext value="drawer">
250
+ <TopNavMenu label="Products" items={mockItems} />
251
+ </TopNavRenderContext>,
252
+ );
253
+ const trigger = screen.getByRole('button', {name: /Products/});
254
+ const section = document.getElementById(
255
+ trigger.getAttribute('aria-controls') ?? '',
256
+ )!;
257
+ expect(getComputedStyle(section).overflow).toBe('hidden');
258
+ expect(getComputedStyle(section.firstElementChild!).overflow).toBe('');
259
+
260
+ await user.click(trigger);
261
+ expect(getComputedStyle(section).overflow).toBe('visible');
262
+ });
244
263
  });
245
264
 
246
265
  describe('TopNavMenu pass-through props', () => {
@@ -194,18 +194,26 @@ const drawerStyles = stylex.create({
194
194
  chevronExpanded: {
195
195
  transform: 'rotate(180deg)',
196
196
  },
197
+ // The section clips its items only for the 0fr collapse animation: a
198
+ // permanent clip cuts off the focus ring, which paints outside the item.
199
+ // `overflow` is discrete, so it rides the transition with `allow-discrete`
200
+ // and switches to `visible` only after the open has finished. Collapsing
201
+ // clips again at once.
197
202
  items: {
198
203
  display: 'grid',
199
204
  gridTemplateRows: '0fr',
200
- transitionProperty: 'grid-template-rows',
201
- transitionDuration: durationVars['--duration-medium'],
205
+ overflow: 'hidden',
206
+ transitionProperty: 'grid-template-rows, overflow',
207
+ transitionBehavior: 'allow-discrete',
208
+ transitionDuration: `${durationVars['--duration-medium']}, 0s`,
202
209
  transitionTimingFunction: easeVars['--ease-standard'],
203
210
  },
204
211
  itemsExpanded: {
205
212
  gridTemplateRows: '1fr',
213
+ overflow: 'visible',
214
+ transitionDelay: `0s, ${durationVars['--duration-medium']}`,
206
215
  },
207
216
  itemsInner: {
208
- overflow: 'hidden',
209
217
  minHeight: 0,
210
218
  },
211
219
  item: {