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..ea77cd4f8 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,16 @@
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;
+
+ position: static;
+ top: 0;
+ left: 0; // or inset-inline-start: 0; for RTL support
+ z-index: 999;
+ 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..275f8d07d 100644
--- a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx
+++ b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx
@@ -47,6 +47,8 @@ export function TableViewer() {
const hideMenuRef = useRef
(null);
const skipToMainRef = useRef(null);
+ const isSmallScreen = isTablet === true || isMobile === true;
+
useEffect(() => {
if (hasFocus !== 'none' && navigationBarRef.current) {
hideMenuRef.current?.focus();
@@ -54,6 +56,9 @@ export function TableViewer() {
}, [hasFocus]);
useEffect(() => {
+ if (isSmallScreen) {
+ return;
+ }
if (!navigationBarRef.current || !hideMenuRef.current) {
return;
}
@@ -114,7 +119,9 @@ export function TableViewer() {
undefined,
);
}
- }, [accessibility, selectedNavigationView]);
+ }, [accessibility, selectedNavigationView, isSmallScreen]);
+
+ // Drawer focus-trap is implemented inside NavigationDrawer via portal
// Monitor focus on SkipToMain
useEffect(() => {
@@ -174,8 +181,6 @@ export function TableViewer() {
}
};
- const isSmallScreen = isTablet === true || isMobile === true;
-
return (
<>
@@ -203,6 +208,7 @@ export function TableViewer() {
/>
)}{' '}