Skip to content

Commit c706649

Browse files
committed
test(usePortalRefocus): drive the hand-off scenarios with real clicks
The pairing tests reproduced the v-close-popup ordering by hand: show the opener, wait one tick, hide the closer. That pins the mechanism but takes the ordering on faith. They now dispatch a real click through CDP on an element carrying both the click handler and v-close-popup, so the browser itself runs the microtask checkpoint between the two listeners that puts the opener's show ahead of the closer's hide. Two harness details make a real click land: the portals animate for 1ms (fake timers finish the transition bookkeeping instantly, the CSS animation does not, and a zero duration would skip the opening phase under test), and the dialog's clickable content sits in a div, the only child of the dialog's inner element that takes pointer events.
1 parent 80eae26 commit c706649

1 file changed

Lines changed: 97 additions & 36 deletions

File tree

ui/src/composables/private.use-portal-refocus/use-portal-refocus.test.js

Lines changed: 97 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import {
88
test,
99
vi
1010
} from 'vitest'
11-
import { defineComponent, h, nextTick } from 'vue'
11+
import { cdp } from 'vitest/browser'
12+
import { defineComponent, h, shallowRef, withDirectives } from 'vue'
1213

1314
import QDialog from '../../components/dialog/QDialog.js'
1415
import QMenu from '../../components/menu/QMenu.js'
16+
import ClosePopup from '../../directives/close-popup/ClosePopup.js'
1517
import usePortalRefocus from './use-portal-refocus.js'
1618

1719
let wrapper
@@ -34,6 +36,40 @@ async function settle() {
3436
await vi.runAllTimersAsync()
3537
}
3638

39+
/**
40+
* A real click, dispatched by the browser rather than by script: the
41+
* browser runs a microtask checkpoint between the element's listeners,
42+
* which is what puts a dialog's show ahead of the v-close-popup listener
43+
* that follows it on the same element. The cursor is parked again
44+
* afterwards (see test/vitest.setup.js).
45+
*/
46+
async function realClick(el) {
47+
const { x, y, width, height } = el.getBoundingClientRect()
48+
const mouse = params =>
49+
cdp().send('Input.dispatchMouseEvent', {
50+
x: Math.round(x + width / 2),
51+
y: Math.round(y + height / 2),
52+
button: 'left',
53+
clickCount: 1,
54+
...params
55+
})
56+
57+
await mouse({ type: 'mouseMoved', button: 'none' })
58+
await mouse({ type: 'mousePressed' })
59+
await mouse({ type: 'mouseReleased' })
60+
await cdp().send('Input.dispatchMouseEvent', {
61+
type: 'mouseMoved',
62+
x: 1275,
63+
y: 795
64+
})
65+
}
66+
67+
// the fake timers settle a portal's transition bookkeeping instantly,
68+
// but its CSS animation runs in real time; a real click on a still
69+
// scaling element would miss it, so the harness portals barely animate
70+
// (a zero duration would skip the opening phase altogether)
71+
const noTransition = { transitionDuration: 1 }
72+
3773
const dialogContent = () => [
3874
h('input', { class: 'dialog-input', autofocus: true }),
3975
h('button', { class: 'dialog-btn' }, 'Dialog button')
@@ -77,21 +113,41 @@ function createControl() {
77113
}
78114

79115
/**
80-
* A menu anchored on a focusable box, holding one focusable item, plus a
81-
* dialog with an autofocused input.
116+
* A menu anchored on a focusable box, plus a dialog with an autofocused
117+
* input. Clicking the menu's item opens the dialog and, through
118+
* v-close-popup, closes the menu.
82119
*/
83120
function mountMenuAndDialog(dialogProps) {
84121
wrapper = mount(
85122
defineComponent({
86123
setup() {
124+
const dialogRef = shallowRef(null)
125+
87126
return () =>
88127
h('div', [
89128
h('div', { class: 'anchor', tabindex: 0 }, [
90-
h(QMenu, null, () =>
91-
h('div', { class: 'item', tabindex: 0 }, 'Item')
129+
h(QMenu, noTransition, () =>
130+
withDirectives(
131+
h(
132+
'div',
133+
{
134+
class: 'item',
135+
tabindex: 0,
136+
onClick: () => {
137+
dialogRef.value.show()
138+
}
139+
},
140+
'Item'
141+
),
142+
[[ClosePopup]]
143+
)
92144
)
93145
]),
94-
h(QDialog, dialogProps, dialogContent)
146+
h(
147+
QDialog,
148+
{ ref: dialogRef, ...noTransition, ...dialogProps },
149+
dialogContent
150+
)
95151
])
96152
}
97153
})
@@ -105,20 +161,43 @@ function mountMenuAndDialog(dialogProps) {
105161
}
106162

107163
/**
108-
* Two dialogs: the first holds a focusable button, the second an
109-
* autofocused input; a focusable opener sits outside both.
164+
* Two dialogs, with a focusable opener outside both. The first dialog's
165+
* button opens the second and, through v-close-popup, closes the first.
110166
*/
111167
function mountTwoDialogs() {
112168
wrapper = mount(
113169
defineComponent({
114170
setup() {
171+
const dialogBRef = shallowRef(null)
172+
115173
return () =>
116174
h('div', [
117175
h('button', { class: 'opener' }, 'Open'),
118-
h(QDialog, { class: 'dialog-a' }, () =>
119-
h('button', { class: 'dialog-a-btn' }, 'Open next')
176+
// only a div child of the dialog's inner element takes
177+
// pointer events; anything else lets clicks fall through to
178+
// the backdrop
179+
h(QDialog, { class: 'dialog-a', ...noTransition }, () =>
180+
h('div', [
181+
withDirectives(
182+
h(
183+
'button',
184+
{
185+
class: 'dialog-a-btn',
186+
onClick: () => {
187+
dialogBRef.value.show()
188+
}
189+
},
190+
'Open next'
191+
),
192+
[[ClosePopup]]
193+
)
194+
])
120195
),
121-
h(QDialog, { class: 'dialog-b' }, dialogContent)
196+
h(
197+
QDialog,
198+
{ class: 'dialog-b', ref: dialogBRef, ...noTransition },
199+
dialogContent
200+
)
122201
])
123202
}
124203
})
@@ -133,19 +212,6 @@ function mountTwoDialogs() {
133212
}
134213
}
135214

136-
/**
137-
* Opens the portal on top the way a v-close-popup click does it: the
138-
* opener's show has already queued its autofocus (a browser-dispatched
139-
* event runs its microtasks between listeners) by the time the portal
140-
* underneath gets to hide.
141-
*/
142-
async function showOverThenHide(opener, closer) {
143-
opener.show()
144-
await nextTick()
145-
closer.hide()
146-
await settle()
147-
}
148-
149215
describe('[usePortalRefocus API]', () => {
150216
describe('[Functions]', () => {
151217
describe('[(function)default]', () => {
@@ -218,11 +284,8 @@ describe('[usePortalRefocus API]', () => {
218284
menu.show()
219285
await settle()
220286

221-
const item = document.querySelector('.item')
222-
item.focus()
223-
expect(document.activeElement).toBe(item)
224-
225-
await showOverThenHide(dialog, menu)
287+
await realClick(document.querySelector('.item'))
288+
await settle()
226289

227290
// the dialog's autofocus won over the menu's restore
228291
expect(document.activeElement).toBe(
@@ -245,11 +308,8 @@ describe('[usePortalRefocus API]', () => {
245308
dialogA.show()
246309
await settle()
247310

248-
const button = document.querySelector('.dialog-a-btn')
249-
button.focus()
250-
expect(document.activeElement).toBe(button)
251-
252-
await showOverThenHide(dialogB, dialogA)
311+
await realClick(document.querySelector('.dialog-a-btn'))
312+
await settle()
253313

254314
expect(document.activeElement).toBe(
255315
document.querySelector('.dialog-input')
@@ -263,13 +323,14 @@ describe('[usePortalRefocus API]', () => {
263323
})
264324

265325
test('restores focus itself when the portal opening on top takes none', async () => {
266-
const { anchor, menu, dialog } = mountMenuAndDialog({ noFocus: true })
326+
const { anchor, menu } = mountMenuAndDialog({ noFocus: true })
267327

268328
anchor.focus()
269329
menu.show()
270330
await settle()
271331

272-
await showOverThenHide(dialog, menu)
332+
await realClick(document.querySelector('.item'))
333+
await settle()
273334

274335
expect(document.querySelector('.dialog-input')).not.toBe(null)
275336
expect(document.activeElement).toBe(anchor)

0 commit comments

Comments
 (0)