@@ -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
1314import QDialog from '../../components/dialog/QDialog.js'
1415import QMenu from '../../components/menu/QMenu.js'
16+ import ClosePopup from '../../directives/close-popup/ClosePopup.js'
1517import usePortalRefocus from './use-portal-refocus.js'
1618
1719let 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+
3773const 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 */
83120function 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 */
111167function 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-
149215describe ( '[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