Skip to content

Commit f65dd70

Browse files
chrisbbreuerclaude
andcommitted
fix(drawer): give each drawer its own panel, and focus it without frames
Two problems, both of which leave a drawer that looks right and cannot be reached by keyboard. The panel was found with a document-wide query, which returns the first drawer in the document rather than the one that just opened. On a page with two - a cart, and the item being added to it - opening the second focused the first, which is hidden, so the focus retry spent all ten of its attempts failing. Each drawer now carries an id and looks up its own panel, the same idiom Dropdown and Popover already use. The retry also ran only on `requestAnimationFrame`, which does not fire at all while a page is not painting - a background tab, or a window the compositor has parked. A drawer opened in that state never took focus, and still had none when the reader came back, because the single pending frame lands long after everything else has moved on. A timer now races the frame and the first to arrive wins, so the retry survives a sleeping compositor without firing twice per round. The second one showed up while testing the first: `requestAnimationFrame` never fires in the browser pane these were being checked in, so the focus move could not be observed there at all. That is a quirk of the test environment and also exactly the condition a parked tab is in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d1def96 commit f65dd70

3 files changed

Lines changed: 197 additions & 10 deletions

File tree

packages/components/src/ui/drawer/Drawer.stx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,22 @@ const closeButtonPosition = {
5050
export const containerClasses = `pointer-events-none fixed ${positionClasses[position]} flex max-w-full`.trim()
5151
export const panelClasses = `pointer-events-auto relative ${sizeClasses[position]} ${className}`.trim()
5252
export const closeButtonClasses = `absolute top-0 ${closeButtonPosition[position]} flex pt-4 pr-2 sm:pr-4`.trim()
53+
54+
/*
55+
* This drawer's own id.
56+
*
57+
* The focus move looked the panel up with a document-wide query, which finds
58+
* the first drawer on the page rather than the one that just opened. With two
59+
* on a page - a cart and the item being added to it - opening the second
60+
* focused the first, which is hidden, so focus went nowhere and the retry
61+
* spent its ten frames failing. Same idiom as Dropdown and Popover.
62+
*/
63+
export const drawerInstanceId = `stx-drawer-${Math.random().toString(36).slice(2, 11)}`
5364
</script>
5465

5566
<script client>
5667
const emit = defineEmits()
68+
const drawerInstanceId = {{ drawerInstanceId }}
5769
// useReactiveProp lets the parent drive `open` via signals
5870
// (`:open="drawerOpen()"`) instead of the prop being captured once.
5971
// See stacksjs/stx#1704.
@@ -139,24 +151,47 @@ let opener = null
139151
* meantime, so a drawer opened and shut inside those few frames does not
140152
* pull focus back afterwards.
141153
*/
154+
/*
155+
* The next chance to try, whichever comes first.
156+
*
157+
* `requestAnimationFrame` is the right signal when the page is painting, and
158+
* it is suspended entirely when the page is not - a background tab, or a
159+
* window the compositor has parked. A drawer opened in that state would never
160+
* take focus, and would still not have it when the reader came back, because
161+
* the one scheduled callback fires long after everything else has moved on.
162+
* So a timer races the frame and the first to arrive wins.
163+
*/
164+
function soon(callback) {
165+
let done = false
166+
167+
const once = () => {
168+
if (done) return
169+
done = true
170+
callback()
171+
}
172+
173+
requestAnimationFrame(once)
174+
setTimeout(once, 32)
175+
}
176+
142177
function focusFirst(attempts) {
143178
if (!isOpen()) return
144179

145-
const panel = document.querySelector('[data-stx-drawer-panel]')
180+
const panel = document.querySelector(`[data-stx-drawer-panel="${drawerInstanceId}"]`)
146181
if (!panel) return
147182

148183
const target = panel.querySelector('[autofocus]') || panel.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') || panel
149184

150185
target.focus?.()
151186

152187
if (document.activeElement === target) return
153-
if (attempts > 0) requestAnimationFrame(() => focusFirst(attempts - 1))
188+
if (attempts > 0) soon(() => focusFirst(attempts - 1))
154189
}
155190

156191
function captureFocus() {
157192
opener = document.activeElement
158193

159-
requestAnimationFrame(() => focusFirst(10))
194+
soon(() => focusFirst(10))
160195
}
161196

162197
function releaseFocus() {
@@ -206,7 +241,7 @@ defineExpose({
206241
<div
207242
class="{{ panelClasses }}"
208243
style="animation: slideIn 0.5s ease-in-out"
209-
data-stx-drawer-panel
244+
data-stx-drawer-panel="{{ drawerInstanceId }}"
210245
role="dialog"
211246
aria-modal="true"
212247
{{ title ? 'aria-label="' + title + '"' : '' }}

packages/components/test/drawer.test.ts

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,11 @@ describe('Drawer', () => {
101101
* thing a source-text check cannot notice.
102102
*/
103103
function loadFocusFirst(environment: Record<string, any>) {
104-
const start = source.indexOf('function focusFirst(')
104+
const start = source.indexOf('function soon(')
105105
const end = source.indexOf('function captureFocus(')
106106

107107
if (start < 0 || end < 0)
108-
throw new Error('focusFirst is no longer in Drawer.stx under that name')
108+
throw new Error('soon/focusFirst are no longer in Drawer.stx under those names')
109109

110110
const names = Object.keys(environment)
111111
const make = new Function(...names, `${source.slice(start, end)}; return focusFirst`)
@@ -159,6 +159,7 @@ describe('Drawer focus retry', () => {
159159

160160
const focusFirst = loadFocusFirst({
161161
isOpen: () => true,
162+
drawerInstanceId: 'stx-drawer-test',
162163
document: dom.document,
163164
requestAnimationFrame: (callback: () => void) => {
164165
frames++
@@ -182,6 +183,7 @@ describe('Drawer focus retry', () => {
182183

183184
const focusFirst = loadFocusFirst({
184185
isOpen: () => true,
186+
drawerInstanceId: 'stx-drawer-test',
185187
document: dom.document,
186188
requestAnimationFrame: (callback: () => void) => { frames++; callback() },
187189
})
@@ -199,6 +201,7 @@ describe('Drawer focus retry', () => {
199201

200202
const focusFirst = loadFocusFirst({
201203
isOpen: () => open,
204+
drawerInstanceId: 'stx-drawer-test',
202205
document: dom.document,
203206
requestAnimationFrame: (callback: () => void) => { frames++; open = false; callback() },
204207
})
@@ -249,3 +252,117 @@ describe('Drawer theming', () => {
249252
expect(source).toContain('export const panelClasses = `pointer-events-auto relative ${sizeClasses[position]} ${className}`')
250253
})
251254
})
255+
256+
/**
257+
* Two drawers on one page.
258+
*
259+
* The panel was found with a document-wide query, which returns the first
260+
* drawer in the document and not the one that just opened. A page with a cart
261+
* drawer and an item drawer therefore focused the wrong panel - a hidden one -
262+
* so the retry spent its ten frames failing and focus never moved.
263+
*/
264+
describe('Drawer instance scoping', () => {
265+
test('each drawer looks up its own panel', () => {
266+
expect(source).toContain('drawerInstanceId')
267+
expect(source).toContain('[data-stx-drawer-panel="${drawerInstanceId}"]')
268+
})
269+
270+
test('the id it queries for is the id it renders', () => {
271+
// The two halves are written in different places; a rename in one is
272+
// invisible until a drawer stops taking focus.
273+
expect(source).toContain('data-stx-drawer-panel="{{ drawerInstanceId }}"')
274+
})
275+
276+
test('the id differs per instance', () => {
277+
expect(source).toContain('Math.random()')
278+
})
279+
280+
test('finds nothing rather than the wrong panel', () => {
281+
// The point of the id: a query scoped to another drawer's id must miss,
282+
// where the old document-wide one would have returned that drawer.
283+
const dom = stubDom({ shown: true })
284+
let asked = ''
285+
286+
const focusFirst = loadFocusFirst({
287+
isOpen: () => true,
288+
drawerInstanceId: 'drawer-two',
289+
document: {
290+
querySelector: (selector: string) => {
291+
asked = selector
292+
// Only drawer-one exists in this document.
293+
return selector.includes('drawer-one') ? dom.panel : null
294+
},
295+
get activeElement() { return 'body' },
296+
},
297+
requestAnimationFrame: (callback: () => void) => callback(),
298+
})
299+
300+
focusFirst(10)
301+
302+
expect(asked).toContain('drawer-two')
303+
expect(dom.active.current).toBe('body')
304+
})
305+
})
306+
307+
/**
308+
* A drawer opened while the page is not painting.
309+
*
310+
* `requestAnimationFrame` does not fire at all in a background tab or a parked
311+
* window, so a retry built only on frames never runs: the drawer opens without
312+
* focus and still has none when the reader comes back, because the single
313+
* pending callback fires after everything else has moved on. A timer races the
314+
* frame so the retry survives a compositor that is asleep.
315+
*/
316+
describe('Drawer focus without frames', () => {
317+
test('still focuses when requestAnimationFrame never fires', () => {
318+
const dom = stubDom({ shown: true })
319+
const timers: Array<() => void> = []
320+
321+
const focusFirst = loadFocusFirst({
322+
isOpen: () => true,
323+
drawerInstanceId: 'stx-drawer-test',
324+
document: dom.document,
325+
requestAnimationFrame: () => {},
326+
setTimeout: (callback: () => void) => { timers.push(callback); return 1 },
327+
})
328+
329+
// Hidden on the first pass, revealed before the timer runs the retry.
330+
dom.shown.value = false
331+
focusFirst(10)
332+
dom.shown.value = true
333+
while (timers.length) timers.shift()!()
334+
335+
expect(dom.document.activeElement).toBe(dom.target)
336+
})
337+
338+
test('the frame and the timer do not both retry', () => {
339+
// Racing two schedulers must not double the work each round; ten attempts
340+
// would otherwise become a thousand.
341+
const dom = stubDom({ shown: false })
342+
const frames: Array<() => void> = []
343+
const timers: Array<() => void> = []
344+
let attempts = 0
345+
346+
const focusFirst = loadFocusFirst({
347+
isOpen: () => true,
348+
drawerInstanceId: 'stx-drawer-test',
349+
document: {
350+
querySelector: () => { attempts++; return dom.panel },
351+
get activeElement() { return 'body' },
352+
},
353+
requestAnimationFrame: (callback: () => void) => { frames.push(callback); return 1 },
354+
setTimeout: (callback: () => void) => { timers.push(callback); return 1 },
355+
})
356+
357+
focusFirst(2)
358+
359+
// Run both schedulers, frame first, then the timer that raced it.
360+
while (frames.length || timers.length) {
361+
while (frames.length) frames.shift()!()
362+
while (timers.length) timers.shift()!()
363+
}
364+
365+
// Three attempts total: the initial call plus two retries.
366+
expect(attempts).toBe(3)
367+
})
368+
})

packages/components/test/visual/__snapshots__/drawer-template.snap.txt

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,21 @@ const closeButtonPosition = {
5656
export const containerClasses = `pointer-events-none fixed ${positionClasses[position]} flex max-w-full`.trim()
5757
export const panelClasses = `pointer-events-auto relative ${sizeClasses[position]} ${className}`.trim()
5858
export const closeButtonClasses = `absolute top-0 ${closeButtonPosition[position]} flex pt-4 pr-2 sm:pr-4`.trim()
59+
60+
/*
61+
* This drawer's own id.
62+
*
63+
* The focus move looked the panel up with a document-wide query, which finds
64+
* the first drawer on the page rather than the one that just opened. With two
65+
* on a page - a cart and the item being added to it - opening the second
66+
* focused the first, which is hidden, so focus went nowhere and the retry
67+
* spent its ten frames failing. Same idiom as Dropdown and Popover.
68+
*/
69+
export const drawerInstanceId = `stx-drawer-${Math.random().toString(36).slice(2, 11)}`
5970
</script>
6071
<script client>
6172
const emit = defineEmits()
73+
const drawerInstanceId = {{ drawerInstanceId }}
6274
// useReactiveProp lets the parent drive `open` via signals
6375
// (`:open="drawerOpen()"`) instead of the prop being captured once.
6476
// See stacksjs/stx#1704.
@@ -144,24 +156,47 @@ let opener = null
144156
* meantime, so a drawer opened and shut inside those few frames does not
145157
* pull focus back afterwards.
146158
*/
159+
/*
160+
* The next chance to try, whichever comes first.
161+
*
162+
* `requestAnimationFrame` is the right signal when the page is painting, and
163+
* it is suspended entirely when the page is not - a background tab, or a
164+
* window the compositor has parked. A drawer opened in that state would never
165+
* take focus, and would still not have it when the reader came back, because
166+
* the one scheduled callback fires long after everything else has moved on.
167+
* So a timer races the frame and the first to arrive wins.
168+
*/
169+
function soon(callback) {
170+
let done = false
171+
172+
const once = () => {
173+
if (done) return
174+
done = true
175+
callback()
176+
}
177+
178+
requestAnimationFrame(once)
179+
setTimeout(once, 32)
180+
}
181+
147182
function focusFirst(attempts) {
148183
if (!isOpen()) return
149184

150-
const panel = document.querySelector('[data-stx-drawer-panel]')
185+
const panel = document.querySelector(`[data-stx-drawer-panel="${drawerInstanceId}"]`)
151186
if (!panel) return
152187

153188
const target = panel.querySelector('[autofocus]') || panel.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') || panel
154189

155190
target.focus?.()
156191

157192
if (document.activeElement === target) return
158-
if (attempts > 0) requestAnimationFrame(() => focusFirst(attempts - 1))
193+
if (attempts > 0) soon(() => focusFirst(attempts - 1))
159194
}
160195

161196
function captureFocus() {
162197
opener = document.activeElement
163198

164-
requestAnimationFrame(() => focusFirst(10))
199+
soon(() => focusFirst(10))
165200
}
166201

167202
function releaseFocus() {
@@ -210,7 +245,7 @@ defineExpose({
210245
<div
211246
class="{{ panelClasses }}"
212247
style="animation: slideIn 0.5s ease-in-out"
213-
data-stx-drawer-panel
248+
data-stx-drawer-panel="{{ drawerInstanceId }}"
214249
role="dialog"
215250
aria-modal="true"
216251
{{ title ? 'aria-label="' + title + '"' : '' }}

0 commit comments

Comments
 (0)