@@ -8,8 +8,15 @@ const user = userEvent.setup();
88const createModal = ( {
99 hidden = true ,
1010 initialFocusEl,
11- } : { hidden ?: boolean ; initialFocusEl ?: ReturnType < typeof html > } = { } ) => html `
12- < div data-controller ="s-modal ">
11+ renderFocusables = true ,
12+ excludeTabIndex = false ,
13+ } : {
14+ hidden ?: boolean ;
15+ initialFocusEl ?: ReturnType < typeof html > ;
16+ renderFocusables ?: boolean ;
17+ excludeTabIndex ?: boolean ;
18+ } = { } ) => html `
19+ < div data-controller ="s-modal " data-testid ="controller ">
1320 < button
1421 class ="s-btn "
1522 data-action ="s-modal#show "
@@ -20,7 +27,7 @@ const createModal = ({
2027 < aside
2128 class ="s-modal "
2229 id ="modal-base "
23- tabindex ="-1 "
30+ tabindex ="${ ! excludeTabIndex ? - 1 : null } "
2431 role ="dialog "
2532 aria-labelledby ="modal-base-title "
2633 aria-describedby ="modal-base-description "
@@ -31,26 +38,52 @@ const createModal = ({
3138 < h1 class ="s-modal--header " id ="modal-base-title "> Title</ h1 >
3239
3340 < p class ="s-modal--body ">
34- < span id ="modal-base-description "> Description</ span >
41+ < span id ="modal-base-description " data-testid =" modal-text-body " > Description</ span >
3542 < form >
36- < input type ="text " data-testid ="first-focusable-element " />
37- ${ initialFocusEl }
43+ ${
44+ renderFocusables
45+ ? html `
46+ < input
47+ type ="text "
48+ data-testid ="first-focusable-element "
49+ />
50+ ${ initialFocusEl }
51+ `
52+ : null
53+ }
3854 </ form >
3955 </ p >
40-
41- < div class ="d-flex gx8 s-modal--footer ">
42- < button class ="flex--item s-btn s-btn__filled " type ="button "> Save changes</ button >
43- < button class ="flex--item s-btn " type ="button " data-action ="s-modal#hide "> Cancel</ button >
44- </ div >
45-
46- < button
47- class ="s-btn s-btn__muted s-modal--close "
48- type ="button "
49- aria-label ="Close "
50- data-action ="s-modal#hide "
51- data-testid ="close-btn ">
52- Close
53- </ button >
56+ ${
57+ renderFocusables
58+ ? html ` < div class ="d-flex gx8 s-modal--footer ">
59+ < button
60+ class ="flex--item s-btn s-btn__filled "
61+ type ="button "
62+ data-testid ="save-btn "
63+ >
64+ Save changes
65+ </ button >
66+ < button
67+ class ="flex--item s-btn "
68+ type ="button "
69+ data-action ="s-modal#hide "
70+ data-testid ="cancel-btn "
71+ >
72+ Cancel
73+ </ button >
74+ </ div >
75+
76+ < button
77+ class ="s-btn s-btn__muted s-modal--close "
78+ type ="button "
79+ aria-label ="Close "
80+ data-action ="s-modal#hide "
81+ data-testid ="close-btn "
82+ >
83+ Close
84+ </ button > `
85+ : null
86+ }
5487 </ div >
5588 </ aside >
5689 </ div >
@@ -156,4 +189,114 @@ describe("modal", () => {
156189 // has not changed to the first focusable element
157190 expect ( closeButton ) . to . have . focus ;
158191 } ) ;
192+
193+ it ( "should not shift focus outside the modal when tab is pressed if there's no tabbable element in the modal" , async ( ) => {
194+ // test with `tabindex="-1"`
195+ await testFocusChange ( true ) ;
196+
197+ // reset the test
198+ const controller = await screen . findByTestId ( "controller" ) ;
199+ controller . remove ( ) ;
200+
201+ // test without `tabindex="-1"`
202+ await testFocusChange ( false ) ;
203+
204+ async function testFocusChange ( withTabIndex : boolean ) : Promise < void > {
205+ await fixture (
206+ createModal ( {
207+ renderFocusables : false ,
208+ excludeTabIndex : ! withTabIndex ,
209+ } )
210+ ) ;
211+
212+ const modal = await screen . findByTestId ( "modal" ) ;
213+ const trigger = await screen . findByTestId ( "trigger" ) ;
214+
215+ expect ( modal ) . not . to . be . visible ;
216+ await user . click ( trigger ) ;
217+ expect ( modal ) . to . be . visible ;
218+
219+ // wait for s-modal:shown handler to complete
220+ await new Promise ( ( resolve ) =>
221+ modal . addEventListener ( "s-modal:shown" , resolve )
222+ ) ;
223+
224+ // since there's nothing else to focus, the modal itself or the triggering element
225+ // should be focused. depends on whether the modal has a `tabindex` attribute or not.
226+ const expectedFocusElement = withTabIndex ? modal : trigger ;
227+ expect ( expectedFocusElement ) . to . have . focus ;
228+
229+ await user . tab ( ) ;
230+
231+ // since there's nothing else to focus, the same element should still be focused
232+ expect ( expectedFocusElement ) . to . have . focus ;
233+ }
234+ } ) ;
235+
236+ it ( "should not deselect highlighted text when a keypress is detected" , async ( ) => {
237+ await fixture ( createModal ( { excludeTabIndex : true } ) ) ;
238+
239+ const modal = await screen . findByTestId ( "modal" ) ;
240+ const trigger = await screen . findByTestId ( "trigger" ) ;
241+
242+ expect ( modal ) . not . to . be . visible ;
243+ await user . click ( trigger ) ;
244+ expect ( modal ) . to . be . visible ;
245+
246+ // highlight some text with the cursor
247+ const description = await screen . findByTestId ( "modal-text-body" ) ;
248+ await user . pointer ( [
249+ // left click and hold at char 0
250+ { target : description , offset : 0 , keys : "[MouseLeft>]" } ,
251+ // drag the mouse to the right 5 characters
252+ { offset : 5 } ,
253+ // release the left mouse button
254+ { keys : "[/MouseLeft]" } ,
255+ ] ) ;
256+
257+ // confirm highlight
258+ const selection = document . getSelection ( ) ?. toString ( ) ;
259+ expect ( selection ) . to . be . equal ( "Descr" ) ;
260+
261+ // simulate a few non-Tab keypresses
262+ await user . keyboard (
263+ "{s}{t}{a}{c}{k}{Shift}{Control}{ArrowUp}{ArrowRight}"
264+ ) ;
265+
266+ // highlight should remain intact
267+ expect ( selection ) . to . be . equal ( "Descr" ) ;
268+ } ) ;
269+
270+ it ( "should cycle through focusable modal elements when tab is pressed" , async ( ) => {
271+ await fixture ( createModal ( ) ) ;
272+
273+ const modal = await screen . findByTestId ( "modal" ) ;
274+ const trigger = await screen . findByTestId ( "trigger" ) ;
275+
276+ expect ( modal ) . not . to . be . visible ;
277+ await user . click ( trigger ) ;
278+ expect ( modal ) . to . be . visible ;
279+
280+ // wait for s-modal:shown handler to complete
281+ await new Promise ( ( resolve ) =>
282+ modal . addEventListener ( "s-modal:shown" , resolve )
283+ ) ;
284+
285+ const firstFocusableElement = await screen . findByTestId (
286+ "first-focusable-element"
287+ ) ;
288+ const secondFocusableElement = await screen . findByTestId ( "save-btn" ) ;
289+ const thirdFocusableElement = await screen . findByTestId ( "cancel-btn" ) ;
290+ const lastFocusableElement = await screen . findByTestId ( "close-btn" ) ;
291+
292+ expect ( firstFocusableElement ) . to . have . focus ;
293+ await user . tab ( ) ;
294+ expect ( secondFocusableElement ) . to . have . focus ;
295+ await user . tab ( ) ;
296+ expect ( thirdFocusableElement ) . to . have . focus ;
297+ await user . tab ( ) ;
298+ expect ( lastFocusableElement ) . to . have . focus ;
299+ await user . tab ( ) ;
300+ expect ( firstFocusableElement ) . to . have . focus ;
301+ } ) ;
159302} ) ;
0 commit comments