Skip to content

Commit 5cbfefe

Browse files
committed
fix: prevent drawer from immediately closing on touch devices
d40fa46 removed preventDefault() from touchend to fix Android keyboard focus. This caused a regression: tapping the drawer toggle opens it, but the browser-synthesised click (~4ms later) lands on the now-visible backdrop, which closes the drawer immediately. Fix: ignore the first click on the backdrop after open() via a justOpened flag + capture-phase stopImmediatePropagation. Only click is guarded — touchend is never synthesised, so legitimate touch dismissal works immediately. Add Playwright tests: open/close/re-open cycle, and a regression guard that proves synthesised clicks still hit the backdrop (ensuring the guard remains necessary).
1 parent d40fa46 commit 5cbfefe

File tree

3 files changed

+84
-2
lines changed

3 files changed

+84
-2
lines changed

src/drawer/drawer.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function createDrawer(
4242
drawer.appendChild(grid)
4343

4444
let drawerOpen = false
45+
let justOpened = false
4546

4647
for (const buttonDef of buttons) {
4748
const button = el('button')
@@ -94,6 +95,14 @@ export function createDrawer(
9495
backdrop.style.display = 'block'
9596
drawer.classList.add('open')
9697
drawerOpen = true
98+
// Guard against synthesised click on backdrop. When onTap fires on
99+
// touchend (no preventDefault), the browser synthesises mousedown/click
100+
// ~4ms later at the same coordinates. By then the backdrop is visible
101+
// and intercepts the click, immediately closing the drawer.
102+
justOpened = true
103+
setTimeout(() => {
104+
justOpened = false
105+
}, 300)
97106
}
98107

99108
function close(): void {
@@ -106,7 +115,20 @@ export function createDrawer(
106115
return drawerOpen
107116
}
108117

109-
// Backdrop dismisses drawer
118+
// Backdrop dismisses drawer. The click listener ignores the first click
119+
// after open() to avoid synthesised click from the triggering touch.
120+
// touchend is never synthesised, so it always works immediately.
121+
backdrop.addEventListener(
122+
'click',
123+
(e: Event) => {
124+
if (justOpened) {
125+
justOpened = false
126+
e.stopImmediatePropagation()
127+
}
128+
},
129+
{ capture: true },
130+
)
131+
110132
onTap(backdrop, () => {
111133
const kbWasOpen = isKeyboardOpen()
112134
haptic()

tests/playwright/smoke.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ test('help overlay shows version', async ({ page }) => {
3232
await page.goto('/')
3333
await page.waitForSelector('#wt-toolbar', { timeout: 10_000 })
3434

35-
// Open help via touchend (same pattern as touch.spec.ts — tap() can miss on mobile viewports)
35+
// Open help via touchend — simulates iOS Safari not firing click on dynamic elements
3636
const helpBtn = page.locator('#wt-font-controls button', { hasText: '?' })
3737
await expect(helpBtn).toBeVisible()
3838
await helpBtn.dispatchEvent('touchend', {

tests/playwright/touch.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,66 @@ test('backdrop responds to touchend-only', async ({ page }) => {
6565
await expect(page.locator('#wt-drawer')).not.toHaveClass(/open/)
6666
})
6767

68+
test('drawer open → close → re-open cycle', async ({ page }) => {
69+
const toggle = page.locator('#wt-toolbar button', { hasText: 'More' })
70+
const drawer = page.locator('#wt-drawer')
71+
72+
// Open
73+
await toggle.dispatchEvent('touchend', {
74+
touches: [],
75+
changedTouches: [],
76+
targetTouches: [],
77+
})
78+
await expect(drawer).toHaveClass(/open/)
79+
80+
// Close via backdrop
81+
await page.locator('#wt-backdrop').dispatchEvent('touchend', {
82+
touches: [],
83+
changedTouches: [],
84+
targetTouches: [],
85+
})
86+
await expect(drawer).not.toHaveClass(/open/)
87+
88+
// Re-open via tap — synthesised click must not re-close
89+
await toggle.tap()
90+
await expect(drawer).toHaveClass(/open/)
91+
})
92+
93+
test('synthesised click from tap() hits backdrop (regression guard)', async ({ page }) => {
94+
// Proves the mechanism that caused the open-then-close bug still exists:
95+
// after touchend opens the drawer, synthesised mousedown/click land on the
96+
// backdrop. The justOpened guard in drawer.ts must block these.
97+
// Listen at document level (capture phase) because the fix uses
98+
// stopImmediatePropagation on the backdrop element.
99+
await page.evaluate(() => {
100+
const w = window as unknown as { __backdropClicks: { isTrusted: boolean }[] }
101+
w.__backdropClicks = []
102+
document.addEventListener(
103+
'click',
104+
(e) => {
105+
if ((e.target as HTMLElement)?.id === 'wt-backdrop') {
106+
w.__backdropClicks.push({ isTrusted: e.isTrusted })
107+
}
108+
},
109+
{ capture: true },
110+
)
111+
})
112+
113+
const toggle = page.locator('#wt-toolbar button', { hasText: 'More' })
114+
await toggle.tap()
115+
await page.waitForTimeout(200)
116+
117+
const clicks = await page.evaluate(
118+
() => (window as unknown as { __backdropClicks: { isTrusted: boolean }[] }).__backdropClicks,
119+
)
120+
// Synthesised click should have reached the backdrop
121+
expect(clicks.length).toBeGreaterThan(0)
122+
expect(clicks[0]?.isTrusted).toBe(true)
123+
124+
// But the drawer should still be open (guard blocked the close)
125+
await expect(page.locator('#wt-drawer')).toHaveClass(/open/)
126+
})
127+
68128
test('help button responds to touchend-only', async ({ page }) => {
69129
const helpBtn = page.locator('#wt-font-controls button', { hasText: '?' })
70130
await expect(helpBtn).toBeVisible()

0 commit comments

Comments
 (0)