@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
|
|
13
|
-
*
|
|
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;
|
|
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
|
|
17
|
-
*
|
|
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
|
|
481
|
-
// (`navItemStyles.disabled`)
|
|
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':
|
|
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.
|
|
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.
|
|
851
|
-
"@astryxdesign/cli": "0.7.6-canary.
|
|
852
|
-
"@astryxdesign/interaction-harness": "0.7.6-canary.
|
|
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
|
-
|
|
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).
|
|
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('
|
|
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.
|
|
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
|
|
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
|
|
150
|
-
| -------------------------- |
|
|
151
|
-
| Link, selected | `aria-current="page"` on the link; selected icon if given.
|
|
152
|
-
| Disabled button | `aria-disabled`; focusable; no `onClick`.
|
|
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.
|
|
156
|
-
| Collapsed rail leaf | Icon-only; tooltip with label.
|
|
157
|
-
| Collapsed rail parent | Icon-only trigger; flyout with header and children.
|
|
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:
|
|
221
|
-
|
|
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
|
|
17
|
-
*
|
|
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
|
-
|
|
355
|
-
{...
|
|
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
|
|
706
|
-
// (`navItemStyles.disabled`)
|
|
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':
|
|
711
|
-
|
|
712
|
-
|
|
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)}
|