From 9fb2158ad0277b41d5743dcea9ce8f43df27342b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 2 Feb 2026 17:09:02 +0100 Subject: [PATCH 01/11] Refactor NavigationDrawer and TableViewer for improved accessibility and layout handling --- .../NavigationDrawer.module.scss | 19 ++-- .../NavigationDrawer/NavigationDrawer.tsx | 104 ++++++++++++++++-- .../pages/TableViewer/TableViewer.module.scss | 2 + .../src/app/pages/TableViewer/TableViewer.tsx | 66 +---------- 4 files changed, 105 insertions(+), 86 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss index a9ca749e5..b8e781311 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss @@ -67,18 +67,9 @@ 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 - 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)); - } + // Participate in flex layout within mainContainer + position: static; + order: 0; } // xlarge and xxlarge @@ -86,6 +77,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.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 146b925cd..9da54df08 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,10 @@ export const NavigationDrawer = forwardRef< >(({ children, heading, view, openedWithKeyboard, onClose }, ref) => { const { t } = useTranslation(); const { addModal, removeModal } = useAccessibility(); - const { skipToMainFocused } = useApp(); + const { skipToMainFocused, isMobile, isTablet } = useApp(); + const isSmallScreen = isMobile === true || isTablet === true || window.matchMedia('(max-width: 1199px)').matches; + const containerRef = React.useRef(null); + const headingId = React.useId(); React.useEffect(() => { addModal('NavigationDrawer', () => { @@ -66,21 +70,100 @@ 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 as HTMLElement[]; + }; + + const focusables = getFocusable(); + const first = focusables[0] || (ref && typeof ref !== 'function' ? ref.current : null); + const last = focusables[focusables.length - 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(); + } + } else { + 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); + + return () => { + node?.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keydown', handleDocKeyDown, true); + }; + }, [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 0c35dab2c..7b083b1c1 100644 --- a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx +++ b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx @@ -13,7 +13,6 @@ import { SkipToMain } from '../../components/SkipToMain/SkipToMain'; import { Footer } from '../../components/Footer/Footer'; import { getConfig } from '../../util/config/getConfig'; import { OpenAPI } from '@pxweb2/pxweb2-api-client'; -import useAccessibility from '../../context/useAccessibility'; import useApp from '../../context/useApp'; import { AccessibilityProvider } from '../../context/AccessibilityProvider'; import { VariablesProvider } from '../../context/VariablesProvider'; @@ -28,7 +27,6 @@ export function TableViewer() { setSkipToMainFocused, } = useApp(); const config = getConfig(); - const accessibility = useAccessibility(); const baseUrl = config.apiUrl; OpenAPI.BASE = baseUrl; @@ -59,68 +57,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(() => { @@ -209,6 +146,7 @@ export function TableViewer() { /> )}{' '}
Date: Mon, 2 Feb 2026 17:09:40 +0100 Subject: [PATCH 02/11] Refactor NavigationDrawer for improved readability and maintainability --- .../NavigationDrawer/NavigationDrawer.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 9da54df08..edeebc10c 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -27,7 +27,10 @@ export const NavigationDrawer = forwardRef< const { t } = useTranslation(); const { addModal, removeModal } = useAccessibility(); const { skipToMainFocused, isMobile, isTablet } = useApp(); - const isSmallScreen = isMobile === true || isTablet === true || window.matchMedia('(max-width: 1199px)').matches; + const isSmallScreen = + isMobile === true || + isTablet === true || + window.matchMedia('(max-width: 1199px)').matches; const containerRef = React.useRef(null); const headingId = React.useId(); @@ -79,7 +82,9 @@ export const NavigationDrawer = forwardRef< 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[];} + if (!node) { + return [] as HTMLElement[]; + } const list = Array.from(node.querySelectorAll(sel)).filter( (el) => el.offsetParent !== null, ); @@ -87,7 +92,8 @@ export const NavigationDrawer = forwardRef< }; const focusables = getFocusable(); - const first = focusables[0] || (ref && typeof ref !== 'function' ? ref.current : null); + const first = + focusables[0] || (ref && typeof ref !== 'function' ? ref.current : null); const last = focusables[focusables.length - 1] || first; // Move focus into the drawer before trapping @@ -107,9 +113,13 @@ export const NavigationDrawer = forwardRef< onClose(true, view); return; } - if (e.key !== 'Tab') {return;} + if (e.key !== 'Tab') { + return; + } const active = document.activeElement as HTMLElement | null; - if (!first || !last) {return;} + if (!first || !last) { + return; + } if (e.shiftKey) { if (active === first) { e.preventDefault(); @@ -126,7 +136,9 @@ export const NavigationDrawer = forwardRef< node?.addEventListener('keydown', handleKeyDown); // Global trap to catch Tab presses even if focus escapes const handleDocKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Tab') {return;} + if (e.key !== 'Tab') { + return; + } const inDrawer = node?.contains(document.activeElement as Node) ?? false; if (!inDrawer && first) { e.preventDefault(); @@ -141,7 +153,8 @@ export const NavigationDrawer = forwardRef< }; }, [onClose, view, ref, isSmallScreen]); - const portalTarget = document.querySelector('[data-drawer-root]') ?? document.body; + const portalTarget = + document.querySelector('[data-drawer-root]') ?? document.body; return createPortal( <> {isSmallScreen && ( From a3b9cf8f8cf495f4df59bb265902acf0b03c154a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Tue, 3 Feb 2026 10:22:17 +0100 Subject: [PATCH 03/11] test: add tests for NavigationDrawer component --- .../NavigationDrawer.spec.tsx | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx 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..0c1b62220 --- /dev/null +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx @@ -0,0 +1,219 @@ +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, + }); + 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'); +}); From b39a3860f3056264442c85a6e328d30dcf27fda9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Tue, 3 Feb 2026 10:23:30 +0100 Subject: [PATCH 04/11] refactor: improve code formatting and readability in NavigationDrawer tests --- .../NavigationDrawer.spec.tsx | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx index 0c1b62220..7866fd42c 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx @@ -1,6 +1,12 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +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'; @@ -22,7 +28,8 @@ 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), + getIconDirection: (dir: string, ltr: string, rtl: string) => + dir === 'rtl' ? rtl : ltr, })); vi.mock('i18next', () => ({ @@ -104,7 +111,7 @@ function renderDrawer(options?: {

- + , ); return { ref, onClose }; @@ -113,7 +120,9 @@ function renderDrawer(options?: { test('portals into [data-drawer-root] when present', () => { renderDrawer({ smallScreen: false, useDrawerRoot: true }); - const portalRoot = document.querySelector('[data-drawer-root]') as HTMLElement; + const portalRoot = document.querySelector( + '[data-drawer-root]', + ) as HTMLElement; expect(portalRoot).toBeInTheDocument(); const drawer = within(portalRoot).getByTestId('selection-drawer'); @@ -127,7 +136,9 @@ test('portals into [data-drawer-root] when present', () => { 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; + const drawer = document.querySelector( + '[data-view="selection"]', + ) as HTMLElement; expect(drawer).toBeInTheDocument(); expect(drawer.parentElement).toBe(document.body); }); @@ -144,7 +155,9 @@ test('small screens: role="dialog" and 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; + const backdrop = document.querySelector( + '[aria-hidden="true"]', + ) as HTMLElement; expect(backdrop).toBeInTheDocument(); fireEvent.click(backdrop); @@ -152,7 +165,10 @@ test('small screens: clicking backdrop calls onClose(false, view)', () => { }); test('forwarded ref focuses hide button when openedWithKeyboard=true', async () => { - const { ref } = renderDrawer({ smallScreen: false, openedWithKeyboard: true }); + const { ref } = renderDrawer({ + smallScreen: false, + openedWithKeyboard: true, + }); const hideBtn = screen.getByRole('button', { name: 'presentation_page.side_menu.hide', @@ -165,7 +181,7 @@ test('forwarded ref focuses hide button when openedWithKeyboard=true', async () test('small screens: focus trap cycles Tab within the drawer', async () => { renderDrawer({ smallScreen: true }); - const hideBtn = screen.getByRole('button', { + const hideBtn = screen.getByRole('button', { name: 'presentation_page.side_menu.hide', }); const btnA = screen.getByRole('button', { name: 'A' }); @@ -178,7 +194,10 @@ test('small screens: focus trap cycles Tab within the drawer', async () => { configurable: true, }); Object.defineProperty(el, 'offsetWidth', { value: 10, configurable: true }); - Object.defineProperty(el, 'offsetHeight', { value: 10, configurable: true }); + Object.defineProperty(el, 'offsetHeight', { + value: 10, + configurable: true, + }); }); // Move focus to the last focusable element (B) From 2d3daedb3b6e67302b5499e46e7d615b8f869ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Wed, 4 Feb 2026 16:49:02 +0100 Subject: [PATCH 05/11] Refactor Helpection.tsx to make focus trap work there --- .../src/app/components/Help/HelpSection.tsx | 40 +++++++------------ .../NavigationDrawer.module.scss | 6 ++- .../NavigationDrawer.spec.tsx | 2 + .../NavigationDrawer/NavigationDrawer.tsx | 9 +++-- 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/pxweb2/src/app/components/Help/HelpSection.tsx b/packages/pxweb2/src/app/components/Help/HelpSection.tsx index 3c40f60e4..bf6da0593 100644 --- a/packages/pxweb2/src/app/components/Help/HelpSection.tsx +++ b/packages/pxweb2/src/app/components/Help/HelpSection.tsx @@ -6,29 +6,6 @@ type HelpSectionProps = Readonly<{ helpSectionContent: HelpSectionLocaleContent; }>; -function LinkList({ - items, -}: Readonly<{ - items: NonNullable; -}>) { - return ( -
    - {items.map((link, idx) => ( -
  • - - {link.text} - -
  • - ))} -
- ); -} - export default function HelpSection({ helpSectionContent }: HelpSectionProps) { const { description, links, informationCard } = helpSectionContent; const hasLinks = Boolean(links && links.length > 0); @@ -50,9 +27,22 @@ export default function HelpSection({ helpSectionContent }: HelpSectionProps) { {description}
)} - {hasLinks && ( + {hasLinks && links && (
- +
    + {links.map((link, idx) => ( +
  • + + {link.text} + +
  • + ))} +
)} diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss index b8e781311..6efba6ec7 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.module.scss @@ -67,8 +67,10 @@ border-end-end-radius: var(--px-border-radius-xlarge); border-end-start-radius: var(--px-border-radius-none); - // Participate in flex layout within mainContainer - position: static; + position: absolute; + inset-inline-start: 120px; + z-index: 999; + top: 88px; order: 0; } diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx index 7866fd42c..4071b5c11 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.spec.tsx @@ -49,6 +49,8 @@ function setSmallScreen(isSmall: boolean) { skipToMainFocused: false, isMobile: false, isTablet: false, + isXLargeDesktop: !isSmall, + isXXLargeDesktop: !isSmall, }); Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index edeebc10c..0d43d6a05 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -26,11 +26,11 @@ export const NavigationDrawer = forwardRef< >(({ children, heading, view, openedWithKeyboard, onClose }, ref) => { const { t } = useTranslation(); const { addModal, removeModal } = useAccessibility(); - const { skipToMainFocused, isMobile, isTablet } = useApp(); + const { skipToMainFocused, isMobile, isTablet, isXLargeDesktop, isXXLargeDesktop } = useApp(); const isSmallScreen = isMobile === true || isTablet === true || - window.matchMedia('(max-width: 1199px)').matches; + (isXLargeDesktop === false && isXXLargeDesktop === false && isMobile === false && isTablet === false) const containerRef = React.useRef(null); const headingId = React.useId(); @@ -169,8 +169,9 @@ export const NavigationDrawer = forwardRef< className={cl(styles.navigationDrawer, styles.fadein, { [styles.skipToMainContentVisible]: skipToMainFocused, })} - role={isSmallScreen ? 'dialog' : 'region'} - aria-modal={isSmallScreen ? 'true' : undefined} + {...(isSmallScreen + ? { role: 'dialog', 'aria-modal': 'true' } + : { role: 'region' })} aria-labelledby={headingId} data-testid={`${view}-drawer`} data-view={view} From 0d688d00289f8797b248cca83e6a9d30a043d919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Wed, 4 Feb 2026 16:49:39 +0100 Subject: [PATCH 06/11] refactor: improve code formatting and readability in HelpSection and NavigationDrawer components --- .../src/app/components/Help/HelpSection.tsx | 28 +++++++++---------- .../NavigationDrawer/NavigationDrawer.tsx | 13 +++++++-- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/pxweb2/src/app/components/Help/HelpSection.tsx b/packages/pxweb2/src/app/components/Help/HelpSection.tsx index bf6da0593..4e226221c 100644 --- a/packages/pxweb2/src/app/components/Help/HelpSection.tsx +++ b/packages/pxweb2/src/app/components/Help/HelpSection.tsx @@ -29,20 +29,20 @@ export default function HelpSection({ helpSectionContent }: HelpSectionProps) { )} {hasLinks && links && (
-
    - {links.map((link, idx) => ( -
  • - - {link.text} - -
  • - ))} -
+
    + {links.map((link, idx) => ( +
  • + + {link.text} + +
  • + ))} +
)} diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 0d43d6a05..8e5ba653d 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -26,11 +26,20 @@ export const NavigationDrawer = forwardRef< >(({ children, heading, view, openedWithKeyboard, onClose }, ref) => { const { t } = useTranslation(); const { addModal, removeModal } = useAccessibility(); - const { skipToMainFocused, isMobile, isTablet, isXLargeDesktop, isXXLargeDesktop } = useApp(); + const { + skipToMainFocused, + isMobile, + isTablet, + isXLargeDesktop, + isXXLargeDesktop, + } = useApp(); const isSmallScreen = isMobile === true || isTablet === true || - (isXLargeDesktop === false && isXXLargeDesktop === false && isMobile === false && isTablet === false) + (isXLargeDesktop === false && + isXXLargeDesktop === false && + isMobile === false && + isTablet === false); const containerRef = React.useRef(null); const headingId = React.useId(); From b8bf717d41de33a849bdc5f3afa212a9cc65c6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 9 Feb 2026 09:20:23 +0100 Subject: [PATCH 07/11] Rerender getFocusable to get focusable items in HelpSection to appear in focus trap --- .../src/app/components/Help/HelpSection.tsx | 40 ++++++++++++------- .../NavigationDrawer/Drawers/DrawerHelp.tsx | 9 +++++ .../NavigationDrawer/NavigationDrawer.tsx | 20 ++++++++-- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/packages/pxweb2/src/app/components/Help/HelpSection.tsx b/packages/pxweb2/src/app/components/Help/HelpSection.tsx index 4e226221c..3c40f60e4 100644 --- a/packages/pxweb2/src/app/components/Help/HelpSection.tsx +++ b/packages/pxweb2/src/app/components/Help/HelpSection.tsx @@ -6,6 +6,29 @@ type HelpSectionProps = Readonly<{ helpSectionContent: HelpSectionLocaleContent; }>; +function LinkList({ + items, +}: Readonly<{ + items: NonNullable; +}>) { + return ( +
    + {items.map((link, idx) => ( +
  • + + {link.text} + +
  • + ))} +
+ ); +} + export default function HelpSection({ helpSectionContent }: HelpSectionProps) { const { description, links, informationCard } = helpSectionContent; const hasLinks = Boolean(links && links.length > 0); @@ -27,22 +50,9 @@ export default function HelpSection({ helpSectionContent }: HelpSectionProps) { {description} )} - {hasLinks && links && ( + {hasLinks && (
-
    - {links.map((link, idx) => ( -
  • - - {link.text} - -
  • - ))} -
+
)} diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx index f0c3e5100..3b33e2d47 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(() => { + window.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.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 8e5ba653d..ea10570b6 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -100,10 +100,10 @@ export const NavigationDrawer = forwardRef< return list as HTMLElement[]; }; - const focusables = getFocusable(); - const first = + let focusables = getFocusable(); + let first = focusables[0] || (ref && typeof ref !== 'function' ? ref.current : null); - const last = focusables[focusables.length - 1] || first; + let last = focusables[focusables.length - 1] || first; // Move focus into the drawer before trapping const active = document.activeElement as HTMLElement | null; @@ -113,7 +113,7 @@ export const NavigationDrawer = forwardRef< } if (first) { // Defer to next tick to ensure render - setTimeout(() => first.focus(), 0); + setTimeout(() => first && first.focus(), 0); } const handleKeyDown = (e: KeyboardEvent) => { @@ -156,9 +156,21 @@ export const NavigationDrawer = forwardRef< }; 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[focusables.length - 1] || first; + if (first) { + setTimeout(() => first && first.focus(), 0); + } + }; + window.addEventListener('drawer-help-rendered', rerunFocus); + return () => { node?.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleDocKeyDown, true); + window.removeEventListener('drawer-help-rendered', rerunFocus); }; }, [onClose, view, ref, isSmallScreen]); From 7e03ebf1ad7ef65a33b45da2fb477f58186e1916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 9 Feb 2026 09:24:39 +0100 Subject: [PATCH 08/11] refactor: improve code formatting in DrawerHelp and NavigationDrawer components --- .../NavigationDrawer/Drawers/DrawerHelp.tsx | 14 +++++++------- .../NavigationDrawer/NavigationDrawer.tsx | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx index 3b33e2d47..648267124 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx @@ -15,13 +15,13 @@ 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(() => { - window.dispatchEvent(new CustomEvent('drawer-help-rendered')); - }, 0); - return () => clearTimeout(timeout); - }, [helpSectionContent]); + React.useEffect(() => { + // Fire a custom event after mount to signal that HelpSection is rendered + const timeout = setTimeout(() => { + window.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.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index ea10570b6..9839ac740 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -159,7 +159,9 @@ export const NavigationDrawer = forwardRef< // 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); + first = + focusables[0] || + (ref && typeof ref !== 'function' ? ref.current : null); last = focusables[focusables.length - 1] || first; if (first) { setTimeout(() => first && first.focus(), 0); From 71ccff86cd071c99876d82abc48998c582a17172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 9 Feb 2026 11:14:06 +0100 Subject: [PATCH 09/11] Updated after SonarQube feedback --- .../app/components/NavigationDrawer/NavigationDrawer.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 9839ac740..0a7e92f5a 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -97,13 +97,13 @@ export const NavigationDrawer = forwardRef< const list = Array.from(node.querySelectorAll(sel)).filter( (el) => el.offsetParent !== null, ); - return list as HTMLElement[]; + return list; }; let focusables = getFocusable(); let first = focusables[0] || (ref && typeof ref !== 'function' ? ref.current : null); - let last = focusables[focusables.length - 1] || first; + let last = focusables.at(-1) || first; // Move focus into the drawer before trapping const active = document.activeElement as HTMLElement | null; @@ -134,12 +134,10 @@ export const NavigationDrawer = forwardRef< e.preventDefault(); last.focus(); } - } else { - if (active === last) { + } if (active === last) { e.preventDefault(); first.focus(); } - } }; node?.addEventListener('keydown', handleKeyDown); From 2668288097b5d3c0af1657afd716ae1c507da5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 9 Feb 2026 11:15:16 +0100 Subject: [PATCH 10/11] fix: correct indentation in keydown event handler for focus management --- .../app/components/NavigationDrawer/NavigationDrawer.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 0a7e92f5a..9d2afb353 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -134,10 +134,11 @@ export const NavigationDrawer = forwardRef< e.preventDefault(); last.focus(); } - } if (active === last) { - e.preventDefault(); - first.focus(); - } + } + if (active === last) { + e.preventDefault(); + first.focus(); + } }; node?.addEventListener('keydown', handleKeyDown); From b7aa065fb2265be00da9ca274b0794451c320be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 9 Feb 2026 14:03:48 +0100 Subject: [PATCH 11/11] fix: replace window with globalThis for event handling in DrawerHelp and NavigationDrawer components --- .../components/NavigationDrawer/Drawers/DrawerHelp.tsx | 2 +- .../components/NavigationDrawer/NavigationDrawer.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx index 648267124..1e692f7da 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerHelp.tsx @@ -18,7 +18,7 @@ export function DrawerHelp() { React.useEffect(() => { // Fire a custom event after mount to signal that HelpSection is rendered const timeout = setTimeout(() => { - window.dispatchEvent(new CustomEvent('drawer-help-rendered')); + globalThis.dispatchEvent(new CustomEvent('drawer-help-rendered')); }, 0); return () => clearTimeout(timeout); }, [helpSectionContent]); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx index 9d2afb353..2ca4e6af5 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/NavigationDrawer.tsx @@ -113,7 +113,7 @@ export const NavigationDrawer = forwardRef< } if (first) { // Defer to next tick to ensure render - setTimeout(() => first && first.focus(), 0); + setTimeout(() => first?.focus(), 0); } const handleKeyDown = (e: KeyboardEvent) => { @@ -161,17 +161,17 @@ export const NavigationDrawer = forwardRef< first = focusables[0] || (ref && typeof ref !== 'function' ? ref.current : null); - last = focusables[focusables.length - 1] || first; + last = focusables.at(-1) || first; if (first) { - setTimeout(() => first && first.focus(), 0); + setTimeout(() => first?.focus(), 0); } }; - window.addEventListener('drawer-help-rendered', rerunFocus); + globalThis.addEventListener('drawer-help-rendered', rerunFocus); return () => { node?.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleDocKeyDown, true); - window.removeEventListener('drawer-help-rendered', rerunFocus); + globalThis.removeEventListener('drawer-help-rendered', rerunFocus); }; }, [onClose, view, ref, isSmallScreen]);