Skip to content
Merged
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 apps/docs/docgen.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ module.exports = {
'numpad/Numpad',
'overlays/Alert',
'overlays/OverlayContentContext',
'overlays/FocusTrap',
'overlays/FullscreenAlert',
'overlays/modal/Modal',
'overlays/modal/ModalHeader',
Expand Down
122 changes: 122 additions & 0 deletions apps/docs/docs/components/overlay/FocusTrap/_webExamples.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
:::note Before using FocusTrap
`<FocusTrap>` is intended to prevent keyboard users from interacting with elements on the page that a mouse user cannot interact with either. An example of this is `<Modal>` where the user cannot click the items behind the modal. If you want to shift focus based on UI events, consider using the [.focus()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus) method instead.
:::

:::warning Accessibility
It is **required** that when using a `<FocusTrap>` there is logic using only key presses to escape the focus trap. Otherwise, keyboard users will be blocked after they enter a `<FocusTrap>`.
:::

## Basic example

:::note
All the examples have controls to enable / disable the focus trap so that page keyboard navigation isn't blocked.
:::
Copy link
Contributor

Choose a reason for hiding this comment

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

small nit: this note seems a little overkill with the accessibility warning shown above. But if you are worried about people removing the logic to exit the focus trap functionality I think it is fine to keep.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd like to keep it since it's important for customers to implement exit logic. And <FocusTrap> can't determine if exit logic has been added. If not implemented, keyboard nav users can be completely blocked resulting in critical bugs.


When enabled, only the children of the `<FocusTrap>` will be able to receive focus. Otherwise, standard DOM focus order follows.

```jsx live
function Example() {
const [focusTrapEnabled, setFocusTrapEnabled] = useState(false);

return (
<VStack gap={2}>
<Checkbox
checked={focusTrapEnabled}
onChange={() => setFocusTrapEnabled((prev) => !prev)}
label="Focus trap enabled"
/>
{focusTrapEnabled && (
<FocusTrap>
<VStack gap={2} background="bgAlternate" padding={2}>
<Text>Inside FocusTrap</Text>
<HStack gap={1}>
<Button>1</Button>
<Button>2</Button>
<Button>3</Button>
</HStack>
<Checkbox
checked={focusTrapEnabled}
onChange={() => setFocusTrapEnabled((prev) => !prev)}
label="Focus trap enabled"
/>
</VStack>
</FocusTrap>
)}
</VStack>
);
}
```

## Include trigger in FocusTrap

The `includeTriggerInFocusTrap` prop includes the triggering element causing the `<FocusTrap>` to render as part of the focus order.

```jsx live
function Example() {
const [focusTrapEnabled, setFocusTrapEnabled] = useState(false);

return (
<VStack gap={2}>
<Checkbox
checked={focusTrapEnabled}
onChange={() => setFocusTrapEnabled((prev) => !prev)}
label="Focus trap enabled"
/>
{focusTrapEnabled && (
<FocusTrap includeTriggerInFocusTrap>
<VStack gap={2} background="bgAlternate" padding={2}>
<Text>Inside FocusTrap</Text>
<HStack gap={1}>
<Button>1</Button>
<Button>2</Button>
<Button>3</Button>
</HStack>
<Checkbox
checked={focusTrapEnabled}
onChange={() => setFocusTrapEnabled((prev) => !prev)}
label="Focus trap enabled"
/>
</VStack>
</FocusTrap>
)}
</VStack>
);
}
```

## Restore focus on unmount

The `restoreFocusOnUnmount` prop returns focus to the triggering element when the `<FocusTrap>` is unmounted.

```jsx live
function Example() {
const [focusTrapEnabled, setFocusTrapEnabled] = useState(false);

return (
<VStack gap={2}>
<Checkbox
checked={focusTrapEnabled}
onChange={() => setFocusTrapEnabled((prev) => !prev)}
label="Focus trap enabled"
/>
{focusTrapEnabled && (
<FocusTrap restoreFocusOnUnmount>
<VStack gap={2} background="bgAlternate" padding={2}>
<Text>Inside FocusTrap</Text>
<HStack gap={1}>
<Button>1</Button>
<Button>2</Button>
<Button>3</Button>
</HStack>
<Checkbox
checked={focusTrapEnabled}
onChange={() => setFocusTrapEnabled((prev) => !prev)}
label="Focus trap enabled"
/>
</VStack>
</FocusTrap>
)}
</VStack>
);
}
```
10 changes: 10 additions & 0 deletions apps/docs/docs/components/overlay/FocusTrap/_webPropsTable.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable';
import webPropsData from ':docgen/web/overlays/FocusTrap/data';
import { sharedParentTypes } from ':docgen/_types/sharedParentTypes';
import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases';

<ComponentPropsTable
props={webPropsData}
sharedTypeAliases={sharedTypeAliases}
sharedParentTypes={sharedParentTypes}
/>
25 changes: 25 additions & 0 deletions apps/docs/docs/components/overlay/FocusTrap/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
id: focusTrap
title: FocusTrap
platform_switcher_options: { web: true, mobile: false }
hide_title: true
---

import { VStack } from '@coinbase/cds-web/layout';
import { ComponentHeader } from '@site/src/components/page/ComponentHeader';
import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer';

import webPropsToc from ':docgen/web/overlays/FocusTrap/toc-props';
import WebPropsTable from './_webPropsTable.mdx';
import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx';
import webMetadata from './webMetadata.json';

<VStack gap={5}>
<ComponentHeader title="FocusTrap" webMetadata={webMetadata} />
<ComponentTabsContainer
webPropsTable={<WebPropsTable />}
webExamples={<WebExamples />}
webExamplesToc={webExamplesToc}
webPropsToc={webPropsToc}
/>
</VStack>
15 changes: 15 additions & 0 deletions apps/docs/docs/components/overlay/FocusTrap/webMetadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"import": "import { FocusTrap } from '@coinbase/cds-web/overlays/FocusTrap'",
"source": "https://github.com/coinbase/cds/blob/master/packages/web/src/overlays/FocusTrap.tsx",
"description": "FocusTrap is a component that traps focus within its children.",
"relatedComponents": [
{
"label": "Modal",
"url": "/components/overlay/Modal/"
},
{
"label": "Tray",
"url": "/components/overlay/Tray/"
}
]
}
1 change: 1 addition & 0 deletions apps/docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ const sidebars: SidebarsConfig = {
label: 'Overlay',
items: [
{ type: 'doc', id: 'components/overlay/Alert/alert', label: 'Alert' },
{ type: 'doc', id: 'components/overlay/FocusTrap/focusTrap', label: 'FocusTrap' },
{
type: 'doc',
id: 'components/overlay/FullscreenAlert/fullscreenAlert',
Expand Down
4 changes: 4 additions & 0 deletions packages/common/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 8.37.1 ((1/14/2026, 12:37 PM PST))

This is an artificial version bump with no new change.

## 8.37.0 ((1/12/2026, 02:16 PM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-common",
"version": "8.37.0",
"version": "8.37.1",
"description": "Coinbase Design System - Common",
"repository": {
"type": "git",
Expand Down
4 changes: 4 additions & 0 deletions packages/mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 8.37.1 ((1/14/2026, 12:37 PM PST))

This is an artificial version bump with no new change.

## 8.37.0 ((1/12/2026, 02:16 PM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mcp-server",
"version": "8.37.0",
"version": "8.37.1",
"description": "Coinbase Design System - MCP Server",
"repository": {
"type": "git",
Expand Down
4 changes: 4 additions & 0 deletions packages/mobile/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 8.37.1 ((1/14/2026, 12:37 PM PST))

This is an artificial version bump with no new change.

## 8.37.0 ((1/12/2026, 02:16 PM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mobile",
"version": "8.37.0",
"version": "8.37.1",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
Expand Down
6 changes: 6 additions & 0 deletions packages/web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 8.37.1 (1/14/2026 PST)

#### 🐞 Fixes

- Fix focus shift bug for includeTriggerInFocusTrap prop being true in FocusTrap. [[#258](https://github.com/coinbase/cds/pull/258)]

## 8.37.0 (1/12/2026 PST)

#### 🚀 Updates
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web",
"version": "8.37.0",
"version": "8.37.1",
"description": "Coinbase Design System - Web",
"repository": {
"type": "git",
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/overlays/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,10 @@ export const FocusTrap = memo(function FocusTrap({
return;
}

const focusableElements = elements.querySelectorAll(FOCUSABLE_ELEMENTS);
let focusableElements = Array.from(elements.querySelectorAll(FOCUSABLE_ELEMENTS));
if (includeTriggerInFocusTrap && previouslyFocusedElement.current) {
focusableElements = [previouslyFocusedElement.current, ...focusableElements];
}

if (focusableElements?.length) {
const elementToAutoFocus = focusableElements[0] as HTMLElement;
Expand Down
36 changes: 36 additions & 0 deletions packages/web/src/overlays/__tests__/FocusTrap.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react';
import useMeasure from 'react-use-measure';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';

Expand Down Expand Up @@ -384,4 +385,39 @@ describe('FocusTrap', () => {

document.body.removeChild(initialFocusElement);
});

it('includes the trigger in the focus trap when includeTriggerInFocusTrap is true', () => {
const TestComponent = () => {
const [open, setOpen] = useState(false);

return (
<div>
<button data-testid="trigger" onClick={() => setOpen(true)}>
Open
</button>
{open && (
<FocusTrap includeTriggerInFocusTrap>
<div>
<button data-testid="first">First</button>
<button data-testid="second">Second</button>
</div>
</FocusTrap>
)}
</div>
);
};

render(<TestComponent />);

const trigger = screen.getByTestId('trigger');
trigger.focus();
fireEvent.click(trigger);

// Trigger should stay in the focusable set once the trap is active
expect(trigger).toHaveFocus();
fireEvent.keyDown(trigger, { key: 'Tab', code: 'Tab' });
expect(screen.getByTestId('first')).toHaveFocus();
fireEvent.keyDown(screen.getByTestId('first'), { key: 'Tab', code: 'Tab', shiftKey: true });
expect(trigger).toHaveFocus();
});
});
1 change: 1 addition & 0 deletions packages/web/src/overlays/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './Alert';
export * from './FocusTrap';
export * from './FullscreenAlert';
export * from './modal/FullscreenModal';
export * from './modal/FullscreenModalHeader';
Expand Down