diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx index f0c3e5100..1e692f7da 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx @@ -7,6 +7,7 @@ import type { LocaleContent, HelpSection as HelpSectionType, } from '../../../util/config/localeContentTypes'; +import React from 'react'; export function DrawerHelp() { const { i18n } = useTranslation(); @@ -14,6 +15,14 @@ export function DrawerHelp() { const helpSectionContent: HelpSectionType | undefined = localeContent?.tableViewer?.helpSection; + React.useEffect(() => { + // Fire a custom event after mount to signal that HelpSection is rendered + const timeout = setTimeout(() => { + globalThis.dispatchEvent(new CustomEvent('drawer-help-rendered')); + }, 0); + return () => clearTimeout(timeout); + }, [helpSectionContent]); + if (!helpSectionContent) { return null; } diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss index a9ca749e5..6efba6ec7 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss @@ -67,18 +67,11 @@ border-end-end-radius: var(--px-border-radius-xlarge); border-end-start-radius: var(--px-border-radius-none); - // Not from Figma position: absolute; - inset-inline-start: 120px; // Instead of "left" to handle rtl languages + inset-inline-start: 120px; z-index: 999; - - // Position NavigationDrawer below the header - top: fixed.$spacing-22; - - &.skipToMainContentVisible { - // Calculate position of NavigationDrawer below the header and SkipToMainContent - top: calc(fixed.$spacing-22 + var(--skip-to-main-content-height)); - } + top: 88px; + order: 0; } // xlarge and xxlarge @@ -86,6 +79,10 @@ width: 396px; padding: 0px fixed.$spacing-8 fixed.$spacing-8 0px; border-radius: var(--px-border-radius-none); + + // Participate in flex layout within mainContainer + position: static; + order: 0; } } diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx new file mode 100644 index 000000000..4071b5c11 --- /dev/null +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, afterEach } from 'vitest'; +import NavigationDrawer from './NavigationDrawer'; + +// Mocks +vi.mock('../../context/useAccessibility', () => ({ + __esModule: true, + default: () => ({ + addModal: vi.fn(), + removeModal: vi.fn(), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock('@pxweb2/pxweb2-ui', () => ({ + Heading: (props: React.ComponentProps<'h2'>) =>

, + Icon: () => , + Label: (props: React.ComponentProps<'span'>) => , + getIconDirection: (dir: string, ltr: string, rtl: string) => + dir === 'rtl' ? rtl : ltr, +})); + +vi.mock('i18next', () => ({ + __esModule: true, + default: { dir: () => 'ltr' }, +})); + +// useApp mock (hook default export) +import useApp from '../../context/useApp'; +vi.mock('../../context/useApp', () => ({ + __esModule: true, + default: vi.fn(), +})); + +function setSmallScreen(isSmall: boolean) { + (useApp as any).mockReturnValue({ + skipToMainFocused: false, + isMobile: false, + isTablet: false, + isXLargeDesktop: !isSmall, + isXXLargeDesktop: !isSmall, + }); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: isSmall && query === '(max-width: 1199px)', + media: query, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +afterEach(() => { + // Cleanup custom drawer root if created + const root = document.querySelector('[data-drawer-root]'); + root?.parentElement?.removeChild(root); + vi.clearAllMocks(); +}); + +function renderDrawer(options?: { + view?: 'selection' | 'view' | 'edit' | 'save' | 'help'; + heading?: string; + openedWithKeyboard?: boolean; + smallScreen?: boolean; + useDrawerRoot?: boolean; + onClose?: ReturnType; +}) { + const { + view = 'selection', + heading = 'Selection', + openedWithKeyboard = false, + smallScreen = false, + useDrawerRoot = false, + onClose = vi.fn(), + } = options || {}; + + setSmallScreen(smallScreen); + + if (useDrawerRoot) { + const root = document.createElement('div'); + root.setAttribute('data-drawer-root', ''); + document.body.appendChild(root); + } + + // Forwarded ref to the hide button + const ref = React.createRef(); + + render( + +
+ + +
+
, + ); + + return { ref, onClose }; +} + +test('portals into [data-drawer-root] when present', () => { + renderDrawer({ smallScreen: false, useDrawerRoot: true }); + + const portalRoot = document.querySelector( + '[data-drawer-root]', + ) as HTMLElement; + expect(portalRoot).toBeInTheDocument(); + + const drawer = within(portalRoot).getByTestId('selection-drawer'); + expect(drawer).toBeInTheDocument(); + + // On large screens, drawer is a region (not dialog) + expect(drawer).toHaveAttribute('role', 'region'); + expect(drawer).not.toHaveAttribute('aria-modal'); +}); + +test('falls back to document.body when no [data-drawer-root] is present', () => { + renderDrawer({ smallScreen: false, useDrawerRoot: false }); + + const drawer = document.querySelector( + '[data-view="selection"]', + ) as HTMLElement; + expect(drawer).toBeInTheDocument(); + expect(drawer.parentElement).toBe(document.body); +}); + +test('small screens: role="dialog" and aria-modal="true"', () => { + renderDrawer({ smallScreen: true }); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + // Current implementation uses string "true" + expect(dialog).toHaveAttribute('aria-modal', 'true'); +}); + +test('small screens: clicking backdrop calls onClose(false, view)', () => { + const { onClose } = renderDrawer({ smallScreen: true }); + + const backdrop = document.querySelector( + '[aria-hidden="true"]', + ) as HTMLElement; + expect(backdrop).toBeInTheDocument(); + + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledWith(false, 'selection'); +}); + +test('forwarded ref focuses hide button when openedWithKeyboard=true', async () => { + const { ref } = renderDrawer({ + smallScreen: false, + openedWithKeyboard: true, + }); + + const hideBtn = screen.getByRole('button', { + name: 'presentation_page.side_menu.hide', + }); + + expect(ref.current).toBe(hideBtn); + await waitFor(() => expect(hideBtn).toHaveFocus()); +}); + +test('small screens: focus trap cycles Tab within the drawer', async () => { + renderDrawer({ smallScreen: true }); + + const hideBtn = screen.getByRole('button', { + name: 'presentation_page.side_menu.hide', + }); + const btnA = screen.getByRole('button', { name: 'A' }); + const btnB = screen.getByRole('button', { name: 'B' }); + + // jsdom lacks layout; mark elements as visible for offsetParent filter + [hideBtn, btnA, btnB].forEach((el) => { + Object.defineProperty(el, 'offsetParent', { + value: document.body, + configurable: true, + }); + Object.defineProperty(el, 'offsetWidth', { value: 10, configurable: true }); + Object.defineProperty(el, 'offsetHeight', { + value: 10, + configurable: true, + }); + }); + + // Move focus to the last focusable element (B) + btnB.focus(); + expect(btnB).toHaveFocus(); + + // Tab from last should wrap to first (hide button) + await userEvent.tab(); + await waitFor(() => expect(hideBtn).toHaveFocus()); +}); + +test('small screens: document-level trap pulls focus back into drawer on Tab', async () => { + renderDrawer({ smallScreen: true }); + + const hideBtn = screen.getByRole('button', { + name: 'presentation_page.side_menu.hide', + }); + + // Simulate focus escaping to an external, focusable element outside the drawer + const outsideButton = document.createElement('button'); + outsideButton.textContent = 'outside'; + document.body.appendChild(outsideButton); + outsideButton.focus(); + expect(document.activeElement).toBe(outsideButton); + + // Global tab should move focus back to the drawer's first focusable + await userEvent.tab(); + await waitFor(() => expect(hideBtn).toHaveFocus()); +}); + +test('small screens: Escape closes the drawer with keyboard=true', () => { + const { onClose } = renderDrawer({ smallScreen: true }); + + const drawer = screen.getByTestId('selection-drawer'); + fireEvent.keyDown(drawer, { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledWith(true, 'selection'); +}); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 146b925cd..2ca4e6af5 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -1,4 +1,5 @@ import React, { forwardRef } from 'react'; +import { createPortal } from 'react-dom'; import cl from 'clsx'; import { useTranslation } from 'react-i18next'; @@ -25,7 +26,22 @@ export const NavigationDrawer = forwardRef< >(({ children, heading, view, openedWithKeyboard, onClose }, ref) => { const { t } = useTranslation(); const { addModal, removeModal } = useAccessibility(); - const { skipToMainFocused } = useApp(); + const { + skipToMainFocused, + isMobile, + isTablet, + isXLargeDesktop, + isXXLargeDesktop, + } = useApp(); + const isSmallScreen = + isMobile === true || + isTablet === true || + (isXLargeDesktop === false && + isXXLargeDesktop === false && + isMobile === false && + isTablet === false); + const containerRef = React.useRef(null); + const headingId = React.useId(); React.useEffect(() => { addModal('NavigationDrawer', () => { @@ -66,21 +82,124 @@ export const NavigationDrawer = forwardRef< } }, [openedWithKeyboard, ref]); - return ( + // Trap focus within the drawer on small screens only + React.useEffect(() => { + if (!isSmallScreen) { + return; + } + const getFocusable = () => { + const sel = + 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'; + const node = containerRef.current; + if (!node) { + return [] as HTMLElement[]; + } + const list = Array.from(node.querySelectorAll(sel)).filter( + (el) => el.offsetParent !== null, + ); + return list; + }; + + let focusables = getFocusable(); + let first = + focusables[0] || (ref && typeof ref !== 'function' ? ref.current : null); + let last = focusables.at(-1) || first; + + // Move focus into the drawer before trapping + const active = document.activeElement as HTMLElement | null; + const node = containerRef.current; + if (active && node && !node.contains(active)) { + active.blur(); + } + if (first) { + // Defer to next tick to ensure render + setTimeout(() => first?.focus(), 0); + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(true, view); + return; + } + if (e.key !== 'Tab') { + return; + } + const active = document.activeElement as HTMLElement | null; + if (!first || !last) { + return; + } + if (e.shiftKey) { + if (active === first) { + e.preventDefault(); + last.focus(); + } + } + if (active === last) { + e.preventDefault(); + first.focus(); + } + }; + + node?.addEventListener('keydown', handleKeyDown); + // Global trap to catch Tab presses even if focus escapes + const handleDocKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') { + return; + } + const inDrawer = node?.contains(document.activeElement as Node) ?? false; + if (!inDrawer && first) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', handleDocKeyDown, true); + + // Listen for custom event to re-run getFocusable when DrawerHelp is rendered + const rerunFocus = () => { + focusables = getFocusable(); + first = + focusables[0] || + (ref && typeof ref !== 'function' ? ref.current : null); + last = focusables.at(-1) || first; + if (first) { + setTimeout(() => first?.focus(), 0); + } + }; + globalThis.addEventListener('drawer-help-rendered', rerunFocus); + + return () => { + node?.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keydown', handleDocKeyDown, true); + globalThis.removeEventListener('drawer-help-rendered', rerunFocus); + }; + }, [onClose, view, ref, isSmallScreen]); + + const portalTarget = + document.querySelector('[data-drawer-root]') ?? document.body; + return createPortal( <> + {isSmallScreen && ( +
onClose(false, view)} + className={styles.backdrop} + aria-hidden="true" + >
+ )}
onClose(false, view)} - className={styles.backdrop} - >
-
- + {heading}
- + , + portalTarget, ); }); diff --git a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.module.scss b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.module.scss index 474715997..82ad303ae 100644 --- a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.module.scss +++ b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.module.scss @@ -60,6 +60,7 @@ justify-content: center; align-items: flex-start; flex-shrink: 0; + //position: relative; // Anchor absolute-positioned drawer inside this container // Handle rtl languages border-start-start-radius: var(--px-border-radius-xlarge); @@ -97,6 +98,7 @@ padding-bottom: fixed.$spacing-8; overflow-y: auto; gap: fixed.$spacing-8; + order: 1; // Ensure content sits to the right of the drawer in flex layout } &.expanded { diff --git a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx index e8ce627ca..8ecf3e391 100644 --- a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx +++ b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx @@ -11,7 +11,6 @@ import NavigationRail from '../../components/NavigationMenu/NavigationRail/Navig import NavigationBar from '../../components/NavigationMenu/NavigationBar/NavigationBar'; import { SkipToMain } from '../../components/SkipToMain/SkipToMain'; import { Footer } from '../../components/Footer/Footer'; -import useAccessibility from '../../context/useAccessibility'; import useApp from '../../context/useApp'; import { AccessibilityProvider } from '../../context/AccessibilityProvider'; import { VariablesProvider } from '../../context/VariablesProvider'; @@ -25,7 +24,6 @@ export function TableViewer() { skipToMainFocused, setSkipToMainFocused, } = useApp(); - const accessibility = useAccessibility(); const { tableId } = useParams<{ tableId: string }>(); const [selectedTableId] = useState(tableId ?? ''); @@ -53,68 +51,7 @@ export function TableViewer() { } }, [hasFocus]); - useEffect(() => { - if (!navigationBarRef.current || !hideMenuRef.current) { - return; - } - let item = null; - - if (selectedNavigationView === 'selection') { - item = navigationBarRef.current.selection; - accessibility.addFocusOverride( - 'selectionButton', - navigationBarRef.current.selection, - undefined, - hideMenuRef.current, - ); - } - - if (selectedNavigationView === 'view') { - item = navigationBarRef.current.view; - accessibility.addFocusOverride( - 'viewButton', - navigationBarRef.current.view, - undefined, - hideMenuRef.current, - ); - } - if (selectedNavigationView === 'edit') { - item = navigationBarRef.current.edit; - accessibility.addFocusOverride( - 'editButton', - navigationBarRef.current.edit, - undefined, - hideMenuRef.current, - ); - } - if (selectedNavigationView === 'save') { - item = navigationBarRef.current.save; - accessibility.addFocusOverride( - 'saveButton', - navigationBarRef.current.save, - undefined, - hideMenuRef.current, - ); - } - if (selectedNavigationView === 'help') { - item = navigationBarRef.current.help; - accessibility.addFocusOverride( - 'helpButton', - navigationBarRef.current.help, - undefined, - hideMenuRef.current, - ); - } - - if (item) { - accessibility.addFocusOverride( - 'hideButton', - hideMenuRef.current, - item, - undefined, - ); - } - }, [accessibility, selectedNavigationView]); + // Drawer focus-trap is implemented inside NavigationDrawer via portal // Monitor focus on SkipToMain useEffect(() => { @@ -203,6 +140,7 @@ export function TableViewer() { /> )}{' '}