diff --git a/change/@fluentui-react-headless-components-preview-5da52a68-8fac-475d-be37-2410f33fb32a.json b/change/@fluentui-react-headless-components-preview-5da52a68-8fac-475d-be37-2410f33fb32a.json new file mode 100644 index 00000000000000..c1ed1fd499090d --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-5da52a68-8fac-475d-be37-2410f33fb32a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add useFocusTrap hook", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/etc/focus.api.md b/packages/react-components/react-headless-components-preview/library/etc/focus.api.md new file mode 100644 index 00000000000000..54ad2c14fed531 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/focus.api.md @@ -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; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index e849e95241e356..f575a7588f4264 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -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", diff --git a/packages/react-components/react-headless-components-preview/library/src/focus.ts b/packages/react-components/react-headless-components-preview/library/src/focus.ts new file mode 100644 index 00000000000000..26a3edd910cc73 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/focus.ts @@ -0,0 +1 @@ +export { useFocusTrap } from './hooks/useFocusTrap'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts index 14bbafd814b9b7..4c764c96692a6e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts @@ -9,3 +9,4 @@ export type { PositioningShorthandValue, } from './usePositioning'; export { POSITIONS, ALIGNMENTS, getPlacementString, resolvePositioningShorthand } from './usePositioning'; +export { useFocusTrap } from './useFocusTrap'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/constants.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/constants.ts new file mode 100644 index 00000000000000..faed3f2b5747cd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/constants.ts @@ -0,0 +1,3 @@ +export const TABBABLE_NODES = /input|select|textarea|button|object/; + +export const FOCUS_SELECTOR = 'a, input, select, textarea, button, object, [tabindex]'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/cycleTabFocus.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/cycleTabFocus.ts new file mode 100644 index 00000000000000..62b8e89cdc9aff --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/cycleTabFocus.ts @@ -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(); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/index.ts new file mode 100644 index 00000000000000..7c8d9c46e73d2a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/index.ts @@ -0,0 +1 @@ +export { useFocusTrap } from './useFocusTrap'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/useFocusTrap.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/useFocusTrap.test.tsx new file mode 100644 index 00000000000000..288a59371d00f5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/useFocusTrap.test.tsx @@ -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; +}; + +const TestTrap = ({ active = true, children, trapAttrs }: TestTrapProps) => { + const setRef = useFocusTrap(active); + return ( +
+ {children} +
+ ); +}; + +function flushAutoFocus() { + act(() => { + jest.runAllTimers(); + }); +} + +describe('useFocusTrap', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns a ref callback', () => { + const captured: { current: React.RefCallback | null } = { current: null }; + const Capture = () => { + captured.current = useFocusTrap(); + return null; + }; + render(); + + expect(typeof captured.current).toBe('function'); + }); + + it('focuses the element marked with [data-autofocus]', () => { + render( + + + + , + ); + flushAutoFocus(); + + expect(screen.getByRole('button', { name: 'auto' })).toHaveFocus(); + }); + + it('falls back to the first tabbable descendant when no [data-autofocus] is present', () => { + render( + + + + , + ); + flushAutoFocus(); + + expect(screen.getByRole('button', { name: 'one' })).toHaveFocus(); + }); + + it('focuses a focusable-but-not-tabbable descendant when no tabbable is present', () => { + render( + + + link + + , + ); + flushAutoFocus(); + + expect(screen.getByRole('link', { name: 'link' })).toHaveFocus(); + }); + + it('focuses the trap node itself when it is the only focusable element', () => { + render(); + flushAutoFocus(); + + expect(screen.getByTestId('trap')).toHaveFocus(); + }); + + it('does nothing when active=false', () => { + render( + + + , + ); + + flushAutoFocus(); + + expect(document.body).toHaveFocus(); + }); + + it('cycles focus from the last tabbable to the first when Tab is pressed', () => { + render( + + + + , + ); + + 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( + + + + , + ); + flushAutoFocus(); + + const [, last] = screen.getAllByRole('button'); + + userEvent.tab({ shift: true }); + + expect(last).toHaveFocus(); + }); + + it('cleans up on unmount', () => { + const { unmount } = render( + + + + , + ); + + flushAutoFocus(); + + expect(() => unmount()).not.toThrow(); + + render( + + + + , + ); + + flushAutoFocus(); + + expect(screen.getByRole('button', { name: 'three' })).toHaveFocus(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/useFocusTrap.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/useFocusTrap.ts new file mode 100644 index 00000000000000..e1d18c690d7509 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/useFocusTrap.ts @@ -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 { + const ref = React.useRef(null); + const [setTimeout, clearTimeout] = useTimeout(); + const { targetDocument } = useFluent_unstable(); + + const focusNode = (node: HTMLElement) => { + const focusElement = + node.querySelector('[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; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/findFocusable.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/findFocusable.ts new file mode 100644 index 00000000000000..9545ddb334cf50 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/findFocusable.ts @@ -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(FOCUS_SELECTOR)); + return candidates.find(isFocusable) ?? null; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/findTabbable.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/findTabbable.ts new file mode 100644 index 00000000000000..6ecc726f95f243 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/findTabbable.ts @@ -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(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(FOCUS_SELECTOR)).filter(isTabbable); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/getTabIndex.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/getTabIndex.ts new file mode 100644 index 00000000000000..f7c4392a15bd6a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/getTabIndex.ts @@ -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); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/index.ts new file mode 100644 index 00000000000000..9f37e20daafc26 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/index.ts @@ -0,0 +1,3 @@ +export { findFocusable } from './findFocusable'; +export { findTabbable, findAllTabbable } from './findTabbable'; +export { isFocusable } from './isFocusable'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isFocusable.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isFocusable.ts new file mode 100644 index 00000000000000..4b6e852df938fb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isFocusable.ts @@ -0,0 +1,21 @@ +import { isHTMLElement } from '@fluentui/react-utilities'; +import { TABBABLE_NODES } from '../constants'; +import { getTabIndex } from './getTabIndex'; +import { isVisible } from './isVisible'; + +/** + * Predicate: can the element receive programmatic focus? + * Considers element type, `disabled`, `tabindex`, anchor `href`, and visibility. + */ +export function isFocusable(element: HTMLElement): boolean { + const nodeName = element.nodeName.toLowerCase(); + const hasExplicitTabIndex = !Number.isNaN(getTabIndex(element)); + const isAnchor = isHTMLElement(element, { constructorName: 'HTMLAnchorElement' }); + const isFormControl = TABBABLE_NODES.test(nodeName); + const isDisabled = (element as { disabled?: boolean }).disabled === true; + + const focusable = + (isFormControl && !isDisabled) || (isAnchor ? Boolean(element.href) || hasExplicitTabIndex : hasExplicitTabIndex); + + return focusable && isVisible(element); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isTabbable.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isTabbable.ts new file mode 100644 index 00000000000000..690ff6a0f20b4f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isTabbable.ts @@ -0,0 +1,11 @@ +import { getTabIndex } from './getTabIndex'; +import { isFocusable } from './isFocusable'; + +/** + * Predicate: is the element reachable via Tab key navigation? + * A tabbable element is focusable AND has either no `tabindex` or a non-negative `tabindex`. + */ +export function isTabbable(element: HTMLElement): boolean { + const tabIndex = getTabIndex(element); + return (Number.isNaN(tabIndex) || tabIndex >= 0) && isFocusable(element); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isVisible.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isVisible.ts new file mode 100644 index 00000000000000..02a7c42ceba992 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useFocusTrap/utils/isVisible.ts @@ -0,0 +1,40 @@ +function isInlineHidden(element: HTMLElement): boolean { + // jsdom doesn't compute layout, so this check is unreliable in unit tests. + if (process.env.NODE_ENV === 'test') { + return false; + } + + return element.style.display === 'none'; +} + +/** + * Determines whether an element is "visible" for focus purposes: + * not aria-hidden, not the `hidden` attribute, not `type="hidden"`, and + * none of its ancestors up to the document body are inline-hidden. + */ +export function isVisible(element: HTMLElement): boolean { + const hidden = + element.getAttribute('aria-hidden') === 'true' || + element.hasAttribute('hidden') || + element.getAttribute('type') === 'hidden'; + + if (hidden) { + return false; + } + + let parent: HTMLElement = element; + + while (parent) { + if (parent === parent.ownerDocument?.body || parent.nodeType === 11) { + break; + } + + if (isInlineHidden(parent)) { + return false; + } + + parent = parent.parentNode as HTMLElement; + } + + return true; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapAutoFocus.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapAutoFocus.stories.tsx new file mode 100644 index 00000000000000..05b6ab26fe280a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapAutoFocus.stories.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { useFocusTrap } from '@fluentui/react-headless-components-preview/focus'; + +const classes = { + button: + 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none disabled:opacity-50 disabled:cursor-not-allowed', + ghostButton: + 'px-4 py-2 rounded-md bg-white text-gray-800 font-medium border border-gray-300 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer', + panel: 'flex flex-col gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200 max-w-sm', + hint: 'text-sm text-gray-600', + input: + 'px-3 py-2 rounded-md border border-gray-300 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2', +}; + +export const AutoFocus = (): React.ReactNode => { + const [open, setOpen] = React.useState(false); + const trapRef = useFocusTrap(open); + + return ( +
+ + +

+ On open, focus jumps to the input marked with data-autofocus instead of the first tabbable element. +

+ + {open && ( +
+

Edit name

+ + + +
+ )} +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapDefault.stories.tsx new file mode 100644 index 00000000000000..5fe572f4bcb470 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapDefault.stories.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { useFocusTrap } from '@fluentui/react-headless-components-preview/focus'; + +const classes = { + button: + 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none disabled:opacity-50 disabled:cursor-not-allowed', + ghostButton: + 'px-4 py-2 rounded-md bg-white text-gray-800 font-medium border border-gray-300 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer', + panel: 'flex flex-col gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200 max-w-sm', + hint: 'text-sm text-gray-600', +}; + +export const Default = (): React.ReactNode => { + const [open, setOpen] = React.useState(false); + const trapRef = useFocusTrap(open); + + return ( +
+ + +

+ Open the panel, then press Tab / Shift+Tab. Focus cycles inside the panel and + cannot escape until you close it. +

+ + {open && ( +
+

Trapped panel

+ + + +
+ )} +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapDescription.md new file mode 100644 index 00000000000000..4fcda1e2525d92 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/FocusTrapDescription.md @@ -0,0 +1,20 @@ +`useFocusTrap` keeps keyboard focus inside a single element while the trap is active. + +When the trap activates, focus moves to the first descendant matching `[data-autofocus]`, falling back to the first tabbable descendant, then any focusable descendant, and finally the trap node itself. While active, **Tab** and **Shift+Tab** cycle focus between the first and last tabbable descendants — the user cannot Tab out. + +```tsx +import { useFocusTrap } from '@fluentui/react-headless-components-preview/focus'; + +const Panel = ({ open, onClose }: { open: boolean; onClose: () => void }) => { + const ref = useFocusTrap(open); + if (!open) return null; + return ( +
+ + +
+ ); +}; +``` + +The example below opens an inline panel and traps focus inside it. Open the panel, then press **Tab** repeatedly: focus stays in the panel, cycling between the buttons. Press **Shift+Tab** from the first button to wrap to the last. Click **Close** to release the trap. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/index.stories.tsx new file mode 100644 index 00000000000000..9c231b4fa66d73 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/index.stories.tsx @@ -0,0 +1,18 @@ +import { FocusTrap } from './utils.stories'; + +import descriptionMd from './FocusTrapDescription.md'; + +export { Default } from './FocusTrapDefault.stories'; +export { AutoFocus } from './FocusTrapAutoFocus.stories'; + +export default { + title: 'Headless Concepts/FocusTrap', + component: FocusTrap, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/utils.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/utils.stories.tsx new file mode 100644 index 00000000000000..0ff7ad1e8f7a69 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/FocusTrap/utils.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; + +type FocusTrapProps = { + /** Whether the trap is enabled. */ + active?: boolean; +}; + +/** + * Helper component used by Storybook to auto-generate the `useFocusTrap` + * args table. The hook itself doesn't render anything. + */ +export const FocusTrap: React.FC = () =>
;