From a411cb4ebcfb4f1f05b3883721aa55f3d72bc701 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 13 Aug 2026 23:16:20 -0700 Subject: [PATCH] fix: focus main after SPA navigation --- docs/officers/PUBLISH_AND_CHECK.md | 85 +++++ src/App.jsx | 2 +- src/App.test.jsx | 514 ++++++++++++++++++++++++++++- src/components/ScrollToTop.jsx | 63 +++- src/headerClearance.test.js | 59 +++- src/index.css | 14 + 6 files changed, 731 insertions(+), 6 deletions(-) diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index 869d2bd9..f4abd204 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -413,6 +413,91 @@ Do not use this section until #133 records that both GitHub environments are pro **Escalation:** platform owner first, then the accessibility reviewer or backup release officer. Treat an unexpected live publication as a hosting incident. +### Verify focus after a client-side page change — WEB-UX-004 SOURCE CHECK AVAILABLE; LIVE CHECK NOT AVAILABLE YET + +**Purpose:** prove that a keyboard user who opens another page without a full browser reload moves from the old navigation control into the new main content. The source must not move focus on the first page load or take focus that the new page or user already chose. A client-side page change replaces the page content while the website stays open. + +**Approver:** the pull-request accessibility reviewer approves the source evidence. The named release observer approves a later public check, with the platform owner available if the revision or publication record is unclear. + +**Prerequisites:** issue #657; one exact reviewed pull request and commit; the recorded old-source failure; the named green WEB-UX-004 and full App results; and a reviewed undo. The source check uses GitHub and test summaries only. It needs no terminal, account, sign-in, private page, or real data. The later public check also requires a separately approved exact commit, host readback for that commit, a private browser window, a keyboard, and safe signed-out public pages. Protected publication is still **NOT AVAILABLE YET** under #133/#136. At the time this procedure was added, #623 Netlify deploy `6a7e072f8f346b0008510d29`, source `c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec`, remained live. Do not call WEB-UX-004 live while that remains the host record. + +**Specialist dependency:** the platform maintainer must supply the exact issue, pull request, commit, test record, and later host readback. The officer can perform every browser step without a terminal. No Firebase or provider specialist action belongs to this check because those surfaces do not change. + +**Source check:** + +1. Ask the platform maintainer for issue #657, the pull request, and the exact commit. +2. Open the pull request's **Files changed** view. +3. Confirm it changes only the two focus source files, the existing App test file, the global stylesheet, the header-clearance test, and this guide. +4. Read the recorded old-source failure. +5. Confirm that failure says the persistent navigation link kept focus instead of the main content. +6. Read the named WEB-UX-004 green result. +7. Confirm the green result covers first loads without focus movement. +8. Confirm the green result covers a new path moving stale or lost focus to the existing main content once. +9. Confirm the green result covers search-only, page-section-only, and same-path state-only changes without a handoff. +10. Confirm the green result preserves focus chosen by the destination page or user. +11. Confirm the green result makes canceled, replaced, missing, and unmounted destinations inert. +12. Confirm the green result preserves the skip link and the released phone-menu close behavior. +13. Confirm the green result reports no added website service call. +14. Read the full App result and the hosted required checks for the same exact commit. +15. Record source changed, tests passed, and merge state separately. + +**Later signed-out public check — NOT AVAILABLE YET:** + +16. Open the approved public page in a private browser window at 1280 CSS pixels wide. +17. Confirm the host identifies the exact approved commit. +18. Stay signed out. +19. Keep every form empty. +20. Press `Tab` once after the direct page load. +21. Confirm the skip link appears instead of focus jumping into main content. +22. Use `Tab` to reach a public desktop navigation link. +23. Press `Enter` once to open a different public page. +24. Confirm the old navigation link no longer owns focus. +25. Confirm the new main content owns focus with the visible yellow-and-dark scoped cue. +26. Confirm the cue is visible, unclipped, and does not move the layout. +27. Press `Tab` once. +28. Confirm focus continues to the first normal destination control, or the next normal page control when the destination has none. +29. Use the browser's keyboard Back command once. +30. Confirm focus returns to the current page's main content. +31. Use the browser's keyboard Forward command once. +32. Confirm focus returns to the current page's main content. +33. Change the browser view to 390 CSS pixels wide. +34. Reload one safe public page. +35. Press `Tab` once. +36. Confirm the skip link appears and the first load did not focus main. +37. Use the keyboard to open the phone navigation menu. +38. Use the keyboard to activate one safe public destination. +39. Confirm the phone menu closes. +40. Confirm focus moves to the new main content with the same visible, unclipped scoped cue. +41. Press `Tab` once. +42. Confirm focus enters the destination order and does not return to a hidden menu link. +43. Record the exact commit, pages, browser, date, and both checked widths. +44. Save one redacted public screenshot at each width. +45. Record website publication, exact `runmprc.com` revision, Firebase, outside providers, accounts and sign-in, production data, and live behavior as separate states. + +**Expected result:** source evidence proves one focus handoff after a real path change, no handoff on first load or same-path cleanup, preservation of newer destination or user focus, and inert stale work. A later approved public check shows main focus at 1280 and 390 CSS pixels, a visible unclipped scoped cue alongside unchanged #291 behavior, normal next-`Tab` order, and the released phone menu closing. This interface change does not deploy Firebase, configure a provider, use an account, sign in, read or change production data, or prove live behavior. + +**Stop conditions:** stop if the issue, commit, review, old-source failure, or green results are missing or mismatched. Stop if the host still identifies #623 or another revision. Stop if anyone asks for sign-in, a private page, a form submission, a real name, member data, payment data, or a provider action. Stop if initial load moves focus, old or hidden navigation keeps focus, destination-chosen focus is replaced, the scoped cue is missing or clipped, focus becomes trapped, the next `Tab` order is wrong, or the layout moves. Stop if a merge, workflow, preview, or screenshot is called proof of publication or live behavior. + +**Success proof:** keep the issue, pull request, exact commit, named review, old-source failure, green WEB-UX-004 and full App results, hosted checks, checked public pages, browser, date, widths, and two redacted screenshots. Complete every line below without combining states: + +```text +Source changed: +Tests passed: +Code merged: +Deploy Preview checked: +Website published: +runmprc.com exact revision verified: +Firebase deployed: +Outside providers configured or verified: +Account or sign-in action: +Production data action: +Live route-focus behavior verified: +``` + +**Undo:** if the change is not published, open one tracked issue and reviewed pull request to revert or safely correct the exact focus source. If a later approved publication is wrong, use the reviewed release rollback or safe roll-forward for that exact revision and repeat this check. Do not edit live CSS, browser files, Firebase, or provider settings by hand. Do not publish only to test an undo. + +**Escalation:** accessibility reviewer first, then the platform owner or backup release officer. Treat an unexpected publication, unknown revision, account prompt, private-data exposure, Firebase action, or provider action as an incident and follow the private escalation path. + ## Expected result Merge, release approval, Firebase deployment, backend verification, Pages publication, Netlify publication, `runmprc.com`, and provider verification are recorded as separate states. A backend failure or missing authority leaves the website unpublished. diff --git a/src/App.jsx b/src/App.jsx index 9e5188c1..65f18a66 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -59,7 +59,7 @@ function App() { -
+
} /> diff --git a/src/App.test.jsx b/src/App.test.jsx index cd3ec654..efcff535 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -1,6 +1,7 @@ /* eslint-env jest */ import React from 'react'; +import PropTypes from 'prop-types'; import { readFileSync } from 'fs'; import { join } from 'path'; import { @@ -11,7 +12,15 @@ import { waitFor, within, } from '@testing-library/react'; -import { resolvePath } from 'react-router-dom'; +import { + Link, + MemoryRouter, + Navigate, + Route, + Routes, + resolvePath, + useNavigate, +} from 'react-router-dom'; import { useServiceLocator } from './services/ServiceLocatorContext'; import { createCheckoutSession, @@ -48,6 +57,7 @@ import { } from './services/shop/shopService'; import { createJoinUsPageSchema } from './services/seo/structuredData'; import App from './App'; +import ScrollToTop from './components/ScrollToTop'; const PUBLIC_INDEX_SOURCE = readFileSync( join(__dirname, '../public/index.html'), @@ -222,6 +232,508 @@ test('renders the MPRC home route without contacting Firebase', () => { } }); +const ROUTE_FOCUS_FUTURE_FLAGS = Object.freeze({ + v7_relativeSplatPath: true, + v7_startTransition: true, +}); + +function DestinationFocus({ mode }) { + const targetRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (mode === 'layout') targetRef.current?.focus(); + }, [mode]); + + React.useEffect(() => { + if (mode === 'passive') targetRef.current?.focus(); + }, [mode]); + + return ; +} + +DestinationFocus.propTypes = { + mode: PropTypes.oneOf([null, 'layout', 'passive']), +}; + +function RouteFocusHarness({ + destinationFocus = null, + mainKey = 'current-main', + showMain = true, + showOutsideFocus = true, +}) { + const navigate = useNavigate(); + + return ( + <> + Persistent navigation + Path with search and hash + Middle navigation + Redirect navigation + + + + + + + + {showOutsideFocus && } + + {showMain && ( +
+ + + +

Origin page

+ + + )} + /> + Middle page} /> + } /> + +

Destination page

+ + + )} + /> +
+
+ )} + + ); +} + +RouteFocusHarness.propTypes = { + destinationFocus: PropTypes.oneOf([null, 'layout', 'passive']), + mainKey: PropTypes.string, + showMain: PropTypes.bool, + showOutsideFocus: PropTypes.bool, +}; + +function PassiveDestinationBeforeScrollHarness() { + return ( + <> + Passive-focus navigation +
+ + Passive origin} /> + +

Passive destination

+ + + )} + /> +
+
+ + + ); +} + +function routeFocusTree({ + destinationFocus = null, + initialEntries = ['/'], + initialIndex, + mainKey = 'current-main', + showMain = true, + showOutsideFocus = true, + strict = false, +} = {}) { + const tree = ( + + + + ); + return strict ? {tree} : tree; +} + +describe('WEB-UX-004 SPA route focus handoff', () => { + let allFrames; + let nextFrameId; + let pendingFrames; + + beforeEach(() => { + allFrames = new Map(); + nextFrameId = 0; + pendingFrames = new Map(); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + nextFrameId += 1; + allFrames.set(nextFrameId, callback); + pendingFrames.set(nextFrameId, callback); + return nextFrameId; + }); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { + pendingFrames.delete(frameId); + }); + jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function invokeFrame(frameId) { + const callback = allFrames.get(frameId); + expect(callback).toEqual(expect.any(Function)); + pendingFrames.delete(frameId); + act(() => callback(16)); + } + + function invokeOnlyPendingFrame() { + expect(pendingFrames.size).toBe(1); + invokeFrame([...pendingFrames.keys()][0]); + } + + test('keeps initial and deep-link rendering focus-inert while preserving scroll', () => { + const initial = render(routeFocusTree()); + expect(window.scrollTo).toHaveBeenCalledTimes(1); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + expect(window.requestAnimationFrame).not.toHaveBeenCalled(); + expect(screen.getByRole('main')).not.toHaveFocus(); + + initial.unmount(); + render(routeFocusTree({ initialEntries: ['/destination?view=full#summary'] })); + expect(window.scrollTo).toHaveBeenCalledTimes(2); + expect(window.requestAnimationFrame).not.toHaveBeenCalled(); + expect(screen.getByRole('main')).not.toHaveFocus(); + }); + + test('ignores search, hash, state, and same-path replacement changes', () => { + render(routeFocusTree()); + + ['Change search', 'Change hash', 'Change state', 'Replace same path'].forEach((name) => { + fireEvent.click(screen.getByRole('button', { name })); + }); + + expect(window.scrollTo).toHaveBeenCalledTimes(1); + expect(window.requestAnimationFrame).not.toHaveBeenCalled(); + expect(screen.getByRole('main')).not.toHaveFocus(); + }); + + test('moves an unchanged persistent navigation origin to main exactly once', () => { + render(routeFocusTree()); + const main = screen.getByRole('main'); + const mainFocus = jest.spyOn(main, 'focus'); + const navigation = screen.getByRole('link', { name: 'Persistent navigation' }); + navigation.focus(); + + fireEvent.click(navigation); + expect(screen.getByRole('heading', { name: 'Destination page' })) + .toBeInTheDocument(); + const frameId = [...pendingFrames.keys()][0]; + invokeFrame(frameId); + + expect(main).toHaveFocus(); + expect(mainFocus).toHaveBeenCalledTimes(1); + expect(mainFocus).toHaveBeenCalledWith({ preventScroll: true }); + expect(window.scrollTo).toHaveBeenCalledTimes(2); + invokeFrame(frameId); + expect(mainFocus).toHaveBeenCalledTimes(1); + }); + + test('restores body, document-root, null, and disconnected focus fallbacks to main', () => { + render(routeFocusTree()); + const main = screen.getByRole('main'); + + const navigateAndSetActive = (active) => { + const middle = screen.getByRole('link', { name: 'Middle navigation' }); + middle.focus(); + fireEvent.click(middle); + const activeSpy = jest.spyOn(document, 'activeElement', 'get').mockReturnValue(active); + invokeOnlyPendingFrame(); + activeSpy.mockRestore(); + expect(main).toHaveFocus(); + fireEvent.click(screen.getByRole('button', { name: 'Back navigation' })); + invokeOnlyPendingFrame(); + expect(main).toHaveFocus(); + }; + + navigateAndSetActive(document.body); + navigateAndSetActive(document.documentElement); + navigateAndSetActive(null); + navigateAndSetActive(document.createElement('button')); + }); + + test('recovers after the focused outgoing route control is disconnected', () => { + render(routeFocusTree()); + const outgoing = screen.getByRole('button', { name: 'Outgoing route control' }); + outgoing.focus(); + + fireEvent.click(screen.getByRole('button', { name: 'Programmatic destination' })); + expect(outgoing.isConnected).toBe(false); + invokeOnlyPendingFrame(); + + expect(screen.getByRole('main')).toHaveFocus(); + }); + + test('does not refocus main or replace its connected descendant focus', () => { + render(routeFocusTree()); + const main = screen.getByRole('main'); + const mainFocus = jest.spyOn(main, 'focus'); + main.focus(); + mainFocus.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Programmatic destination' })); + invokeOnlyPendingFrame(); + expect(main).toHaveFocus(); + expect(mainFocus).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Back navigation' })); + invokeOnlyPendingFrame(); + const descendant = screen.getByRole('button', { name: 'Persistent main control' }); + descendant.focus(); + fireEvent.click(screen.getByRole('button', { name: 'Programmatic destination' })); + invokeOnlyPendingFrame(); + expect(descendant).toHaveFocus(); + expect(mainFocus).not.toHaveBeenCalled(); + }); + + test.each(['layout', 'passive'])( + 'never focuses main when the destination owns %s-effect focus', + (destinationFocus) => { + render(routeFocusTree({ destinationFocus })); + const main = screen.getByRole('main'); + const mainFocus = jest.spyOn(main, 'focus'); + const navigation = screen.getByRole('link', { name: 'Persistent navigation' }); + navigation.focus(); + + fireEvent.click(navigation); + const destination = screen.getByRole('button', { name: 'Destination focus' }); + expect(destination).toHaveFocus(); + expect(mainFocus).not.toHaveBeenCalled(); + invokeOnlyPendingFrame(); + expect(destination).toHaveFocus(); + expect(mainFocus).not.toHaveBeenCalled(); + }, + ); + + test('schedules settlement after destination passive focus work', () => { + window.requestAnimationFrame.mockImplementation((callback) => { + nextFrameId += 1; + callback(16); + return nextFrameId; + }); + render( + + + , + ); + const main = screen.getByRole('main'); + const mainFocus = jest.spyOn(main, 'focus'); + const navigation = screen.getByRole('link', { name: 'Passive-focus navigation' }); + navigation.focus(); + + fireEvent.click(navigation); + + expect(screen.getByRole('button', { name: 'Destination focus' })).toHaveFocus(); + expect(mainFocus).not.toHaveBeenCalled(); + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + test('consumes a deliberate outside-focus handoff without a delayed retry', () => { + const view = render(routeFocusTree()); + const main = screen.getByRole('main'); + const mainFocus = jest.spyOn(main, 'focus'); + const navigation = screen.getByRole('link', { name: 'Persistent navigation' }); + navigation.focus(); + fireEvent.click(navigation); + + const outside = screen.getByRole('button', { name: 'Persistent outside focus' }); + outside.focus(); + invokeOnlyPendingFrame(); + expect(outside).toHaveFocus(); + expect(mainFocus).not.toHaveBeenCalled(); + + view.rerender(routeFocusTree({ showOutsideFocus: false })); + expect(document.body).toHaveFocus(); + view.rerender(routeFocusTree()); + expect(mainFocus).not.toHaveBeenCalled(); + expect(main).not.toHaveFocus(); + }); + + test('generation-fences a canceled intermediate transition', () => { + render(routeFocusTree()); + const main = screen.getByRole('main'); + const mainFocus = jest.spyOn(main, 'focus'); + const middle = screen.getByRole('link', { name: 'Middle navigation' }); + middle.focus(); + fireEvent.click(middle); + const middleFrame = [...pendingFrames.keys()][0]; + + fireEvent.click(screen.getByRole('link', { name: 'Persistent navigation' })); + const destinationFrame = [...pendingFrames.keys()][0]; + expect(destinationFrame).not.toBe(middleFrame); + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(middleFrame); + + invokeFrame(middleFrame); + expect(mainFocus).not.toHaveBeenCalled(); + invokeFrame(destinationFrame); + expect(main).toHaveFocus(); + expect(mainFocus).toHaveBeenCalledTimes(1); + }); + + test('coalesces a synchronous redirect onto the final pathname', async () => { + render(routeFocusTree()); + const main = screen.getByRole('main'); + const mainFocus = jest.spyOn(main, 'focus'); + const redirect = screen.getByRole('link', { name: 'Redirect navigation' }); + redirect.focus(); + fireEvent.click(redirect); + + expect(await screen.findByRole('heading', { name: 'Destination page' })) + .toBeInTheDocument(); + expect(allFrames.size).toBeGreaterThanOrEqual(2); + const frameIds = [...allFrames.keys()]; + frameIds.slice(0, -1).forEach((frameId) => { + invokeFrame(frameId); + expect(mainFocus).not.toHaveBeenCalled(); + }); + invokeFrame(frameIds[frameIds.length - 1]); + expect(main).toHaveFocus(); + expect(mainFocus).toHaveBeenCalledTimes(1); + }); + + test('consumes a replaced main identity without focusing the replacement later', () => { + const view = render(routeFocusTree()); + const oldMain = screen.getByRole('main'); + const oldFocus = jest.spyOn(oldMain, 'focus'); + fireEvent.click(screen.getByRole('link', { name: 'Persistent navigation' })); + const frameId = [...pendingFrames.keys()][0]; + + view.rerender(routeFocusTree({ mainKey: 'replacement-main' })); + const replacement = screen.getByRole('main'); + const replacementFocus = jest.spyOn(replacement, 'focus'); + expect(oldMain.isConnected).toBe(false); + invokeFrame(frameId); + + expect(oldFocus).not.toHaveBeenCalled(); + expect(replacementFocus).not.toHaveBeenCalled(); + view.rerender(routeFocusTree({ mainKey: 'third-main' })); + expect(screen.getByRole('main')).not.toHaveFocus(); + }); + + test('consumes a missing main target without focusing one added later', () => { + const view = render(routeFocusTree({ showMain: false })); + const navigation = screen.getByRole('link', { name: 'Persistent navigation' }); + navigation.focus(); + fireEvent.click(navigation); + invokeOnlyPendingFrame(); + + view.rerender(routeFocusTree()); + expect(screen.getByRole('main')).not.toHaveFocus(); + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + test('cancels and fences a hostile callback after unmount', () => { + const view = render(routeFocusTree()); + fireEvent.click(screen.getByRole('link', { name: 'Persistent navigation' })); + const frameId = [...pendingFrames.keys()][0]; + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + + try { + view.unmount(); + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(frameId); + invokeFrame(frameId); + expect(outside).toHaveFocus(); + } finally { + outside.remove(); + } + }); + + test('keeps StrictMode initial replay focus-inert', () => { + render(routeFocusTree({ strict: true })); + expect(window.requestAnimationFrame).not.toHaveBeenCalled(); + expect(screen.getByRole('main')).not.toHaveFocus(); + }); + + test('hands off POP backward and forward pathname changes', () => { + render(routeFocusTree({ + initialEntries: ['/', '/destination'], + initialIndex: 1, + })); + const main = screen.getByRole('main'); + + const back = screen.getByRole('button', { name: 'Back navigation' }); + back.focus(); + fireEvent.click(back); + invokeOnlyPendingFrame(); + expect(main).toHaveFocus(); + + const forward = screen.getByRole('button', { name: 'Forward navigation' }); + forward.focus(); + fireEvent.click(forward); + invokeOnlyPendingFrame(); + expect(main).toHaveFocus(); + }); + + test('treats a pathname with search and hash as one new-page handoff', () => { + render(routeFocusTree()); + const navigation = screen.getByRole('link', { name: 'Path with search and hash' }); + navigation.focus(); + fireEvent.click(navigation); + invokeOnlyPendingFrame(); + + expect(screen.getByRole('main')).toHaveFocus(); + expect(window.scrollTo).toHaveBeenCalledTimes(2); + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + test('keeps the real skip-link/mobile-close shell and moves hidden-origin focus to main', () => { + render(); + const main = screen.getByRole('main'); + expect(main).toHaveAttribute('id', 'main-content'); + expect(main).toHaveAttribute('tabindex', '-1'); + expect(screen.getByRole('link', { name: /skip to content/i })) + .toHaveAttribute('href', '#main-content'); + + fireEvent.click(screen.getByRole('button', { name: 'Open navigation menu' })); + const primaryNavigation = document.getElementById('primary-navigation'); + const shop = within(primaryNavigation).getByRole('link', { name: 'Shop' }); + shop.focus(); + fireEvent.click(shop); + expect(shop.closest('ul')).toHaveClass('hide__nav'); + expect(pendingFrames.size).toBeGreaterThanOrEqual(1); + [...pendingFrames.keys()].forEach(invokeFrame); + + expect(main).toHaveFocus(); + expect(track).not.toHaveBeenCalled(); + expect(listActiveProducts).not.toHaveBeenCalled(); + expect(getProductBySlug).not.toHaveBeenCalled(); + expect(createMerchCheckout).not.toHaveBeenCalled(); + expect(listPublicEvents).not.toHaveBeenCalled(); + }); +}); + describe('WEB-SUGGESTIONS-001A static Suggestions page', () => { let originalFetch; let originalSendBeacon; diff --git a/src/components/ScrollToTop.jsx b/src/components/ScrollToTop.jsx index 4e2706d5..57a36496 100644 --- a/src/components/ScrollToTop.jsx +++ b/src/components/ScrollToTop.jsx @@ -1,13 +1,74 @@ -import { useEffect } from 'react'; +import { useEffect, useLayoutEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; function ScrollToTop() { const { pathname } = useLocation(); + const previousPathnameRef = useRef(pathname); + const focusIntentRef = useRef(null); useEffect(() => { window.scrollTo(0, 0); }, [pathname]); + useLayoutEffect(() => { + if (previousPathnameRef.current === pathname) return undefined; + previousPathnameRef.current = pathname; + + const intent = { + frameId: null, + main: document.getElementById('main-content'), + origin: document.activeElement, + }; + focusIntentRef.current = intent; + + return () => { + if (focusIntentRef.current === intent) focusIntentRef.current = null; + if (intent.frameId !== null) { + window.cancelAnimationFrame(intent.frameId); + intent.frameId = null; + } + }; + }, [pathname]); + + useEffect(() => { + const intent = focusIntentRef.current; + if (intent === null) return undefined; + + intent.frameId = window.requestAnimationFrame(() => { + if (focusIntentRef.current !== intent) return; + + focusIntentRef.current = null; + intent.frameId = null; + + const { main, origin } = intent; + if ( + !(main instanceof HTMLElement) + || !main.isConnected + || document.getElementById('main-content') !== main + ) return; + + const active = document.activeElement; + if (active === main || (active instanceof Node && main.contains(active))) return; + + const hasNewConnectedFocus = active instanceof Element + && active.isConnected + && active !== document.body + && active !== document.documentElement + && active !== origin; + if (hasNewConnectedFocus) return; + + main.focus({ preventScroll: true }); + }); + + return () => { + if (focusIntentRef.current === intent) focusIntentRef.current = null; + if (intent.frameId !== null) { + window.cancelAnimationFrame(intent.frameId); + intent.frameId = null; + } + }; + }, [pathname]); + return null; } diff --git a/src/headerClearance.test.js b/src/headerClearance.test.js index 9dc8c2e0..aaa37786 100644 --- a/src/headerClearance.test.js +++ b/src/headerClearance.test.js @@ -214,9 +214,9 @@ describe('global keyboard focus visibility', () => { }); test('provides a bounded two-tone focus-visible indicator', () => { - const globalFocusRules = stylesheetRules.filter(({ selector }) => ( - selector.includes(':focus-visible') - )); + const globalFocusRules = stylesheetRules.filter( + ({ selector }) => selector === approvedFocusSelector, + ); expect(globalFocusRules).toHaveLength(1); expect(globalFocusRules[0].selector).toBe(approvedFocusSelector); @@ -249,6 +249,59 @@ describe('global keyboard focus visibility', () => { expect(Number(shadow?.[1])).toBeLessThanOrEqual(8); }); + test('keeps the focused main cue inside the viewport below fixed navigation', () => { + const mainFocusRules = stylesheetRules.filter( + ({ selector }) => selector === '#main-content:focus-visible::after', + ); + expect(mainFocusRules).toHaveLength(1); + + const [{ declarations }] = mainFocusRules; + expect(getDeclarationValues(declarations, 'content')).toEqual(['""']); + expect(getDeclarationValues(declarations, 'position')).toEqual(['fixed']); + const [cueZIndex] = getDeclarationValues(declarations, 'z-index'); + expect(cueZIndex).toBe('98'); + const [navigationZIndex] = getDeclarationValues( + getRule(readStylesheet('components', 'navbar.css'), 'nav'), + 'z-index', + ); + expect(navigationZIndex).toBe('99'); + expect(Number(cueZIndex)).toBeLessThan(Number(navigationZIndex)); + expect(getDeclarationValues(declarations, 'pointer-events')).toEqual(['none']); + expect(getDeclarationValues(declarations, 'box-sizing')).toEqual(['border-box']); + expect(getDeclarationValues(declarations, 'border')).toEqual([ + '3px solid var(--color-secondary)', + ]); + expect(getDeclarationValues(declarations, 'box-shadow')).toEqual([ + '0 0 0 3px var(--color-gray-600)', + ]); + + const focusRule = stylesheetRules.find( + ({ selector }) => selector === approvedFocusSelector, + ); + const outlineWidth = getDeclarationValues(focusRule.declarations, 'outline')[0] + .match(/^(\d+(?:\.\d+)?)px\s/)?.[1]; + const outlineOffset = getDeclarationValues( + focusRule.declarations, + 'outline-offset', + )[0].match(/^(\d+(?:\.\d+)?)px$/)?.[1]; + const shadowSpread = getDeclarationValues(focusRule.declarations, 'box-shadow')[0] + .match(/^0\s+0\s+0\s+(\d+(?:\.\d+)?)px\s/)?.[1]; + const decorationExtent = Math.max( + Number(outlineWidth) + Number(outlineOffset), + Number(shadowSpread), + ); + + ['right', 'bottom', 'left'].forEach((property) => { + const inset = getDeclarationValues(declarations, property)[0] + .match(/^(\d+(?:\.\d+)?)px$/)?.[1]; + expect(Number(inset)).toBeGreaterThanOrEqual(decorationExtent); + }); + const top = getDeclarationValues(declarations, 'top'); + expect(top).toEqual([ + `calc(var(--site-nav-height) + ${decorationExtent}px)`, + ]); + }); + test('has no competing outline suppression in the global stylesheet', () => { const suppressingRules = stylesheetRules .filter(({ declarations }) => getDeclarations(declarations).some(suppressesOutline)) diff --git a/src/index.css b/src/index.css index 9241cb4e..e1a59a22 100644 --- a/src/index.css +++ b/src/index.css @@ -76,6 +76,20 @@ body { padding-top: var(--site-nav-height); } +#main-content:focus-visible::after { + content: ""; + position: fixed; + z-index: 98; + top: calc(var(--site-nav-height) + 6px); + right: 6px; + bottom: 6px; + left: 6px; + box-sizing: border-box; + border: 3px solid var(--color-secondary); + box-shadow: 0 0 0 3px var(--color-gray-600); + pointer-events: none; +} + .container { width: var(--container-width-lg); max-width: 1920px;