@astryxdesign/core 0.7.6-canary.786c16e → 0.7.6-canary.7958bd2

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.
@@ -9,8 +9,8 @@
9
9
  * provided and fall back to `label` when it is missing or blank. Their label
10
10
  * tooltip binds to the interactive control through a callback ref, so hover,
11
11
  * keyboard focus, and `aria-describedby` follow the control from the first
12
- * collapsed render. A disabled control takes neither pointer nor focus, so its
13
- * row is the tooltip's trigger instead.
12
+ * collapsed render. A disabled control takes no pointer, so its row listens
13
+ * for the tooltip instead, while the control keeps focus and the description.
14
14
  *
15
15
  * Collapsed items with children open their submenu flyout through
16
16
  * `useMenuHover`, the shared hover-intent hook (same one `SideNavHeading` and
@@ -1 +1 @@
1
- {"version":3,"file":"SideNavItem.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNavItem.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAOL,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;AAkThF,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,sCA2blB;yBA7ce,WAAW"}
1
+ {"version":3,"file":"SideNavItem.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNavItem.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAOL,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;AA6ThF,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,sCAiclB;yBAnde,WAAW"}
@@ -13,8 +13,8 @@
13
13
  * provided and fall back to `label` when it is missing or blank. Their label
14
14
  * tooltip binds to the interactive control through a callback ref, so hover,
15
15
  * keyboard focus, and `aria-describedby` follow the control from the first
16
- * collapsed render. A disabled control takes neither pointer nor focus, so its
17
- * row is the tooltip's trigger instead.
16
+ * collapsed render. A disabled control takes no pointer, so its row listens
17
+ * for the tooltip instead, while the control keeps focus and the description.
18
18
  *
19
19
  * Collapsed items with children open their submenu flyout through
20
20
  * `useMenuHover`, the shared hover-intent hook (same one `SideNavHeading` and
@@ -52,6 +52,7 @@ import { useSideNavCollapse, SideNavCollapseContext } from "./SideNavCollapseCon
52
52
  import { useSideNavRenderMode } from "./SideNavRenderContext.js";
53
53
  import { useAppShellMobile } from "../AppShell/AppShellMobileContext.js";
54
54
  import { themeProps } from "../utils/themeProps.js";
55
+ import { disabledInteractionProps } from "../utils/disabledInteraction.js";
55
56
  import { useTranslator } from "../i18n/index.js";
56
57
  import { useMergedRefs } from "../hooks/useMergedRefs.js";
57
58
  // =============================================================================
@@ -207,6 +208,8 @@ const EXPANDED_COLLAPSE_STATE = {
207
208
  /**
208
209
  * Renders `<a>` (via LinkComponent) when `href` is set, otherwise `<button>`.
209
210
  * Centralizes the link-vs-button decision used across all SideNavItem paths.
211
+ * A disabled item is a `<button>` that keeps its tab stop and exposes
212
+ * `aria-disabled`, but is inert (spec:AST-085 FR1, FR2).
210
213
  */
211
214
  function NavItemElement({
212
215
  href,
@@ -231,8 +234,11 @@ function NavItemElement({
231
234
  ref: ref,
232
235
  type: "button",
233
236
  onClick: onClick,
234
- disabled: isDisabled,
235
237
  ...rest,
238
+ ...(isDisabled ? {
239
+ 'aria-disabled': true,
240
+ ...disabledInteractionProps(rest.onKeyDown)
241
+ } : null),
236
242
  children: children
237
243
  });
238
244
  }
@@ -335,6 +341,10 @@ export function SideNavItem({
335
341
  const isRailLeaf = isCollapsed && !hasChildren;
336
342
  const railTooltip = useTooltip({
337
343
  placement: 'end',
344
+ // A disabled leaf's row is the trigger (see the collapsed path). It is not
345
+ // focusable itself, so `auto` would never hear the focus that bubbles up
346
+ // from its control.
347
+ focusTrigger: 'always',
338
348
  isEnabled: isRailLeaf,
339
349
  isOpen: isRailLeaf ? undefined : false
340
350
  });
@@ -477,12 +487,14 @@ export function SideNavItem({
477
487
 
478
488
  // Items without children: icon-only link or button, its label in a
479
489
  // tooltip. The control is the trigger, so the element that takes focus is
480
- // the one described. A disabled control takes neither pointer
481
- // (`navItemStyles.disabled`) nor focus, so its row is the trigger instead.
490
+ // the one described. A disabled control takes no pointer
491
+ // (`navItemStyles.disabled`), so its row listens for the tooltip instead;
492
+ // the control still takes focus (focusin reaches the row) and stays the
493
+ // element described (spec:AST-085 FR5).
482
494
  const isRowTrigger = isDisabled;
483
495
  const collapsedAriaProps = {
484
496
  'aria-current': isSelected ? 'page' : undefined,
485
- 'aria-describedby': isRowTrigger ? rest['aria-describedby'] : joinIds(rest['aria-describedby'], railTooltip.describedBy),
497
+ 'aria-describedby': joinIds(rest['aria-describedby'], railTooltip.describedBy),
486
498
  'aria-disabled': isDisabled || undefined,
487
499
  'aria-label': collapsedAccessibleLabel,
488
500
  'data-testid': testId
@@ -500,7 +512,6 @@ export function SideNavItem({
500
512
  });
501
513
  return /*#__PURE__*/_jsxs("div", {
502
514
  ref: isRowTrigger ? railTooltip.ref : undefined,
503
- "aria-describedby": isRowTrigger ? railTooltip.describedBy : undefined,
504
515
  ...stylex.props(styles.root, xstyle),
505
516
  children: [collapsedElement, railTooltip.renderTooltip(label)]
506
517
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.6-canary.786c16e",
3
+ "version": "0.7.6-canary.7958bd2",
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",
@@ -847,9 +847,9 @@
847
847
  "react-dom": ">=19.0.0"
848
848
  },
849
849
  "devDependencies": {
850
- "@astryxdesign/a11y-spec": "0.7.6-canary.786c16e",
851
- "@astryxdesign/cli": "0.7.6-canary.786c16e",
852
- "@astryxdesign/interaction-harness": "0.7.6-canary.786c16e",
850
+ "@astryxdesign/a11y-spec": "0.7.6-canary.7958bd2",
851
+ "@astryxdesign/cli": "0.7.6-canary.7958bd2",
852
+ "@astryxdesign/interaction-harness": "0.7.6-canary.7958bd2",
853
853
  "@babel/cli": "^7.29.7",
854
854
  "@babel/core": "^7.29.7",
855
855
  "@babel/preset-react": "^7.29.7",
@@ -964,10 +964,71 @@ describe('SideNavItem', () => {
964
964
  expect(button).not.toHaveAttribute('aria-current');
965
965
  });
966
966
 
967
- it('disables the button when isDisabled', () => {
967
+ // spec:AST-085 FR1, FR2: a disabled item stays focusable but is inert.
968
+ it('keeps a disabled item focusable and exposes aria-disabled', () => {
968
969
  render(<SideNavItem label="Dashboard" isDisabled />);
969
- const button = screen.getByRole('button');
970
- expect(button).toBeDisabled();
970
+ const button = screen.getByRole('button', {name: 'Dashboard'});
971
+ expect(button).not.toHaveAttribute('disabled');
972
+ expect(button).toHaveAttribute('aria-disabled', 'true');
973
+ button.focus();
974
+ expect(button).toHaveFocus();
975
+ });
976
+
977
+ it('does not run onClick on a disabled item from a click, Enter or Space', async () => {
978
+ const user = userEvent.setup();
979
+ const handleClick = vi.fn();
980
+ render(<SideNavItem label="Dashboard" onClick={handleClick} isDisabled />);
981
+ const button = screen.getByRole('button', {name: 'Dashboard'});
982
+ fireEvent.click(button);
983
+ button.focus();
984
+ await user.keyboard('{Enter}');
985
+ await user.keyboard(' ');
986
+ expect(handleClick).not.toHaveBeenCalled();
987
+ });
988
+
989
+ it('renders a disabled item with href as a focusable button that does not navigate', () => {
990
+ const handleClick = vi.fn();
991
+ render(
992
+ <SideNavItem
993
+ label="Dashboard"
994
+ href="/dashboard"
995
+ onClick={handleClick}
996
+ isDisabled
997
+ />,
998
+ );
999
+ expect(screen.queryByRole('link')).toBeNull();
1000
+ const button = screen.getByRole('button', {name: 'Dashboard'});
1001
+ expect(button).not.toHaveAttribute('disabled');
1002
+ expect(button).not.toHaveAttribute('href');
1003
+ expect(button).toHaveAttribute('aria-disabled', 'true');
1004
+ fireEvent.click(button);
1005
+ expect(handleClick).not.toHaveBeenCalled();
1006
+ });
1007
+
1008
+ it('exposes aria-disabled on the primary of a disabled split row', () => {
1009
+ render(
1010
+ <SideNavItem label="Settings" href="/settings" collapsible isDisabled>
1011
+ <SideNavItem label="General" href="/settings/general" />
1012
+ </SideNavItem>,
1013
+ );
1014
+ const primary = screen.getByRole('button', {name: 'Settings'});
1015
+ expect(primary).not.toHaveAttribute('disabled');
1016
+ expect(primary).toHaveAttribute('aria-disabled', 'true');
1017
+ });
1018
+
1019
+ it('keeps focus on an item that becomes disabled while focused', () => {
1020
+ const item = (isDisabled: boolean) => (
1021
+ <SideNavItem
1022
+ label="Dashboard"
1023
+ onClick={() => {}}
1024
+ isDisabled={isDisabled}
1025
+ />
1026
+ );
1027
+ const {rerender} = render(item(false));
1028
+ const button = screen.getByRole('button', {name: 'Dashboard'});
1029
+ button.focus();
1030
+ rerender(item(true));
1031
+ expect(screen.getByRole('button', {name: 'Dashboard'})).toHaveFocus();
971
1032
  });
972
1033
 
973
1034
  it('calls onClick handler', async () => {
@@ -2447,6 +2508,18 @@ describe('SideNavItem — mobile drawer close-on-activate', () => {
2447
2508
  expect(closeMobileNav).not.toHaveBeenCalled();
2448
2509
  });
2449
2510
 
2511
+ it('does not close the mobile nav when a disabled item is activated', async () => {
2512
+ const user = userEvent.setup();
2513
+ const {closeMobileNav} = renderInDrawer(
2514
+ <SideNavItem label="Home" href="/" isDisabled data-testid="item" />,
2515
+ );
2516
+ const item = screen.getByTestId('item');
2517
+ fireEvent.click(item);
2518
+ item.focus();
2519
+ await user.keyboard('{Enter}');
2520
+ expect(closeMobileNav).not.toHaveBeenCalled();
2521
+ });
2522
+
2450
2523
  it('does not close the mobile nav when a row action is clicked', async () => {
2451
2524
  const user = userEvent.setup();
2452
2525
  const onActionClick = vi.fn();
@@ -2495,7 +2568,7 @@ describe('SideNav keyboard operation', () => {
2495
2568
  expect(screen.getByRole('button', {name: 'Settings'})).toHaveFocus();
2496
2569
  });
2497
2570
 
2498
- it('skips a disabled item in the tab order', async () => {
2571
+ it('keeps a disabled item in the tab order', async () => {
2499
2572
  const user = userEvent.setup();
2500
2573
  render(
2501
2574
  <SideNav>
@@ -2508,6 +2581,8 @@ describe('SideNav keyboard operation', () => {
2508
2581
  await user.tab();
2509
2582
  expect(screen.getByRole('button', {name: 'Dashboard'})).toHaveFocus();
2510
2583
  await user.tab();
2584
+ expect(screen.getByRole('button', {name: 'Archived'})).toHaveFocus();
2585
+ await user.tab();
2511
2586
  expect(screen.getByRole('button', {name: 'Settings'})).toHaveFocus();
2512
2587
  });
2513
2588
 
@@ -4001,6 +4076,51 @@ describe('SideNavItem collapsed-from-mount rail tooltip', () => {
4001
4076
  expect(spy.mock.contexts).toContain(layer);
4002
4077
  });
4003
4078
 
4079
+ it('describes a disabled rail item on the control that takes focus', async () => {
4080
+ // spec:AST-085 FR5: the disabled control keeps its tab stop, so the label
4081
+ // tooltip has to describe it, not the row around it.
4082
+ renderRail(
4083
+ <SideNavItem
4084
+ label="Analytics (Coming Soon)"
4085
+ icon={StubIcon}
4086
+ href="/analytics"
4087
+ isDisabled
4088
+ />,
4089
+ );
4090
+ await act(async () => {});
4091
+ const button = screen.getByRole('button', {
4092
+ name: 'Analytics (Coming Soon)',
4093
+ });
4094
+ expect(button).not.toHaveAttribute('disabled');
4095
+ expect(button).toHaveAttribute('aria-disabled', 'true');
4096
+ expect(railTooltipFor(button)).toHaveTextContent('Analytics (Coming Soon)');
4097
+ expect(button.parentElement).not.toHaveAttribute('aria-describedby');
4098
+ });
4099
+
4100
+ it('shows a disabled rail item its label when Tab focuses it', async () => {
4101
+ // The row listens for the tooltip, but it is not focusable itself, so it
4102
+ // has to hear the focus that bubbles up from the control.
4103
+ const user = userEvent.setup();
4104
+ const spy = vi.spyOn(HTMLElement.prototype, 'showPopover');
4105
+ showPopover = spy;
4106
+ renderRail(
4107
+ <SideNavItem
4108
+ label="Analytics (Coming Soon)"
4109
+ icon={StubIcon}
4110
+ href="/analytics"
4111
+ isDisabled
4112
+ />,
4113
+ );
4114
+ await act(async () => {});
4115
+ const button = screen.getByRole('button', {
4116
+ name: 'Analytics (Coming Soon)',
4117
+ });
4118
+
4119
+ await user.tab();
4120
+ expect(button).toHaveFocus();
4121
+ expect(spy.mock.contexts).toContain(railTooltipFor(button));
4122
+ });
4123
+
4004
4124
  it('describes the link once a disabled item is enabled in the rail', () => {
4005
4125
  const item = (isDisabled: boolean) => (
4006
4126
  <SideNavItem
@@ -39,7 +39,7 @@ system_specs: [spec:AST-085]
39
39
  | Behavior | One SideNav row: a link or button with optional icon and passive end content, optional row-level actions beside it, and optional nested children that can expand and collapse. In a collapsed rail it is icon-only with a tooltip or a flyout. |
40
40
  | End-user impact | Users see and hear the current page, reach row actions before nested items, expand or collapse groups without navigating, and return to the page after choosing a destination in the mobile drawer. |
41
41
  | Builder impact | None. |
42
- | Compatibility/readiness | Records shipped behavior; no public change. Four implementation gaps (see Current-state impact). |
42
+ | Compatibility/readiness | Records shipped behavior; no public change. Three implementation gaps (see Current-state impact). |
43
43
  | Review checks | Reject an interactive control rendered inside the primary element, a link that also toggles collapse, a toggle or action that activates the row, collapsed children that stay reachable, or a drawer activation that leaves the drawer open after navigating. |
44
44
  | Governing rules | `component:SideNav` FR1–FR5, DD1 (nesting and row geometry); `family:navigation-destinations`; `architecture:interaction-modality`; `architecture:public-component-api`. |
45
45
 
@@ -68,10 +68,6 @@ Consumer migration instructions belong in consumer docs and release notes.
68
68
 
69
69
  ## Current-state impact
70
70
 
71
- - **A disabled item leaves the tab order.** A disabled item renders a
72
- `<button>` with the native `disabled` attribute, so it is skipped by Tab and
73
- exposes no focusable disabled state. `spec:AST-085` FR1 has a disabled control
74
- expose `aria-disabled="true"` and keep its tab stop.
75
71
  - **A disabled parent stays operable in a collapsed rail.** In a collapsed rail,
76
72
  a disabled item with children renders its flyout trigger with neither
77
73
  `disabled` nor `aria-disabled`, so it keeps its tab stop and announces no
@@ -125,7 +121,7 @@ Consumer migration instructions belong in consumer docs and release notes.
125
121
 
126
122
  | ID | Invariant | Basis |
127
123
  | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
128
- | FR1 | The primary element is the `as` component, else the `LinkProvider` link, else `<a>`, when `href` is set and the item is enabled; otherwise a `<button type="button">`. A disabled item MUST stay focusable with `aria-disabled="true"` and keep its tab stop (`spec:AST-085` FR1). Implementation gap: a disabled item renders native `disabled` instead, which removes it from the tab order. It shows the icon (`selectedIcon` when selected and given), the label, `endContent`, and, for a whole-row collapsible item, a chevron. `endContent` is passive and renders inside the primary element. | Current source and tests |
124
+ | FR1 | The primary element is the `as` component, else the `LinkProvider` link, else `<a>`, when `href` is set and the item is enabled; otherwise a `<button type="button">`. A disabled item stays focusable with `aria-disabled="true"`, keeps its tab stop, and keeps focus when it becomes disabled (`spec:AST-085` FR1, FR4). It shows the icon (`selectedIcon` when selected and given), the label, `endContent`, and, for a whole-row collapsible item, a chevron. `endContent` is passive and renders inside the primary element. | Current source and tests |
129
125
  | FR2 | In an expanded rail and on a collapsed leaf, `isSelected` sets `aria-current="page"` on the primary element, never on a row wrapper. A collapsed parent's flyout trigger carries no `aria-current`. | Current source and tests |
130
126
  | FR3 | A disabled item does not fire `onClick` or navigate. Its row actions stay operable. | Current source and tests |
131
127
  | FR4 | `actions` render as siblings of the primary element at the row's trailing edge, after the toggle and before nested children in DOM and focus order, at the row's control size (`sm`) unless a control sets its own (`architecture:component-size-cascade`). Clicking an action neither activates nor toggles the row. A row wrapper exists only when there are actions or an independent toggle; a falsy `actions` adds none. | Current source and tests |
@@ -146,16 +142,16 @@ Consumer migration instructions belong in consumer docs and release notes.
146
142
 
147
143
  ### Representative states
148
144
 
149
- | State | Required invariant | Allowed variation |
150
- | -------------------------- | -------------------------------------------------------------------------------------- | ----------------- |
151
- | Link, selected | `aria-current="page"` on the link; selected icon if given. | Fill. |
152
- | Disabled button | `aria-disabled`; focusable; no `onClick`. Implementation gap: native `disabled` (FR1). | Muted styling. |
153
- | Collapsible without action | Whole row toggles; chevron turns. | — |
154
- | Collapsible with `href` | Link navigates; separate toggle button collapses. | — |
155
- | Actions | Actions after toggle, before children; never activate the row. | Action content. |
156
- | Collapsed rail leaf | Icon-only; tooltip with label. | — |
157
- | Collapsed rail parent | Icon-only trigger; flyout with header and children. | Delay. |
158
- | Mobile drawer | Activation closes the drawer. | — |
145
+ | State | Required invariant | Allowed variation |
146
+ | -------------------------- | -------------------------------------------------------------- | ----------------- |
147
+ | Link, selected | `aria-current="page"` on the link; selected icon if given. | Fill. |
148
+ | Disabled button | `aria-disabled`; focusable; no `onClick`. | Muted styling. |
149
+ | Collapsible without action | Whole row toggles; chevron turns. | — |
150
+ | Collapsible with `href` | Link navigates; separate toggle button collapses. | — |
151
+ | Actions | Actions after toggle, before children; never activate the row. | Action content. |
152
+ | Collapsed rail leaf | Icon-only; tooltip with label. | — |
153
+ | Collapsed rail parent | Icon-only trigger; flyout with header and children. | Delay. |
154
+ | Mobile drawer | Activation closes the drawer. | — |
159
155
 
160
156
  ### Transformation and precedence order
161
157
 
@@ -211,16 +207,10 @@ is recorded. Row geometry visual intent is `component:SideNav` DD1.
211
207
  | FR11 | `SideNav.test.tsx` focus ring suite | Plain, split, actions rows | Missing shared ring classes fail. | `audit:SideNavItem/accessibility` |
212
208
  | FR3, FR7, FR9 | `SideNav.test.tsx` keyboard operation and focus management suites | Tab order, Enter/Space toggle, flyout Escape | Lost order, a stranded focus, or no focus return fails. | `audit:SideNavItem/accessibility` |
213
209
 
214
- Pending conformance: the SideNavItem suite's native-`disabled` assertion and
215
- the keyboard suite's disabled Tab-skip assertion pin FR1's implementation gap;
216
- they change when disabled items follow `spec:AST-085`.
217
-
218
210
  Read from source, with no focused test yet:
219
211
 
220
- - FR1 and ORD1–ORD2: a disabled item with `href` rendering a button, the
221
- required focusable `aria-disabled` shape (no item renders it yet), the
222
- button's `type="button"`, the icon, the selected-icon swap, and the whole-row
223
- chevron.
212
+ - FR1 and ORD1–ORD2: the button's `type="button"`, the icon, the
213
+ selected-icon swap, and the whole-row chevron.
224
214
  - FR4: the actions' trailing-edge placement and the wrapper for an independent
225
215
  toggle without actions.
226
216
  - FR5: the forwarded `ref` and `xstyle` on the outer box.
@@ -13,8 +13,8 @@
13
13
  * provided and fall back to `label` when it is missing or blank. Their label
14
14
  * tooltip binds to the interactive control through a callback ref, so hover,
15
15
  * keyboard focus, and `aria-describedby` follow the control from the first
16
- * collapsed render. A disabled control takes neither pointer nor focus, so its
17
- * row is the tooltip's trigger instead.
16
+ * collapsed render. A disabled control takes no pointer, so its row listens
17
+ * for the tooltip instead, while the control keeps focus and the description.
18
18
  *
19
19
  * Collapsed items with children open their submenu flyout through
20
20
  * `useMenuHover`, the shared hover-intent hook (same one `SideNavHeading` and
@@ -74,6 +74,7 @@ import {
74
74
  import {useSideNavRenderMode} from './SideNavRenderContext';
75
75
  import {useAppShellMobile} from '../AppShell/AppShellMobileContext';
76
76
  import {themeProps} from '../utils/themeProps';
77
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
77
78
  import {useTranslator} from '../i18n';
78
79
 
79
80
  import {useMergedRefs} from '../hooks/useMergedRefs';
@@ -324,6 +325,8 @@ interface NavItemElementProps {
324
325
  /**
325
326
  * Renders `<a>` (via LinkComponent) when `href` is set, otherwise `<button>`.
326
327
  * Centralizes the link-vs-button decision used across all SideNavItem paths.
328
+ * A disabled item is a `<button>` that keeps its tab stop and exposes
329
+ * `aria-disabled`, but is inert (spec:AST-085 FR1, FR2).
327
330
  */
328
331
  function NavItemElement({
329
332
  href,
@@ -351,8 +354,16 @@ function NavItemElement({
351
354
  ref={ref as React.Ref<HTMLButtonElement>}
352
355
  type="button"
353
356
  onClick={onClick}
354
- disabled={isDisabled}
355
- {...rest}>
357
+ {...rest}
358
+ {...(isDisabled
359
+ ? {
360
+ 'aria-disabled': true,
361
+ ...disabledInteractionProps(
362
+ rest.onKeyDown as
363
+ React.KeyboardEventHandler<HTMLButtonElement> | undefined,
364
+ ),
365
+ }
366
+ : null)}>
356
367
  {children}
357
368
  </button>
358
369
  );
@@ -539,6 +550,10 @@ export function SideNavItem({
539
550
  const isRailLeaf = isCollapsed && !hasChildren;
540
551
  const railTooltip = useTooltip({
541
552
  placement: 'end',
553
+ // A disabled leaf's row is the trigger (see the collapsed path). It is not
554
+ // focusable itself, so `auto` would never hear the focus that bubbles up
555
+ // from its control.
556
+ focusTrigger: 'always',
542
557
  isEnabled: isRailLeaf,
543
558
  isOpen: isRailLeaf ? undefined : false,
544
559
  });
@@ -702,14 +717,17 @@ export function SideNavItem({
702
717
 
703
718
  // Items without children: icon-only link or button, its label in a
704
719
  // tooltip. The control is the trigger, so the element that takes focus is
705
- // the one described. A disabled control takes neither pointer
706
- // (`navItemStyles.disabled`) nor focus, so its row is the trigger instead.
720
+ // the one described. A disabled control takes no pointer
721
+ // (`navItemStyles.disabled`), so its row listens for the tooltip instead;
722
+ // the control still takes focus (focusin reaches the row) and stays the
723
+ // element described (spec:AST-085 FR5).
707
724
  const isRowTrigger = isDisabled;
708
725
  const collapsedAriaProps = {
709
726
  'aria-current': isSelected ? ('page' as const) : undefined,
710
- 'aria-describedby': isRowTrigger
711
- ? rest['aria-describedby']
712
- : joinIds(rest['aria-describedby'], railTooltip.describedBy),
727
+ 'aria-describedby': joinIds(
728
+ rest['aria-describedby'],
729
+ railTooltip.describedBy,
730
+ ),
713
731
  'aria-disabled': isDisabled || undefined,
714
732
  'aria-label': collapsedAccessibleLabel,
715
733
  'data-testid': testId,
@@ -732,7 +750,6 @@ export function SideNavItem({
732
750
  return (
733
751
  <div
734
752
  ref={isRowTrigger ? railTooltip.ref : undefined}
735
- aria-describedby={isRowTrigger ? railTooltip.describedBy : undefined}
736
753
  {...stylex.props(styles.root, xstyle)}>
737
754
  {collapsedElement}
738
755
  {railTooltip.renderTooltip(label)}