Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add useFocusTrap hook",
"packageName": "@fluentui/react-headless-components-preview",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## API Report File for "@fluentui/react-headless-components-preview"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import * as React_2 from 'react';

// @public
export function useFocusTrap(active?: boolean): React_2.RefCallback<HTMLElement | null>;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@
"import": "./lib/toggle-button.js",
"require": "./lib-commonjs/toggle-button.js"
},
"./focus": {
"types": "./dist/focus.d.ts",
"node": "./lib-commonjs/focus.js",
"import": "./lib/focus.js",
"require": "./lib-commonjs/focus.js"
},
"./toolbar": {
"types": "./dist/toolbar.d.ts",
"node": "./lib-commonjs/toolbar.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useFocusTrap } from './hooks/useFocusTrap';
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export type {
PositioningShorthandValue,
} from './usePositioning';
export { POSITIONS, ALIGNMENTS, getPlacementString, resolvePositioningShorthand } from './usePositioning';
export { useFocusTrap } from './useFocusTrap';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const TABBABLE_NODES = /input|select|textarea|button|object/;

export const FOCUS_SELECTOR = 'a, input, select, textarea, button, object, [tabindex]';
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { findAllTabbable } from './utils';

/**
* Wraps Tab navigation when focus reaches the boundary of `node`.
*
* - Tab on the last tabbable → focus the first.
* - Shift+Tab on the first tabbable → focus the last.
* - Otherwise, leaves the keyboard event alone and lets the browser handle it.
*
* If `node` contains no tabbable descendants, the Tab event is suppressed so
* focus cannot escape the trap.
*/
export function cycleTabFocus(node: HTMLElement, event: KeyboardEvent): void {
const tabbables = findAllTabbable(node);
if (!tabbables.length) {
event.preventDefault();
return;
}

const boundary = tabbables[event.shiftKey ? 0 : tabbables.length - 1];
const root = node.getRootNode() as Document | ShadowRoot;
const atBoundary = boundary === root.activeElement || node === root.activeElement;

if (!atBoundary) {
return;
}

event.preventDefault();
const target = tabbables[event.shiftKey ? tabbables.length - 1 : 0];
target?.focus();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useFocusTrap } from './useFocusTrap';
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as React from 'react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { useFocusTrap } from './useFocusTrap';

type TestTrapProps = {
active?: boolean;
children?: React.ReactNode;
trapAttrs?: React.HTMLAttributes<HTMLDivElement>;
};

const TestTrap = ({ active = true, children, trapAttrs }: TestTrapProps) => {
const setRef = useFocusTrap(active);
return (
<div ref={setRef} data-testid="trap" {...trapAttrs}>
{children}
</div>
);
};

function flushAutoFocus() {
act(() => {
jest.runAllTimers();
});
}

describe('useFocusTrap', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('returns a ref callback', () => {
const captured: { current: React.RefCallback<HTMLElement | null> | null } = { current: null };
const Capture = () => {
captured.current = useFocusTrap();
return null;
};
render(<Capture />);

expect(typeof captured.current).toBe('function');
});

it('focuses the element marked with [data-autofocus]', () => {
render(
<TestTrap>
<button>first</button>
<button data-autofocus>auto</button>
</TestTrap>,
);
flushAutoFocus();

expect(screen.getByRole('button', { name: 'auto' })).toHaveFocus();
});

it('falls back to the first tabbable descendant when no [data-autofocus] is present', () => {
render(
<TestTrap>
<button>one</button>
<button>two</button>
</TestTrap>,
);
flushAutoFocus();

expect(screen.getByRole('button', { name: 'one' })).toHaveFocus();
});

it('focuses a focusable-but-not-tabbable descendant when no tabbable is present', () => {
render(
<TestTrap>
<a href="#" tabIndex={-1}>
link
</a>
</TestTrap>,
);
flushAutoFocus();

expect(screen.getByRole('link', { name: 'link' })).toHaveFocus();
});

it('focuses the trap node itself when it is the only focusable element', () => {
render(<TestTrap trapAttrs={{ tabIndex: 0 }} />);
flushAutoFocus();

expect(screen.getByTestId('trap')).toHaveFocus();
});

it('does nothing when active=false', () => {
render(
<TestTrap active={false}>
<button data-autofocus>auto</button>
</TestTrap>,
);

flushAutoFocus();

expect(document.body).toHaveFocus();
});

it('cycles focus from the last tabbable to the first when Tab is pressed', () => {
render(
<TestTrap>
<button>one</button>
<button>two</button>
</TestTrap>,
);

flushAutoFocus();

const [first] = screen.getAllByRole('button');

userEvent.tab();
userEvent.tab();

expect(first).toHaveFocus();
});

it('cycles focus from the first tabbable to the last when Shift+Tab is pressed', () => {
render(
<TestTrap>
<button>one</button>
<button>two</button>
</TestTrap>,
);
flushAutoFocus();

const [, last] = screen.getAllByRole('button');

userEvent.tab({ shift: true });

expect(last).toHaveFocus();
});

it('cleans up on unmount', () => {
const { unmount } = render(
<TestTrap>
<button>one</button>
<button>two</button>
</TestTrap>,
);

flushAutoFocus();

expect(() => unmount()).not.toThrow();

render(
<TestTrap>
<button>three</button>
<button>four</button>
</TestTrap>,
);

flushAutoFocus();

expect(screen.getByRole('button', { name: 'three' })).toHaveFocus();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client';

import * as React from 'react';
import { Tab } from '@fluentui/keyboard-keys';
import { useTimeout } from '@fluentui/react-utilities';
import { useFluent_unstable } from '@fluentui/react-shared-contexts';
import { cycleTabFocus } from './cycleTabFocus';
import { findFocusable, findTabbable, isFocusable } from './utils';

/**
* Traps keyboard focus within the element the returned ref is attached to.
*
* When the trap activates, focus is moved to the first element matching
* `[data-autofocus]`, falling back to the first tabbable descendant, then any
* focusable descendant, and finally the trap node itself. While the trap is
* active, Tab and Shift+Tab cycle focus between the first and last tabbable
* descendants.
*
* @param active - whether the trap is enabled. Defaults to `true`.
* @returns a ref callback to attach to the element that should hold focus.
*/
export function useFocusTrap(active = true): React.RefCallback<HTMLElement | null> {
const ref = React.useRef<HTMLElement>(null);
const [setTimeout, clearTimeout] = useTimeout();
const { targetDocument } = useFluent_unstable();

const focusNode = (node: HTMLElement) => {
const focusElement =
node.querySelector<HTMLElement>('[data-autofocus]') ??
findTabbable(node) ??
findFocusable(node) ??
(isFocusable(node) ? node : null);

if (focusElement) {
focusElement.focus({ preventScroll: true });
} else if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.warn('[useFocusTrap] Failed to find focusable element within provided node', node);
}
};

const setRef = React.useCallback(
(node: HTMLElement | null) => {
if (!active) {
return;
}

if (node === null) {
clearTimeout();
ref.current = null;
return;
}

if (ref.current === node) {
return;
}

ref.current = node;

setTimeout(() => {
if (node.isConnected) {
focusNode(node);
} else if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.warn('[useFocusTrap] Ref node is not part of the dom', node);
}
});
},
[active, setTimeout, clearTimeout],
);

React.useEffect(() => {
if (!active) {
return undefined;
}

if (ref.current) {
setTimeout(() => {
if (ref.current) {
focusNode(ref.current);
}
});
}

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === Tab && ref.current) {
cycleTabFocus(ref.current, event);
}
};

targetDocument?.addEventListener('keydown', handleKeyDown);
return () => {
clearTimeout();
targetDocument?.removeEventListener('keydown', handleKeyDown);
};
}, [active, setTimeout, clearTimeout, targetDocument]);

return setRef;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FOCUS_SELECTOR } from '../constants';
import { isFocusable } from './isFocusable';

/**
* Returns the first focusable descendant of `container`, or `null` if none exists.
*/
export function findFocusable(container: HTMLElement): HTMLElement | null {
const candidates = Array.from(container.querySelectorAll<HTMLElement>(FOCUS_SELECTOR));
return candidates.find(isFocusable) ?? null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FOCUS_SELECTOR } from '../constants';
import { isTabbable } from './isTabbable';

/**
* Returns the first tabbable descendant of `container`, or `null` if none exists.
*/
export function findTabbable(container: HTMLElement): HTMLElement | null {
const candidates = Array.from(container.querySelectorAll<HTMLElement>(FOCUS_SELECTOR));
return candidates.find(isTabbable) ?? null;
}

/**
* Returns every tabbable descendant of `container` in document order.
*/
export function findAllTabbable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUS_SELECTOR)).filter(isTabbable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Reads the `tabindex` attribute and returns it as a number.
* Returns `NaN` when the attribute is absent or unparseable.
*/
export function getTabIndex(element: HTMLElement): number {
const tabIndex = element.getAttribute('tabindex');
return parseInt(tabIndex ?? '', 10);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { findFocusable } from './findFocusable';
export { findTabbable, findAllTabbable } from './findTabbable';
export { isFocusable } from './isFocusable';
Loading
Loading