Skip to content

Commit 4ce7243

Browse files
authored
docs(examples): add escape shape focus trap example (tldraw#8220)
In order to show developers how to intercept tldraw's Tab-based shape navigation and redirect focus to custom UI, this PR adds a new example demonstrating a focus trap pattern with a contextual toolbar. The example registers a capture-phase `keydown` listener in `onMount` that intercepts Tab before tldraw's own handler, redirecting focus between the canvas and a contextual toolbar. It also handles Shift+Tab cycling and Escape to return focus to the canvas. Relates to tldraw#2549 ### Change type - [x] `other` ### Test plan 1. Open the "Escape shape focus trap" example 2. Click on the geo shape to select it 3. Press Tab — focus should move to the first toolbar button (Duplicate) 4. Press Tab again — focus moves to the Delete button 5. Press Tab again — focus returns to the canvas 6. Press Shift+Tab to cycle backwards through toolbar buttons 7. Press Escape while focused in the toolbar — focus returns to the canvas ### Release notes - Add example showing how to intercept Tab navigation to focus a custom contextual toolbar ### Code changes | Section | LOC change | | ------------- | ---------- | | Documentation | +220 / -0 | <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: adds a self-contained UI example and docs demonstrating keyboard focus handling without changing core library behavior. > > **Overview** > Adds a new `escape-shape-focus-trap` UI example that renders a contextual toolbar for selected shapes and uses a capture-phase `keydown` listener to override tldraw’s Tab-based shape navigation. > > The example demonstrates trapping `Tab`/`Shift+Tab` within toolbar buttons, restoring focus back to the canvas at the ends of the cycle, and handling `Escape` to return focus, with accompanying README guidance. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit af19804. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 41fe118 commit 4ce7243

2 files changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { useCallback } from 'react'
2+
import {
3+
Box,
4+
Tldraw,
5+
TldrawUiButtonIcon,
6+
TldrawUiContextualToolbar,
7+
TldrawUiToolbarButton,
8+
TLEditorComponents,
9+
track,
10+
useEditor,
11+
} from 'tldraw'
12+
import 'tldraw/tldraw.css'
13+
14+
// There's a guide at the bottom of this file!
15+
16+
// [1]
17+
const ShapeToolbar = track(() => {
18+
const editor = useEditor()
19+
const showToolbar = editor.isIn('select.idle') && editor.getOnlySelectedShapeId()
20+
21+
// [2]
22+
const handleReturn = useCallback(() => {
23+
editor.getContainer().focus()
24+
}, [editor])
25+
26+
if (!showToolbar) return null
27+
28+
// [3]
29+
const getSelectionBounds = () => {
30+
const fullBounds = editor.getSelectionRotatedScreenBounds()
31+
if (!fullBounds) return undefined
32+
return new Box(fullBounds.x, fullBounds.y, fullBounds.width, 0)
33+
}
34+
35+
return (
36+
<TldrawUiContextualToolbar getSelectionBounds={getSelectionBounds} label="Shape actions">
37+
<TldrawUiToolbarButton
38+
type="icon"
39+
title="Duplicate"
40+
onClick={() => {
41+
editor.duplicateShapes(editor.getSelectedShapes())
42+
handleReturn()
43+
}}
44+
>
45+
<TldrawUiButtonIcon small icon="duplicate" />
46+
</TldrawUiToolbarButton>
47+
<TldrawUiToolbarButton
48+
type="icon"
49+
title="Delete"
50+
onClick={() => {
51+
editor.deleteShapes(editor.getSelectedShapeIds())
52+
handleReturn()
53+
}}
54+
>
55+
<TldrawUiButtonIcon small icon="trash" />
56+
</TldrawUiToolbarButton>
57+
</TldrawUiContextualToolbar>
58+
)
59+
})
60+
61+
const components: TLEditorComponents = {
62+
InFrontOfTheCanvas: ShapeToolbar,
63+
}
64+
65+
export default function EscapeShapeFocusTrapExample() {
66+
return (
67+
<div className="tldraw__editor">
68+
<Tldraw
69+
components={components}
70+
onMount={(editor) => {
71+
editor.createShape({ type: 'geo', x: 400, y: 200 })
72+
73+
// [4a]
74+
const container = editor.getContainer()
75+
const getToolbar = () => container.querySelector<HTMLElement>('.tlui-contextual-toolbar')
76+
77+
// [4b]
78+
function enableFocusRing() {
79+
container.classList.remove('tl-container__no-focus-ring')
80+
}
81+
82+
function handleKeyDown(e: KeyboardEvent) {
83+
const toolbarEl = getToolbar()
84+
const isInToolbar = toolbarEl?.contains(document.activeElement)
85+
86+
// [5]
87+
if (e.key === 'Tab' && !isInToolbar) {
88+
const hasSelected = editor.getOnlySelectedShapeId() !== null
89+
const isOnCanvas =
90+
document.activeElement === container ||
91+
document.activeElement?.classList.contains('tl-container')
92+
93+
if (hasSelected && isOnCanvas && !e.shiftKey) {
94+
e.preventDefault()
95+
e.stopImmediatePropagation()
96+
const btn = toolbarEl?.querySelector<HTMLElement>('button')
97+
if (btn) {
98+
btn.focus()
99+
enableFocusRing()
100+
}
101+
}
102+
return
103+
}
104+
105+
// [6]
106+
if (e.key === 'Tab' && isInToolbar && toolbarEl) {
107+
const buttons = Array.from(toolbarEl.querySelectorAll<HTMLElement>('button'))
108+
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
109+
if (currentIndex === -1) return
110+
111+
e.preventDefault()
112+
e.stopImmediatePropagation()
113+
114+
if (e.shiftKey) {
115+
if (currentIndex === 0) {
116+
container.focus()
117+
} else {
118+
buttons[currentIndex - 1].focus()
119+
}
120+
} else {
121+
if (currentIndex === buttons.length - 1) {
122+
container.focus()
123+
} else {
124+
buttons[currentIndex + 1].focus()
125+
}
126+
}
127+
enableFocusRing()
128+
return
129+
}
130+
131+
// [7]
132+
if (e.key === 'Escape' && isInToolbar) {
133+
e.preventDefault()
134+
e.stopImmediatePropagation()
135+
container.focus()
136+
}
137+
}
138+
139+
// [8]
140+
container.addEventListener('keydown', handleKeyDown, { capture: true })
141+
return () => container.removeEventListener('keydown', handleKeyDown, { capture: true })
142+
}}
143+
/>
144+
</div>
145+
)
146+
}
147+
148+
/*
149+
[1]
150+
The ShapeToolbar component renders a contextual toolbar above the selected shape.
151+
It uses `track()` so it re-renders when editor state changes (e.g. selection).
152+
153+
[2]
154+
handleReturn moves focus back to the canvas container. Once focus leaves the toolbar
155+
buttons, tldraw's shape navigation takes over Tab again.
156+
157+
[3]
158+
We position the toolbar above the selected shape using getSelectionBounds, the same
159+
pattern used in the contextual toolbar example.
160+
161+
[4a]
162+
All keyboard interception is registered here in onMount via a capture-phase listener.
163+
164+
[4b]
165+
stopImmediatePropagation blocks FocusManager's handler (on document.body) from seeing
166+
the Tab key, so it never removes the tl-container__no-focus-ring class itself. We do
167+
it manually here so that focus-visible outlines appear on the toolbar buttons.
168+
169+
[5]
170+
When Tab is pressed and focus is on the canvas (not in the toolbar), intercept it to
171+
focus the first toolbar button instead of cycling to the next shape.
172+
173+
[6]
174+
When Tab is pressed inside the toolbar, cycle between toolbar buttons. Shift+Tab on the
175+
first button or Tab on the last button returns focus to the canvas. This prevents Tab
176+
from escaping to the SkipToMainContent or other UI elements outside the toolbar.
177+
178+
[7]
179+
Pressing Escape while focused in the toolbar returns focus to the canvas.
180+
181+
[8]
182+
Using { capture: true } ensures our handler runs in the capture phase, before
183+
tldraw's bubble-phase handler in useDocumentEvents. This lets us call
184+
stopImmediatePropagation() to prevent tldraw from also handling the key.
185+
*/
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
title: Escape shape focus trap
3+
component: ./EscapeShapeFocusTrap.tsx
4+
category: ui
5+
priority: 3
6+
keywords:
7+
[
8+
tab,
9+
keyboard navigation,
10+
accessibility,
11+
a11y,
12+
shape toolbar,
13+
focus,
14+
contextual toolbar,
15+
keyboard,
16+
]
17+
---
18+
19+
Tab from a selected shape to a custom contextual toolbar using keyboard navigation.
20+
21+
---
22+
23+
This example demonstrates how to intercept tldraw's built-in Tab-based shape navigation so that pressing Tab while a shape is selected moves focus to a custom toolbar instead of cycling to the next shape.
24+
25+
By default, tldraw traps the Tab key when shapes are selected and uses it to navigate between shapes. This example shows how to break out of that cycle using a capture-phase event listener registered in `onMount`.
26+
27+
## How it works
28+
29+
1. A capture-phase `keydown` listener intercepts Tab before tldraw's own handler sees it
30+
2. When a shape is selected and focus is on the canvas, Tab moves focus to the first toolbar button
31+
3. While focus is inside the toolbar, Tab and Shift+Tab are handled manually to cycle between toolbar buttons
32+
4. Tab on the last button (or Shift+Tab on the first) returns focus to the canvas and restores shape navigation
33+
5. Pressing Escape while focused in the toolbar also returns focus to the canvas
34+
35+
Try it: click a shape, then press Tab to focus the toolbar. Use Tab/Shift+Tab to move between buttons. Press Escape to return to the canvas.

0 commit comments

Comments
 (0)