Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid resetting initial focus claim on each label push

Resetting initialFocusClaimedRef inside pushLabel makes focus eligibility reopen on any label re-registration, not just real screen transitions. useDialogLabelRegistration re-runs its effect whenever pushLabel/popLabel identities change (src/hooks/useDialogLabelRegistration.ts:12-18), which happens when DialogLabelProvider re-renders; that causes a new pushLabel() call, flips this flag back to false, and allows useDialogContainerFocus in Header (src/components/Header.tsx:54-56) to re-focus dialog controls during unrelated re-renders (for example responsive/layout updates), leading to unexpected focus jumps.

Useful? React with 👍 / 👎.

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 {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;
// 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} as 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 @@ 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(() => {
Expand All @@ -83,14 +61,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]);
};
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