From ecc91ee9167bc452018affcf0cf70a03d3a7a692 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 9 Apr 2026 08:10:50 +0300 Subject: [PATCH 1/5] fix: restore focus claim reset on pushLabel for WCAG-compliant inner navigation --- src/components/DialogLabelContext.tsx | 1 + tests/unit/DialogLabelContextTest.tsx | 5 +++-- tests/unit/focusFirstInteractiveElementTest.ts | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index d13193552ef32..5ada08ee786f2 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -51,6 +51,7 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) const pushLabel = (text: string): number => { const id = nextIdRef.current++; labelStackRef.current = [...labelStackRef.current, {id, text}]; + initialFocusClaimedRef.current = false; updateContainerLabel(); return id; }; diff --git a/tests/unit/DialogLabelContextTest.tsx b/tests/unit/DialogLabelContextTest.tsx index b7e885ed7c3bb..bfb45f423ded3 100644 --- a/tests/unit/DialogLabelContextTest.tsx +++ b/tests/unit/DialogLabelContextTest.tsx @@ -123,18 +123,19 @@ describe('DialogLabelContext', () => { expect(result.current.claimInitialFocus()).toBe(false); }); - it('pushLabel does not reset focus claim (only first screen in dialog gets focus)', () => { + it('pushLabel resets focus claim so each screen can claim', () => { const {result} = renderHook(() => useDialogLabelActions(), {wrapper}); // First screen claims focus expect(result.current.claimInitialFocus()).toBe(true); expect(result.current.claimInitialFocus()).toBe(false); - // Inner navigation pushes a new label — claim stays consumed + // Inner navigation pushes a new label — claim resets act(() => { result.current.pushLabel('Screen B'); }); + expect(result.current.claimInitialFocus()).toBe(true); expect(result.current.claimInitialFocus()).toBe(false); }); diff --git a/tests/unit/focusFirstInteractiveElementTest.ts b/tests/unit/focusFirstInteractiveElementTest.ts index fd8bd69b68a7c..9935ffed58fdf 100644 --- a/tests/unit/focusFirstInteractiveElementTest.ts +++ b/tests/unit/focusFirstInteractiveElementTest.ts @@ -26,11 +26,11 @@ afterEach(() => { describe('focusFirstInteractiveElement', () => { describe('guard conditions', () => { - it('returns false when container is null', () => { + it('returns undefined when container is null', () => { expect(focusFirstInteractiveElement(null)).toBeUndefined(); }); - it('returns false when another element already has focus', () => { + it('returns undefined when another element already has focus', () => { const input = document.createElement('input'); document.body.appendChild(input); input.focus(); @@ -42,7 +42,7 @@ describe('focusFirstInteractiveElement', () => { expect(spy).not.toHaveBeenCalled(); }); - it('returns false when container has no focusable elements', () => { + it('returns undefined when container has no focusable elements', () => { const container = createContainer(document.createElement('div')); expect(focusFirstInteractiveElement(container)).toBeUndefined(); @@ -198,7 +198,7 @@ describe('focusFirstInteractiveElement', () => { expect(visibleSpy).toHaveBeenCalledWith({preventScroll: true}); }); - it('returns false when all focusable elements are aria-hidden', () => { + it('returns undefined when all focusable elements are aria-hidden', () => { const hiddenDiv = document.createElement('div'); hiddenDiv.setAttribute('aria-hidden', 'true'); const hiddenButton = document.createElement('button'); From 214ce92b90605602c8be0390ad7746457605b269 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 9 Apr 2026 21:45:32 +0300 Subject: [PATCH 2/5] refactor: use focusVisible API to control focus ring instead of workarounds --- src/hooks/useDialogContainerFocus/index.ts | 43 +--- .../unit/focusFirstInteractiveElementTest.ts | 225 ++++-------------- 2 files changed, 57 insertions(+), 211 deletions(-) diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index 4ac70ea01ada8..2fef5516d75d7 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -3,7 +3,6 @@ import {InteractionManager} from 'react-native'; import type UseDialogContainerFocus from './types'; const FOCUSABLE_SELECTOR = 'button, [href], input, textarea, select, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'; -const PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE = 'data-programmatic-focus'; let hadKeyboardEvent = false; if (typeof document !== 'undefined') { @@ -23,44 +22,18 @@ if (typeof document !== 'undefined') { ); } -type CleanupFn = () => void; - -/** @returns a cleanup function if an element was focused, or undefined otherwise. */ -function focusFirstInteractiveElement(container: HTMLElement | null): CleanupFn | undefined { +/** @returns true if an element was focused, false otherwise. */ +function focusFirstInteractiveElement(container: HTMLElement | null): boolean { if (!container || (document.activeElement && document.activeElement !== document.body)) { - return undefined; + return false; } const targets = container.querySelectorAll(FOCUSABLE_SELECTOR); const target = Array.from(targets).find((el) => !el.closest('[aria-hidden="true"]') && !el.matches(':disabled') && el.getAttribute('aria-disabled') !== 'true'); if (!target) { - return undefined; - } - let cleanupListener: CleanupFn | undefined; - if (!hadKeyboardEvent) { - // On first Tab, prevent default and re-focus the same element with a visible ring - // so the user sees focus land here instead of advancing past the silent focus. - const onFirstTab = (e: KeyboardEvent) => { - if (e.key !== 'Tab' || document.activeElement !== target) { - return; - } - e.preventDefault(); - document.removeEventListener('keydown', onFirstTab, true); - target.removeAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE); - target.style.removeProperty('outline'); - target.focus({preventScroll: true}); - }; - target.setAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, 'true'); - target.style.setProperty('outline', 'none'); - // No blur cleanup — attributes must survive browser tab-switch blur/re-focus cycles. - document.addEventListener('keydown', onFirstTab, true); - cleanupListener = () => { - document.removeEventListener('keydown', onFirstTab, true); - target.removeAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE); - target.style.removeProperty('outline'); - }; + return false; } - target.focus({preventScroll: true}); - return cleanupListener; + target.focus({preventScroll: true, focusVisible: hadKeyboardEvent}); + return true; } /** Focuses the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ @@ -71,7 +44,6 @@ const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimIni } let cancelled = false; let frameId: number; - let focusCleanup: CleanupFn | undefined; // Deferred past useAutoFocusInput's InteractionManager + Promise chain. // eslint-disable-next-line @typescript-eslint/no-deprecated const interactionHandle = InteractionManager.runAfterInteractions(() => { @@ -83,14 +55,13 @@ const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimIni return; } const container = ref.current as unknown as HTMLElement | null; - focusCleanup = focusFirstInteractiveElement(container); + focusFirstInteractiveElement(container); }); }); return () => { cancelled = true; interactionHandle.cancel(); cancelAnimationFrame(frameId); - focusCleanup?.(); }; }, [isReady, ref, claimInitialFocus]); }; diff --git a/tests/unit/focusFirstInteractiveElementTest.ts b/tests/unit/focusFirstInteractiveElementTest.ts index 9935ffed58fdf..9c65065b39dfc 100644 --- a/tests/unit/focusFirstInteractiveElementTest.ts +++ b/tests/unit/focusFirstInteractiveElementTest.ts @@ -7,7 +7,7 @@ // Import the web implementation directly (Jest resolves index.native.ts by default). /* eslint-disable @typescript-eslint/no-require-imports, import/extensions */ const {focusFirstInteractiveElement} = require<{ - focusFirstInteractiveElement: (container: HTMLElement | null) => (() => void) | undefined; + focusFirstInteractiveElement: (container: HTMLElement | null) => boolean; }>('../../src/hooks/useDialogContainerFocus/index.ts'); /* eslint-enable @typescript-eslint/no-require-imports, import/extensions */ @@ -26,11 +26,11 @@ afterEach(() => { describe('focusFirstInteractiveElement', () => { describe('guard conditions', () => { - it('returns undefined when container is null', () => { - expect(focusFirstInteractiveElement(null)).toBeUndefined(); + it('returns false when container is null', () => { + expect(focusFirstInteractiveElement(null)).toBe(false); }); - it('returns undefined when another element already has focus', () => { + it('returns false when another element already has focus', () => { const input = document.createElement('input'); document.body.appendChild(input); input.focus(); @@ -38,142 +38,60 @@ describe('focusFirstInteractiveElement', () => { const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - expect(focusFirstInteractiveElement(container)).toBeUndefined(); + expect(focusFirstInteractiveElement(container)).toBe(false); expect(spy).not.toHaveBeenCalled(); }); - it('returns undefined when container has no focusable elements', () => { + it('returns false when container has no focusable elements', () => { const container = createContainer(document.createElement('div')); - expect(focusFirstInteractiveElement(container)).toBeUndefined(); + expect(focusFirstInteractiveElement(container)).toBe(false); }); }); - describe('focus behavior (page-load modality)', () => { + describe('focus with focusVisible (page-load modality)', () => { beforeEach(() => { document.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); }); - it('focuses the first button with data-programmatic-focus and outline suppressed', () => { + it('focuses with focusVisible: false', () => { const button = document.createElement('button'); - button.setAttribute('aria-label', 'Back'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - expect(focusFirstInteractiveElement(container)).toBeDefined(); - expect(spy).toHaveBeenCalledWith({preventScroll: true}); - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - expect(button.style.outline).toBe('none'); + expect(focusFirstInteractiveElement(container)).toBe(true); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); }); + }); - it('focuses the first link element', () => { - const link = document.createElement('a'); - link.setAttribute('href', '#'); - const container = createContainer(link); - const spy = jest.spyOn(link, 'focus'); - - expect(focusFirstInteractiveElement(container)).toBeDefined(); - expect(spy).toHaveBeenCalledWith({preventScroll: true}); - }); - - it('focuses an input element', () => { - const input = document.createElement('input'); - const container = createContainer(input); - const spy = jest.spyOn(input, 'focus'); - - expect(focusFirstInteractiveElement(container)).toBeDefined(); - expect(spy).toHaveBeenCalledWith({preventScroll: true}); - }); - - it('suppression attributes survive blur (tab-switch resilience)', () => { - const button = document.createElement('button'); - const container = createContainer(button); - - focusFirstInteractiveElement(container); - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - expect(button.style.outline).toBe('none'); - - button.dispatchEvent(new Event('blur')); - - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - expect(button.style.outline).toBe('none'); - }); - - it('returned cleanup removes listener and attributes', () => { - const button = document.createElement('button'); - const container = createContainer(button); - const focusSpy = jest.spyOn(button, 'focus'); - - const cleanup = focusFirstInteractiveElement(container); - expect(cleanup).toBeDefined(); - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - - cleanup?.(); - - expect(button.getAttribute('data-programmatic-focus')).toBeNull(); - expect(button.style.outline).toBe(''); - - // onFirstTab listener should be removed — Tab should not be intercepted - const tabEvent = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true, cancelable: true}); - document.dispatchEvent(tabEvent); - expect(tabEvent.defaultPrevented).toBe(false); - expect(focusSpy).toHaveBeenCalledTimes(1); + describe('focus with focusVisible (keyboard modality)', () => { + beforeEach(() => { + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); }); - it('on first Tab, prevents default and re-focuses without suppression', () => { + it('focuses with focusVisible: true', () => { const button = document.createElement('button'); const container = createContainer(button); - const focusSpy = jest.spyOn(button, 'focus'); - - focusFirstInteractiveElement(container); - expect(focusSpy).toHaveBeenCalledTimes(1); - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - - const tabEvent = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true, cancelable: true}); - const preventSpy = jest.spyOn(tabEvent, 'preventDefault'); - document.dispatchEvent(tabEvent); + const spy = jest.spyOn(button, 'focus'); - expect(preventSpy).toHaveBeenCalled(); - expect(focusSpy).toHaveBeenCalledTimes(2); - expect(button.getAttribute('data-programmatic-focus')).toBeNull(); - expect(button.style.outline).toBe(''); + expect(focusFirstInteractiveElement(container)).toBe(true); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); }); + }); - it('on first Tab when focus moved away, does not prevent default', () => { - const button = document.createElement('button'); - const container = createContainer(button); - - focusFirstInteractiveElement(container); - - // Simulate focus moving away (e.g., user clicked elsewhere) - const otherInput = document.createElement('input'); - document.body.appendChild(otherInput); - otherInput.focus(); - - const tabEvent = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true, cancelable: true}); - const preventSpy = jest.spyOn(tabEvent, 'preventDefault'); - document.dispatchEvent(tabEvent); - - expect(preventSpy).not.toHaveBeenCalled(); + describe('mouse resets keyboard flag', () => { + beforeEach(() => { + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); + document.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); }); - it('on non-Tab keys, does not prevent default and keeps listener for Tab', () => { + it('focuses with focusVisible: false after mousedown', () => { const button = document.createElement('button'); const container = createContainer(button); - const focusSpy = jest.spyOn(button, 'focus'); - - focusFirstInteractiveElement(container); - expect(focusSpy).toHaveBeenCalledTimes(1); - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - - // Arrow key should not trigger interception or remove the listener - const arrowEvent = new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true, cancelable: true}); - document.dispatchEvent(arrowEvent); - expect(arrowEvent.defaultPrevented).toBe(false); + const spy = jest.spyOn(button, 'focus'); - // Suppression attributes should still be present (listener survived ArrowDown) - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - expect(button.style.outline).toBe('none'); + expect(focusFirstInteractiveElement(container)).toBe(true); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); }); }); @@ -195,17 +113,17 @@ describe('focusFirstInteractiveElement', () => { focusFirstInteractiveElement(container); expect(hiddenSpy).not.toHaveBeenCalled(); - expect(visibleSpy).toHaveBeenCalledWith({preventScroll: true}); + expect(visibleSpy).toHaveBeenCalled(); }); - it('returns undefined when all focusable elements are aria-hidden', () => { + it('returns false when all focusable elements are aria-hidden', () => { const hiddenDiv = document.createElement('div'); hiddenDiv.setAttribute('aria-hidden', 'true'); const hiddenButton = document.createElement('button'); hiddenDiv.appendChild(hiddenButton); const container = createContainer(hiddenDiv); - expect(focusFirstInteractiveElement(container)).toBeUndefined(); + expect(focusFirstInteractiveElement(container)).toBe(false); }); }); @@ -254,6 +172,25 @@ describe('focusFirstInteractiveElement', () => { expect(spy).toHaveBeenCalled(); }); + it('finds input elements', () => { + const input = document.createElement('input'); + const container = createContainer(input); + const spy = jest.spyOn(input, 'focus'); + + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalled(); + }); + + it('finds link elements', () => { + const link = document.createElement('a'); + link.setAttribute('href', '#'); + const container = createContainer(link); + const spy = jest.spyOn(link, 'focus'); + + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalled(); + }); + it('skips elements with tabindex="-1"', () => { const skipDiv = document.createElement('div'); skipDiv.setAttribute('tabindex', '-1'); @@ -271,7 +208,6 @@ describe('focusFirstInteractiveElement', () => { const disabledButton = document.createElement('button'); disabledButton.disabled = true; const enabledButton = document.createElement('button'); - enabledButton.setAttribute('aria-label', 'Enabled'); const container = createContainer(disabledButton, enabledButton); const disabledSpy = jest.spyOn(disabledButton, 'focus'); const enabledSpy = jest.spyOn(enabledButton, 'focus'); @@ -294,65 +230,4 @@ describe('focusFirstInteractiveElement', () => { expect(enabledSpy).toHaveBeenCalled(); }); }); - - describe('after keyboard interaction (hadKeyboardEvent = true)', () => { - beforeEach(() => { - document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); - }); - it('focuses without data-programmatic-focus or outline suppression', () => { - const button = document.createElement('button'); - const container = createContainer(button); - const spy = jest.spyOn(button, 'focus'); - - expect(focusFirstInteractiveElement(container)).toBeUndefined(); - expect(spy).toHaveBeenCalledWith({preventScroll: true}); - expect(button.getAttribute('data-programmatic-focus')).toBeNull(); - expect(button.style.outline).toBe(''); - }); - - it('does not register onFirstTab handler', () => { - const button = document.createElement('button'); - const container = createContainer(button); - const focusSpy = jest.spyOn(button, 'focus'); - - focusFirstInteractiveElement(container); - expect(focusSpy).toHaveBeenCalledTimes(1); - - const tabEvent = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true, cancelable: true}); - const preventSpy = jest.spyOn(tabEvent, 'preventDefault'); - document.dispatchEvent(tabEvent); - - // Tab should NOT be intercepted — no preventDefault, no re-focus - expect(preventSpy).not.toHaveBeenCalled(); - expect(focusSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('after mouse resets keyboard flag (mousedown after keydown)', () => { - beforeEach(() => { - document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); - document.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); - }); - - it('restores focus ring suppression and onFirstTab after mousedown', () => { - const button = document.createElement('button'); - const container = createContainer(button); - const focusSpy = jest.spyOn(button, 'focus'); - - expect(focusFirstInteractiveElement(container)).toBeDefined(); - expect(focusSpy).toHaveBeenCalledTimes(1); - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); - expect(button.style.outline).toBe('none'); - - // Tab should be intercepted — re-focus with visible ring - const tabEvent = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true, cancelable: true}); - const preventSpy = jest.spyOn(tabEvent, 'preventDefault'); - document.dispatchEvent(tabEvent); - - expect(preventSpy).toHaveBeenCalled(); - expect(focusSpy).toHaveBeenCalledTimes(2); - expect(button.getAttribute('data-programmatic-focus')).toBeNull(); - expect(button.style.outline).toBe(''); - }); - }); }); From 321fe4bf4928aa50317fd9cb88b75bdc60d26943 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 9 Apr 2026 22:15:36 +0300 Subject: [PATCH 3/5] refactor: track Tab navigation instead of any keyboard event for focusVisible --- src/hooks/useDialogContainerFocus/index.ts | 16 ++- .../unit/focusFirstInteractiveElementTest.ts | 110 ++++++++++++++---- 2 files changed, 97 insertions(+), 29 deletions(-) diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index 2fef5516d75d7..3525dfcbd7e32 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -4,19 +4,25 @@ import type UseDialogContainerFocus from './types'; const FOCUSABLE_SELECTOR = 'button, [href], input, textarea, select, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'; -let hadKeyboardEvent = false; +// Tracks whether the user is Tab-navigating (vs typing in a form or using mouse). +// Tab sets it, typing keys clear it, Enter/Space preserve it, mousedown clears it. +let hadTabNavigation = false; if (typeof document !== 'undefined') { document.addEventListener( 'keydown', - () => { - hadKeyboardEvent = true; + (e: KeyboardEvent) => { + if (e.key === 'Tab') { + hadTabNavigation = true; + } else if (e.key !== 'Enter' && e.key !== ' ') { + hadTabNavigation = false; + } }, true, ); document.addEventListener( 'mousedown', () => { - hadKeyboardEvent = false; + hadTabNavigation = false; }, true, ); @@ -32,7 +38,7 @@ function focusFirstInteractiveElement(container: HTMLElement | null): boolean { if (!target) { return false; } - target.focus({preventScroll: true, focusVisible: hadKeyboardEvent}); + target.focus({preventScroll: true, focusVisible: hadTabNavigation}); return true; } diff --git a/tests/unit/focusFirstInteractiveElementTest.ts b/tests/unit/focusFirstInteractiveElementTest.ts index 9c65065b39dfc..49f4c5e5e6b17 100644 --- a/tests/unit/focusFirstInteractiveElementTest.ts +++ b/tests/unit/focusFirstInteractiveElementTest.ts @@ -20,6 +20,22 @@ function createContainer(...children: HTMLElement[]) { return container; } +function simulateTab() { + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab', bubbles: true})); +} + +function simulateTyping() { + document.dispatchEvent(new KeyboardEvent('keydown', {key: '1', bubbles: true})); +} + +function simulateMouse() { + document.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); +} + +function simulateEnter() { + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); +} + afterEach(() => { document.body.innerHTML = ''; }); @@ -49,40 +65,71 @@ describe('focusFirstInteractiveElement', () => { }); }); - describe('focus with focusVisible (page-load modality)', () => { + describe('Tab navigation (focusVisible: true)', () => { beforeEach(() => { - document.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); + simulateTab(); }); - it('focuses with focusVisible: false', () => { + it('focuses with focusVisible: true after Tab', () => { const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); expect(focusFirstInteractiveElement(container)).toBe(true); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + }); + + it('preserves flag through Enter', () => { + simulateEnter(); + const button = document.createElement('button'); + const container = createContainer(button); + const spy = jest.spyOn(button, 'focus'); + + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + }); + + it('preserves flag through Space', () => { + document.dispatchEvent(new KeyboardEvent('keydown', {key: ' ', bubbles: true})); + const button = document.createElement('button'); + const container = createContainer(button); + const spy = jest.spyOn(button, 'focus'); + + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); }); }); - describe('focus with focusVisible (keyboard modality)', () => { + describe('form interaction (focusVisible: false)', () => { beforeEach(() => { - document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); + simulateTab(); + simulateTyping(); }); - it('focuses with focusVisible: true', () => { + it('clears flag after typing', () => { const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - expect(focusFirstInteractiveElement(container)).toBe(true); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + }); + + it('Enter after typing preserves false', () => { + simulateEnter(); + const button = document.createElement('button'); + const container = createContainer(button); + const spy = jest.spyOn(button, 'focus'); + + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); }); }); - describe('mouse resets keyboard flag', () => { + describe('mouse resets flag', () => { beforeEach(() => { - document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); - document.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); + simulateTab(); + simulateMouse(); }); it('focuses with focusVisible: false after mousedown', () => { @@ -90,14 +137,29 @@ describe('focusFirstInteractiveElement', () => { const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - expect(focusFirstInteractiveElement(container)).toBe(true); + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + }); + }); + + describe('page load (no interaction)', () => { + beforeEach(() => { + simulateMouse(); + }); + + it('focuses with focusVisible: false', () => { + const button = document.createElement('button'); + const container = createContainer(button); + const spy = jest.spyOn(button, 'focus'); + + focusFirstInteractiveElement(container); expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); }); }); describe('aria-hidden filtering', () => { beforeEach(() => { - document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); + simulateTab(); }); it('skips elements inside aria-hidden containers', () => { @@ -129,7 +191,7 @@ describe('focusFirstInteractiveElement', () => { describe('focusable element selection', () => { beforeEach(() => { - document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); + simulateTab(); }); it('finds elements with role="button"', () => { @@ -154,6 +216,15 @@ describe('focusFirstInteractiveElement', () => { expect(spy).toHaveBeenCalled(); }); + it('finds input elements', () => { + const input = document.createElement('input'); + const container = createContainer(input); + const spy = jest.spyOn(input, 'focus'); + + focusFirstInteractiveElement(container); + expect(spy).toHaveBeenCalled(); + }); + it('finds textarea elements', () => { const textarea = document.createElement('textarea'); const container = createContainer(textarea); @@ -172,15 +243,6 @@ describe('focusFirstInteractiveElement', () => { expect(spy).toHaveBeenCalled(); }); - it('finds input elements', () => { - const input = document.createElement('input'); - const container = createContainer(input); - const spy = jest.spyOn(input, 'focus'); - - focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalled(); - }); - it('finds link elements', () => { const link = document.createElement('a'); link.setAttribute('href', '#'); From c99d08ebc856853599eae7c8031e9a62e5cc4a37 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 9 Apr 2026 23:17:56 +0300 Subject: [PATCH 4/5] fix: cast FocusOptions for focusVisible TypeScript compatibility --- src/hooks/useDialogContainerFocus/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index 3525dfcbd7e32..8c6210fce42d7 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -38,7 +38,7 @@ function focusFirstInteractiveElement(container: HTMLElement | null): boolean { if (!target) { return false; } - target.focus({preventScroll: true, focusVisible: hadTabNavigation}); + target.focus({preventScroll: true, focusVisible: hadTabNavigation} as FocusOptions); return true; } From 2c4306076ed831784c86b5945b5c5608421826f7 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 11 Apr 2026 08:13:09 +0300 Subject: [PATCH 5/5] fix: skip focus entirely without Tab navigation and restructure tests --- src/hooks/useDialogContainerFocus/index.ts | 4 +- .../unit/focusFirstInteractiveElementTest.ts | 256 ++++++++---------- 2 files changed, 122 insertions(+), 138 deletions(-) diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index 8c6210fce42d7..88073ba5cc218 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -30,7 +30,7 @@ if (typeof document !== 'undefined') { /** @returns true if an element was focused, false otherwise. */ function focusFirstInteractiveElement(container: HTMLElement | null): boolean { - if (!container || (document.activeElement && document.activeElement !== document.body)) { + if (!hadTabNavigation || !container || (document.activeElement && document.activeElement !== document.body)) { return false; } const targets = container.querySelectorAll(FOCUSABLE_SELECTOR); @@ -38,7 +38,7 @@ function focusFirstInteractiveElement(container: HTMLElement | null): boolean { if (!target) { return false; } - target.focus({preventScroll: true, focusVisible: hadTabNavigation} as FocusOptions); + target.focus({preventScroll: true, focusVisible: true} as FocusOptions); return true; } diff --git a/tests/unit/focusFirstInteractiveElementTest.ts b/tests/unit/focusFirstInteractiveElementTest.ts index 49f4c5e5e6b17..2da97d0380e75 100644 --- a/tests/unit/focusFirstInteractiveElementTest.ts +++ b/tests/unit/focusFirstInteractiveElementTest.ts @@ -36,41 +36,31 @@ function simulateEnter() { document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); } +function simulateSpace() { + document.dispatchEvent(new KeyboardEvent('keydown', {key: ' ', bubbles: true})); +} + afterEach(() => { document.body.innerHTML = ''; }); describe('focusFirstInteractiveElement', () => { - describe('guard conditions', () => { - it('returns false when container is null', () => { - expect(focusFirstInteractiveElement(null)).toBe(false); + describe('when Tab was used (should focus)', () => { + beforeEach(() => { + simulateTab(); }); - it('returns false when another element already has focus', () => { - const input = document.createElement('input'); - document.body.appendChild(input); - input.focus(); + it('should focus the first button with focusVisible: true', () => { const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - expect(focusFirstInteractiveElement(container)).toBe(false); - expect(spy).not.toHaveBeenCalled(); - }); - - it('returns false when container has no focusable elements', () => { - const container = createContainer(document.createElement('div')); - - expect(focusFirstInteractiveElement(container)).toBe(false); - }); - }); - - describe('Tab navigation (focusVisible: true)', () => { - beforeEach(() => { - simulateTab(); + expect(focusFirstInteractiveElement(container)).toBe(true); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); }); - it('focuses with focusVisible: true after Tab', () => { + it('should focus after Tab → Enter', () => { + simulateEnter(); const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); @@ -79,96 +69,96 @@ describe('focusFirstInteractiveElement', () => { expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); }); - it('preserves flag through Enter', () => { - simulateEnter(); + it('should focus after Tab → Space', () => { + simulateSpace(); const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - focusFirstInteractiveElement(container); + expect(focusFirstInteractiveElement(container)).toBe(true); expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); }); - it('preserves flag through Space', () => { - document.dispatchEvent(new KeyboardEvent('keydown', {key: ' ', bubbles: true})); + it('should not focus when container is null', () => { + expect(focusFirstInteractiveElement(null)).toBe(false); + }); + + it('should not focus when another element already has focus', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + expect(focusFirstInteractiveElement(container)).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not focus when container has no focusable elements', () => { + const container = createContainer(document.createElement('div')); + + expect(focusFirstInteractiveElement(container)).toBe(false); }); }); - describe('form interaction (focusVisible: false)', () => { - beforeEach(() => { + describe('when Tab was NOT used (should skip focus)', () => { + it('should skip after typing', () => { simulateTab(); simulateTyping(); - }); - - it('clears flag after typing', () => { const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + expect(focusFirstInteractiveElement(container)).toBe(false); + expect(spy).not.toHaveBeenCalled(); }); - it('Enter after typing preserves false', () => { + it('should skip after typing → Enter', () => { + simulateTab(); + simulateTyping(); simulateEnter(); const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + expect(focusFirstInteractiveElement(container)).toBe(false); + expect(spy).not.toHaveBeenCalled(); }); - }); - describe('mouse resets flag', () => { - beforeEach(() => { + it('should skip after mousedown', () => { simulateTab(); simulateMouse(); - }); - - it('focuses with focusVisible: false after mousedown', () => { const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + expect(focusFirstInteractiveElement(container)).toBe(false); + expect(spy).not.toHaveBeenCalled(); }); - }); - describe('page load (no interaction)', () => { - beforeEach(() => { + it('should skip on page load (no interaction)', () => { simulateMouse(); - }); - - it('focuses with focusVisible: false', () => { const button = document.createElement('button'); const container = createContainer(button); const spy = jest.spyOn(button, 'focus'); - focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + expect(focusFirstInteractiveElement(container)).toBe(false); + expect(spy).not.toHaveBeenCalled(); }); }); - describe('aria-hidden filtering', () => { + describe('element filtering', () => { beforeEach(() => { simulateTab(); }); - it('skips elements inside aria-hidden containers', () => { + it('should skip elements inside aria-hidden containers', () => { const hiddenDiv = document.createElement('div'); hiddenDiv.setAttribute('aria-hidden', 'true'); const hiddenButton = document.createElement('button'); hiddenDiv.appendChild(hiddenButton); const visibleButton = document.createElement('button'); - visibleButton.setAttribute('aria-label', 'Visible'); const container = createContainer(hiddenDiv, visibleButton); const hiddenSpy = jest.spyOn(hiddenButton, 'focus'); const visibleSpy = jest.spyOn(visibleButton, 'focus'); @@ -178,118 +168,112 @@ describe('focusFirstInteractiveElement', () => { expect(visibleSpy).toHaveBeenCalled(); }); - it('returns false when all focusable elements are aria-hidden', () => { + it('should return false when all elements are aria-hidden', () => { const hiddenDiv = document.createElement('div'); hiddenDiv.setAttribute('aria-hidden', 'true'); - const hiddenButton = document.createElement('button'); - hiddenDiv.appendChild(hiddenButton); + hiddenDiv.appendChild(document.createElement('button')); const container = createContainer(hiddenDiv); expect(focusFirstInteractiveElement(container)).toBe(false); }); - }); - - describe('focusable element selection', () => { - beforeEach(() => { - simulateTab(); - }); - it('finds elements with role="button"', () => { - const div = document.createElement('div'); - div.setAttribute('role', 'button'); - div.setAttribute('tabindex', '0'); - const container = createContainer(div); - const spy = jest.spyOn(div, 'focus'); + it('should skip disabled elements', () => { + const disabledButton = document.createElement('button'); + disabledButton.disabled = true; + const enabledButton = document.createElement('button'); + const container = createContainer(disabledButton, enabledButton); + const disabledSpy = jest.spyOn(disabledButton, 'focus'); + const enabledSpy = jest.spyOn(enabledButton, 'focus'); focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalled(); + expect(disabledSpy).not.toHaveBeenCalled(); + expect(enabledSpy).toHaveBeenCalled(); }); - it('finds elements with role="link"', () => { - const div = document.createElement('div'); - div.setAttribute('role', 'link'); - div.setAttribute('tabindex', '0'); - const container = createContainer(div); - const spy = jest.spyOn(div, 'focus'); + it('should skip elements with aria-disabled="true"', () => { + const ariaDisabledButton = document.createElement('button'); + ariaDisabledButton.setAttribute('aria-disabled', 'true'); + const enabledButton = document.createElement('button'); + const container = createContainer(ariaDisabledButton, enabledButton); + const disabledSpy = jest.spyOn(ariaDisabledButton, 'focus'); + const enabledSpy = jest.spyOn(enabledButton, 'focus'); focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalled(); + expect(disabledSpy).not.toHaveBeenCalled(); + expect(enabledSpy).toHaveBeenCalled(); }); - it('finds input elements', () => { - const input = document.createElement('input'); - const container = createContainer(input); - const spy = jest.spyOn(input, 'focus'); + it('should skip elements with tabindex="-1"', () => { + const skipDiv = document.createElement('div'); + skipDiv.setAttribute('tabindex', '-1'); + const button = document.createElement('button'); + const container = createContainer(skipDiv, button); + const skipSpy = jest.spyOn(skipDiv, 'focus'); + const buttonSpy = jest.spyOn(button, 'focus'); focusFirstInteractiveElement(container); - expect(spy).toHaveBeenCalled(); + expect(skipSpy).not.toHaveBeenCalled(); + expect(buttonSpy).toHaveBeenCalled(); }); + }); - it('finds textarea elements', () => { - const textarea = document.createElement('textarea'); - const container = createContainer(textarea); - const spy = jest.spyOn(textarea, 'focus'); + describe('selector coverage', () => { + beforeEach(() => { + simulateTab(); + }); - focusFirstInteractiveElement(container); + it('should find button', () => { + const el = document.createElement('button'); + const spy = jest.spyOn(el, 'focus'); + focusFirstInteractiveElement(createContainer(el)); expect(spy).toHaveBeenCalled(); }); - it('finds select elements', () => { - const select = document.createElement('select'); - const container = createContainer(select); - const spy = jest.spyOn(select, 'focus'); - - focusFirstInteractiveElement(container); + it('should find link (a[href])', () => { + const el = document.createElement('a'); + el.setAttribute('href', '#'); + const spy = jest.spyOn(el, 'focus'); + focusFirstInteractiveElement(createContainer(el)); expect(spy).toHaveBeenCalled(); }); - it('finds link elements', () => { - const link = document.createElement('a'); - link.setAttribute('href', '#'); - const container = createContainer(link); - const spy = jest.spyOn(link, 'focus'); - - focusFirstInteractiveElement(container); + it('should find input', () => { + const el = document.createElement('input'); + const spy = jest.spyOn(el, 'focus'); + focusFirstInteractiveElement(createContainer(el)); expect(spy).toHaveBeenCalled(); }); - it('skips elements with tabindex="-1"', () => { - const skipDiv = document.createElement('div'); - skipDiv.setAttribute('tabindex', '-1'); - const button = document.createElement('button'); - const container = createContainer(skipDiv, button); - const skipSpy = jest.spyOn(skipDiv, 'focus'); - const buttonSpy = jest.spyOn(button, 'focus'); - - focusFirstInteractiveElement(container); - expect(skipSpy).not.toHaveBeenCalled(); - expect(buttonSpy).toHaveBeenCalled(); + it('should find textarea', () => { + const el = document.createElement('textarea'); + const spy = jest.spyOn(el, 'focus'); + focusFirstInteractiveElement(createContainer(el)); + expect(spy).toHaveBeenCalled(); }); - it('skips disabled elements', () => { - const disabledButton = document.createElement('button'); - disabledButton.disabled = true; - const enabledButton = document.createElement('button'); - const container = createContainer(disabledButton, enabledButton); - const disabledSpy = jest.spyOn(disabledButton, 'focus'); - const enabledSpy = jest.spyOn(enabledButton, 'focus'); - - focusFirstInteractiveElement(container); - expect(disabledSpy).not.toHaveBeenCalled(); - expect(enabledSpy).toHaveBeenCalled(); + it('should find select', () => { + const el = document.createElement('select'); + const spy = jest.spyOn(el, 'focus'); + focusFirstInteractiveElement(createContainer(el)); + expect(spy).toHaveBeenCalled(); }); - it('skips elements with aria-disabled="true"', () => { - const ariaDisabledButton = document.createElement('button'); - ariaDisabledButton.setAttribute('aria-disabled', 'true'); - const enabledButton = document.createElement('button'); - const container = createContainer(ariaDisabledButton, enabledButton); - const disabledSpy = jest.spyOn(ariaDisabledButton, 'focus'); - const enabledSpy = jest.spyOn(enabledButton, 'focus'); + it('should find [role="button"]', () => { + const el = document.createElement('div'); + el.setAttribute('role', 'button'); + el.setAttribute('tabindex', '0'); + const spy = jest.spyOn(el, 'focus'); + focusFirstInteractiveElement(createContainer(el)); + expect(spy).toHaveBeenCalled(); + }); - focusFirstInteractiveElement(container); - expect(disabledSpy).not.toHaveBeenCalled(); - expect(enabledSpy).toHaveBeenCalled(); + it('should find [role="link"]', () => { + const el = document.createElement('div'); + el.setAttribute('role', 'link'); + el.setAttribute('tabindex', '0'); + const spy = jest.spyOn(el, 'focus'); + focusFirstInteractiveElement(createContainer(el)); + expect(spy).toHaveBeenCalled(); }); }); });