Skip to content
Open
1 change: 1 addition & 0 deletions src/components/DialogLabelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
57 changes: 17 additions & 40 deletions src/hooks/useDialogContainerFocus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,43 @@
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;
// 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,
);
}

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<HTMLElement>(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: hadTabNavigation});

Check failure on line 41 in src/hooks/useDialogContainerFocus/index.ts

View workflow job for this annotation

GitHub Actions / typecheck

Object literal may only specify known properties, and 'focusVisible' does not exist in type 'FocusOptions'.
return true;
}

/** Focuses the first interactive element inside the dialog after the RHP transition for screen reader announcement. */
Expand All @@ -71,7 +50,6 @@
}
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(() => {
Expand All @@ -83,14 +61,13 @@
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]);
};
Expand Down
5 changes: 3 additions & 2 deletions tests/unit/DialogLabelContextTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading
Loading