Skip to content

Commit 9c08388

Browse files
authored
SPARK-8: Fix Modal Focus Issue when using Keyboard (#1963)
* fix reported focus issue * Also handle case where modal has no tabbable elements * fix webpack.config.js for windows * move if check up * Create loud-suits-stare.md * fix whitespace * revert html changes * revert harder * add tests * lint
1 parent 6e2c64d commit 9c08388

File tree

5 files changed

+204
-47
lines changed

5 files changed

+204
-47
lines changed

.changeset/loud-suits-stare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackoverflow/stacks": patch
3+
---
4+
5+
SPARK-8: Fix Modal text selection issue when using keyboard

docs/product/components/modals.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,4 +419,4 @@ <h1 class="s-modal--header" id="example-modal-celebratory-title">Congratulations
419419
</section>
420420

421421
<!-- Example modals javascript -->
422-
<script src="{{ "/assets/dist/entry.modals.js" | url }}" defer></script>
422+
<script src="{{ "/assets/dist/entry.modals.js" | url }}" defer></script>

lib/components/modal/modal.test.ts

Lines changed: 163 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@ const user = userEvent.setup();
88
const 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
});

lib/components/modal/modal.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,14 @@ export class ModalController extends Stacks.StacksController {
225225
this.modalTarget.addEventListener(
226226
"s-modal:shown",
227227
() => {
228-
const initialFocus =
229-
this.firstVisible(this.initialFocusTargets) ??
230-
this.firstVisible(this.getAllTabbables());
231-
232228
// Only set focus if focus is not already set on an element within the modal
233229
if (!this.modalTarget.contains(document.activeElement)) {
234-
initialFocus?.focus();
230+
const initialFocus =
231+
this.firstVisible(this.initialFocusTargets) ??
232+
this.firstVisible(this.getAllTabbables()) ??
233+
this.modalTarget; //If there's nothing else, focus the modal itself
234+
235+
initialFocus.focus();
235236
}
236237
},
237238
{ once: true }
@@ -242,9 +243,21 @@ export class ModalController extends Stacks.StacksController {
242243
* Returns keyboard focus to the modal if it has left or is about to leave.
243244
*/
244245
private keepFocusWithinModal(e: KeyboardEvent) {
245-
// If somehow the user has tabbed out of the modal or if focus started outside the modal, push them to the first item.
246+
if (e.key !== "Tab") {
247+
return;
248+
}
249+
250+
//Tab key was pressed, figure out what to focus next
251+
const tabbables = this.getAllTabbables();
252+
if (tabbables.length === 0) {
253+
// Nothing focusable found in the modal. Stop the user from tabbing to something outside the modal
254+
e.preventDefault();
255+
return;
256+
}
257+
258+
// If somehow the user has tabbed out of the modal, push them to the first item.
246259
if (!this.modalTarget.contains(<Element>e.target)) {
247-
const focusTarget = this.firstVisible(this.getAllTabbables());
260+
const focusTarget = this.firstVisible(tabbables);
248261
if (focusTarget) {
249262
e.preventDefault();
250263
focusTarget.focus();
@@ -253,24 +266,20 @@ export class ModalController extends Stacks.StacksController {
253266
return;
254267
}
255268

256-
// If we observe a tab keydown and we're on an edge, cycle the focus to the other side.
257-
if (e.key === "Tab") {
258-
const tabbables = this.getAllTabbables();
259-
260-
const firstTabbable = this.firstVisible(tabbables);
261-
const lastTabbable = this.lastVisible(tabbables);
262-
263-
if (firstTabbable && lastTabbable) {
264-
if (firstTabbable === lastTabbable) {
265-
e.preventDefault();
266-
firstTabbable.focus();
267-
} else if (e.shiftKey && e.target === firstTabbable) {
268-
e.preventDefault();
269-
lastTabbable.focus();
270-
} else if (!e.shiftKey && e.target === lastTabbable) {
271-
e.preventDefault();
272-
firstTabbable.focus();
273-
}
269+
// Keep focusable cycling within the modal by looping through elements within the modal
270+
const firstTabbable = this.firstVisible(tabbables);
271+
const lastTabbable = this.lastVisible(tabbables);
272+
273+
if (firstTabbable && lastTabbable) {
274+
if (firstTabbable === lastTabbable) {
275+
e.preventDefault();
276+
firstTabbable.focus();
277+
} else if (e.shiftKey && e.target === firstTabbable) {
278+
e.preventDefault();
279+
lastTabbable.focus();
280+
} else if (!e.shiftKey && e.target === lastTabbable) {
281+
e.preventDefault();
282+
firstTabbable.focus();
274283
}
275284
}
276285
}

webpack.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const baseConfig = (isProd, minify) => ({
3737
{
3838
test: /\.less$/,
3939
use: [
40-
MiniCssExtractPlugin.loader,
40+
isProd ? MiniCssExtractPlugin.loader : "",
4141
{
4242
loader: "css-loader",
4343
options: {

0 commit comments

Comments
 (0)