diff --git a/.changeset/mobilenav-toggle-expanded.md b/.changeset/mobilenav-toggle-expanded.md new file mode 100644 index 000000000000..69b24acdd982 --- /dev/null +++ b/.changeset/mobilenav-toggle-expanded.md @@ -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 diff --git a/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx b/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx index cc04a2b60e5d..3b11466eecea 100644 --- a/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +++ b/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx @@ -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), diff --git a/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleShowcase.tsx b/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleShowcase.tsx index 4291175a9a77..33329f48e46a 100644 --- a/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleShowcase.tsx +++ b/packages/cli/templates/blocks/components/MobileNavToggle/MobileNavToggleShowcase.tsx @@ -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), @@ -28,10 +29,7 @@ export default function MobileNavToggleShowcase() { Page title - + diff --git a/packages/core/src/AppShell/AppShell.tsx b/packages/core/src/AppShell/AppShell.tsx index 890ef05c6c74..e5244c414a67 100644 --- a/packages/core/src/AppShell/AppShell.tsx +++ b/packages/core/src/AppShell/AppShell.tsx @@ -24,6 +24,7 @@ import { isValidElement, useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -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( () => ({ isMobile: isBelowBreakpoint, isMobileNavOpen, + mobileNavId, toggleMobileNav: () => mobileNavEnabled && setMobileNavOpen(!isMobileNavOpen), openMobileNav: () => mobileNavEnabled && setMobileNavOpen(true), @@ -611,6 +616,7 @@ export function AppShell({ [ isBelowBreakpoint, isMobileNavOpen, + mobileNavId, setMobileNavOpen, mobileNavEnabled, mobileNavHasToggle, diff --git a/packages/core/src/AppShell/AppShellMobileContext.tsx b/packages/core/src/AppShell/AppShellMobileContext.tsx index 99d9dec12598..af3f80911b15 100644 --- a/packages/core/src/AppShell/AppShellMobileContext.tsx +++ b/packages/core/src/AppShell/AppShellMobileContext.tsx @@ -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; /** Toggle the mobile nav drawer open/closed */ toggleMobileNav: () => void; /** Open the mobile nav drawer */ @@ -35,6 +40,7 @@ export interface AppShellMobileContextValue { const defaultValue: AppShellMobileContextValue = { isMobile: false, isMobileNavOpen: false, + mobileNavId: '', toggleMobileNav: () => {}, openMobileNav: () => {}, closeMobileNav: () => {}, diff --git a/packages/core/src/AppShell/useAppShellMobile.doc.mjs b/packages/core/src/AppShell/useAppShellMobile.doc.mjs index 8d86e8a779b9..7c2e0fbdbe22 100644 --- a/packages/core/src/AppShell/useAppShellMobile.doc.mjs +++ b/packages/core/src/AppShell/useAppShellMobile.doc.mjs @@ -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', @@ -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', diff --git a/packages/core/src/MobileNav/MobileNav.tsx b/packages/core/src/MobileNav/MobileNav.tsx index 2256186b9f58..a6084609952d 100644 --- a/packages/core/src/MobileNav/MobileNav.tsx +++ b/packages/core/src/MobileNav/MobileNav.tsx @@ -29,6 +29,7 @@ import { useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -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 ?? @@ -407,6 +412,7 @@ export function MobileNav({ return ( , 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 ( + + + + + + } + mobileNav={{breakpoint: 'md'}}> +
Content
+
+ ); +} + +describe('MobileNavToggle ARIA wiring', () => { + it('exposes aria-expanded="false" when the drawer is closed', () => { + render(); + + 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(); + + 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(); + + 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'); + }); +}); diff --git a/packages/core/src/MobileNav/MobileNavToggle.tsx b/packages/core/src/MobileNav/MobileNavToggle.tsx index 61d88aed254f..83688e24f610 100644 --- a/packages/core/src/MobileNav/MobileNavToggle.tsx +++ b/packages/core/src/MobileNav/MobileNavToggle.tsx @@ -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) { @@ -81,6 +86,8 @@ export function MobileNavToggle({ label={label} icon={children ?? } onClick={toggleMobileNav} + aria-expanded={isMobileNavOpen} + aria-controls={mobileNavId || undefined} data-testid={testId ?? 'mobile-nav-toggle'} xstyle={xstyle} className={className} diff --git a/packages/core/src/SideNav/SideNav.test.tsx b/packages/core/src/SideNav/SideNav.test.tsx index 6c75c34105aa..5ace77041f92 100644 --- a/packages/core/src/SideNav/SideNav.test.tsx +++ b/packages/core/src/SideNav/SideNav.test.tsx @@ -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, @@ -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,