Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mobilenav-toggle-expanded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] MobileNav: the toggle button now exposes `aria-expanded` and references the nav drawer via `aria-controls`, so screen-reader users can tell whether the drawer is open and what the button controls. (#3721)
@bhamodi
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function MobileNavToggleBasic() {
value={{
isMobile: true,
isMobileNavOpen: isOpen,
mobileNavId: 'demo-mobile-nav',
toggleMobileNav: () => setIsOpen(v => !v),
openMobileNav: () => setIsOpen(true),
closeMobileNav: () => setIsOpen(false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default function MobileNavToggleShowcase() {
value={{
isMobile: true,
isMobileNavOpen: isOpen,
mobileNavId: 'demo-mobile-nav',
toggleMobileNav: () => setIsOpen(v => !v),
openMobileNav: () => setIsOpen(true),
closeMobileNav: () => setIsOpen(false),
Expand All @@ -28,10 +29,7 @@ export default function MobileNavToggleShowcase() {
Page title
</Text>
</HStack>
<MobileNav
isOpen={isOpen}
onOpenChange={setIsOpen}
header="Navigation">
<MobileNav isOpen={isOpen} onOpenChange={setIsOpen} header="Navigation">
<SideNavSection title="Pages">
<SideNavItem label="Home" isSelected href="#" />
<SideNavItem label="Settings" href="#" />
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isValidElement,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -597,10 +598,14 @@ export function AppShell({
// =========================================================================
// Mobile context — shared with MobileNavToggle and future TopNav mobile
// =========================================================================
// Stable id shared by the drawer (applied as its `id`) and the toggle
// (referenced via `aria-controls`) so screen readers know what it controls.
const mobileNavId = useId();
const mobileContextValue = useMemo<AppShellMobileContextValue>(
() => ({
isMobile: isBelowBreakpoint,
isMobileNavOpen,
mobileNavId,
toggleMobileNav: () =>
mobileNavEnabled && setMobileNavOpen(!isMobileNavOpen),
openMobileNav: () => mobileNavEnabled && setMobileNavOpen(true),
Expand All @@ -611,6 +616,7 @@ export function AppShell({
[
isBelowBreakpoint,
isMobileNavOpen,
mobileNavId,
setMobileNavOpen,
mobileNavEnabled,
mobileNavHasToggle,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/AppShell/AppShellMobileContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export interface AppShellMobileContextValue {
isMobile: boolean;
/** Whether the mobile nav drawer is currently open */
isMobileNavOpen: boolean;
/**
* DOM id of the mobile nav drawer. Shared so the toggle can point its
* `aria-controls` at the drawer and the drawer can apply it as its `id`.
*/
mobileNavId: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're adding a new required property this can result in breaking surfaces currently interacting with this context. One way to tell is how we needed to update the tests and example Calais despite them not needing to apply the Aria controls. Curious if you think this is worth the breaking change? Otherwise we could make this optional and remember to apply the ID internally. The accessibility aspect is not fully realized until the toggle for the mobile nav is also consuming this ID.

/** Toggle the mobile nav drawer open/closed */
toggleMobileNav: () => void;
/** Open the mobile nav drawer */
Expand All @@ -35,6 +40,7 @@ export interface AppShellMobileContextValue {
const defaultValue: AppShellMobileContextValue = {
isMobile: false,
isMobileNavOpen: false,
mobileNavId: '',
toggleMobileNav: () => {},
openMobileNav: () => {},
closeMobileNav: () => {},
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/AppShell/useAppShellMobile.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export const docs = {
description:
'Whether the AppShell-managed mobile navigation drawer is open.',
},
{
name: 'mobileNavId',
type: 'string',
description:
'DOM id of the mobile navigation drawer. Point aria-controls of a custom toggle at this so screen-reader users know which element the toggle expands.',
},
{
name: 'toggleMobileNav',
type: '() => void',
Expand Down Expand Up @@ -111,6 +117,8 @@ export const docsDense = {
isMobile:
'viewport below AppShell mobile nav breakpoint? Use to sync AppShell-adjacent mobile UI',
isMobileNavOpen: 'AppShell-managed mobile nav drawer open?',
mobileNavId:
'DOM id of the drawer; point custom toggle aria-controls at it',
toggleMobileNav: 'toggle drawer; no-op when mobile nav disabled',
openMobileNav: 'open drawer; no-op when mobile nav disabled',
closeMobileNav: 'close drawer',
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/MobileNav/MobileNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -291,6 +292,10 @@ export function MobileNav({
// Read from AppShell context as fallback
const appShellMobile = useAppShellMobile();
const isOpen = isOpenProp ?? appShellMobile.isMobileNavOpen;
// Share the id from AppShell context so the toggle's aria-controls resolves to
// this drawer; fall back to a locally generated id when used standalone.
const fallbackId = useId();
const dialogId = appShellMobile.mobileNavId || fallbackId;
const onOpenChange = useMemo(
() =>
onOpenChangeProp ??
Expand Down Expand Up @@ -407,6 +412,7 @@ export function MobileNav({
return (
<dialog
ref={mergeRefs(ref, dialogRef)}
id={dialogId}
data-testid={testId}
aria-label={label ?? (typeof header === 'string' ? header : 'Navigation')}
onClick={handleDialogClick}
Expand Down
116 changes: 116 additions & 0 deletions packages/core/src/MobileNav/MobileNavToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file MobileNavToggle.test.tsx
* @input Uses vitest, @testing-library/react, AppShell + SideNav
* @output Unit tests for MobileNavToggle ARIA wiring
* @position Testing; validates MobileNavToggle.tsx exposes aria-expanded and
* aria-controls so screen-reader users know the drawer state and target.
*
* SYNC: When MobileNavToggle.tsx changes, update tests to match new behavior
*/

import {
describe,
it,
expect,
vi,
beforeAll,
beforeEach,
afterEach,
} from 'vitest';
import {render, screen, fireEvent} from '@testing-library/react';
import {AppShell} from '../AppShell/AppShell';
import {SideNav, SideNavItem, SideNavSection} from '../SideNav';

// jsdom doesn't implement showModal/close on <dialog>, so we mock them
beforeAll(() => {
HTMLDialogElement.prototype.showModal =
HTMLDialogElement.prototype.showModal ||
function (this: HTMLDialogElement) {
this.setAttribute('open', '');
};
HTMLDialogElement.prototype.close =
HTMLDialogElement.prototype.close ||
function (this: HTMLDialogElement) {
this.removeAttribute('open');
};
});

class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', MockResizeObserver);

function createMockMatchMedia(matches: boolean) {
return {
matches,
media: '',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}

beforeEach(() => {
// Below the breakpoint so the mobile nav (and its toggle) render.
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue(createMockMatchMedia(true)),
);
});

afterEach(() => {
vi.restoreAllMocks();
});

function TestShell() {
return (
<AppShell
sideNav={
<SideNav>
<SideNavSection title="Test" isHeaderHidden>
<SideNavItem label="Home" />
</SideNavSection>
</SideNav>
}
mobileNav={{breakpoint: 'md'}}>
<div>Content</div>
</AppShell>
);
}

describe('MobileNavToggle ARIA wiring', () => {
it('exposes aria-expanded="false" when the drawer is closed', () => {
render(<TestShell />);

const toggle = screen.getByRole('button', {name: /open navigation/i});
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});

it('exposes aria-expanded="true" after opening the drawer', () => {
render(<TestShell />);

const toggle = screen.getByRole('button', {name: /open navigation/i});
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
});

it('references the nav drawer via aria-controls that resolves to the dialog', () => {
render(<TestShell />);

const toggle = screen.getByRole('button', {name: /open navigation/i});
const controls = toggle.getAttribute('aria-controls');
expect(controls).toBeTruthy();

const target = document.getElementById(controls!);
expect(target).not.toBeNull();
expect(target).toBe(screen.getAllByRole('dialog', {hidden: true})[0]);
expect(target?.tagName).toBe('DIALOG');
});
});
11 changes: 9 additions & 2 deletions packages/core/src/MobileNav/MobileNavToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,13 @@ export function MobileNavToggle({
className,
style,
}: MobileNavToggleProps) {
const {isMobile, isMobileNavEnabled, toggleMobileNav} =
useAppShellMobile();
const {
isMobile,
isMobileNavEnabled,
isMobileNavOpen,
mobileNavId,
toggleMobileNav,
} = useAppShellMobile();

// Don't render above the breakpoint or when mobile nav is disabled
if (!isMobile || !isMobileNavEnabled) {
Expand All @@ -81,6 +86,8 @@ export function MobileNavToggle({
label={label}
icon={children ?? <Icon icon="menu" color="inherit" />}
onClick={toggleMobileNav}
aria-expanded={isMobileNavOpen}
aria-controls={mobileNavId || undefined}
data-testid={testId ?? 'mobile-nav-toggle'}
xstyle={xstyle}
className={className}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/SideNav/SideNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,7 @@ describe('SideNavItem — mobile drawer close-on-activate', () => {
value={{
isMobile: true,
isMobileNavOpen: true,
mobileNavId: 'test-mobile-nav',
toggleMobileNav: vi.fn(),
openMobileNav: vi.fn(),
closeMobileNav,
Expand Down Expand Up @@ -1172,6 +1173,7 @@ describe('SideNavItem — mobile drawer close-on-activate', () => {
value={{
isMobile: false,
isMobileNavOpen: false,
mobileNavId: 'test-mobile-nav',
toggleMobileNav: vi.fn(),
openMobileNav: vi.fn(),
closeMobileNav,
Expand Down
Loading