Skip to content

Commit 787c2fb

Browse files
authored
Prevent Nav from Trapping while tabbing (#427)
Prevent Nav from Trapping while tabbing (#427) 94044029
1 parent 2abc274 commit 787c2fb

File tree

4 files changed

+115
-64
lines changed

4 files changed

+115
-64
lines changed

src/components/NavBase.vue

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!--
22
This source file is part of the Swift.org open source project
33
4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66
77
See https://swift.org/LICENSE.txt for license information
@@ -75,7 +75,6 @@ import onIntersect from 'docc-render/mixins/onIntersect';
7575
import NavMenuItems from 'docc-render/components/NavMenuItems.vue';
7676
import BreakpointEmitter from 'docc-render/components/BreakpointEmitter.vue';
7777
78-
import FocusTrap from 'docc-render/utils/FocusTrap';
7978
import scrollLock from 'docc-render/utils/scroll-lock';
8079
import { baseNavStickyAnchorId, MenuLinkModifierClasses } from 'docc-render/constants/nav';
8180
import { isBreakpointAbove } from 'docc-render/utils/breakpoints';
@@ -154,7 +153,6 @@ export default {
154153
isTransitioning: false,
155154
isSticking: false,
156155
noBackgroundTransition: true,
157-
focusTrapInstance: null,
158156
currentBreakpoint: BreakpointName.large,
159157
};
160158
},
@@ -196,7 +194,6 @@ export default {
196194
document.addEventListener('click', this.handleClickOutside);
197195
this.handleFlashOnMount();
198196
await this.$nextTick();
199-
this.focusTrapInstance = new FocusTrap(this.$refs.wrapper);
200197
},
201198
beforeDestroy() {
202199
window.removeEventListener('keydown', this.onEscape);
@@ -206,7 +203,6 @@ export default {
206203
if (this.isOpen) {
207204
this.toggleScrollLock(false);
208205
}
209-
this.focusTrapInstance.destroy();
210206
},
211207
methods: {
212208
getIntersectionTargets() {
@@ -317,16 +313,15 @@ export default {
317313
},
318314
onExpand() {
319315
this.$emit('open');
320-
// lock focus
321-
this.focusTrapInstance.start();
322316
// hide sibling elements from VO
323317
changeElementVOVisibility.hide(this.$refs.wrapper);
318+
// focus on the toggle to prevent tabbing to links in the body
319+
this.$refs.axToggle.focus();
324320
},
325321
onClose() {
326322
this.$emit('close');
327323
// stop the scroll lock
328324
this.toggleScrollLock(false);
329-
this.focusTrapInstance.stop();
330325
changeElementVOVisibility.show(this.$refs.wrapper);
331326
},
332327
async handleFlashOnMount() {

src/utils/changeElementVOVisibility.js

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,46 @@
11
/**
22
* This source file is part of the Swift.org open source project
33
*
4-
* Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
* Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
* Licensed under Apache License v2.0 with Runtime Library Exception
66
*
77
* See https://swift.org/LICENSE.txt for license information
88
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
*/
10+
import TabManager from 'docc-render/utils/TabManager';
11+
12+
const OG_PREFIX = 'data-original-';
13+
const ARIA = 'aria-hidden';
14+
const TABINDEX = 'tabindex';
15+
16+
function cacheOriginalAttribute(element, prop) {
17+
const attr = OG_PREFIX + prop;
18+
19+
// make sure that prop isn't cached already
20+
if (element.getAttribute(attr)) return;
21+
22+
const originalValue = element.getAttribute(prop) || '';
23+
element.setAttribute(attr, originalValue);
24+
}
25+
26+
function retrieveOriginalAttribute(element, prop) {
27+
const attr = OG_PREFIX + prop;
28+
29+
// return if attribute doesn't exist
30+
if (!element.hasAttribute(attr)) return;
31+
// get the cached property
32+
const originalValue = element.getAttribute(attr);
33+
// remove the prefixed attribute
34+
element.removeAttribute(attr);
35+
36+
// if there is a value, set it back.
37+
if (originalValue.length) {
38+
element.setAttribute(prop, originalValue);
39+
} else {
40+
// otherwise remove the attribute entirely.
41+
element.removeAttribute(prop);
42+
}
43+
}
1044

1145
/* eslint-disable no-cond-assign */
1246
function iterateOverSiblings(el, callback) {
@@ -27,43 +61,46 @@ function iterateOverSiblings(el, callback) {
2761
}
2862
}
2963

30-
const PREFIX = 'data-original-';
31-
const prop = 'aria-hidden';
32-
const prefixedProperty = PREFIX + prop;
33-
3464
/**
3565
* Hides an element from VO
3666
* @param {HTMLElement} element
3767
*/
3868
const hideElement = (element) => {
39-
let originalValue = element.getAttribute(prefixedProperty);
40-
if (!originalValue) {
41-
// store the prop temporarily, to retrieve later.
42-
originalValue = element.getAttribute(prop) || '';
43-
element.setAttribute(prefixedProperty, originalValue);
69+
// set original value for prefixed properties and tabindex
70+
// store the prop temporarily, to retrieve later.
71+
cacheOriginalAttribute(element, ARIA);
72+
cacheOriginalAttribute(element, TABINDEX);
73+
74+
// hide the component from VO
75+
element.setAttribute(ARIA, 'true');
76+
77+
// hide the component from tabbing
78+
element.setAttribute(TABINDEX, '-1');
79+
// make sure element's tabbable children are hidden as well
80+
const tabbables = TabManager.getTabbableElements(element);
81+
let i = tabbables.length - 1;
82+
while (i >= 0) {
83+
cacheOriginalAttribute(tabbables[i], TABINDEX);
84+
tabbables[i].setAttribute(TABINDEX, '-1');
85+
i -= 1;
4486
}
45-
// hide the component
46-
element.setAttribute(prop, 'true');
4787
};
4888

4989
/**
5090
* Show an element
5191
* @param {HTMLElement} element
5292
*/
5393
const showElement = (element) => {
54-
// get the cached property
55-
const originalValue = element.getAttribute(prefixedProperty);
56-
if (typeof originalValue === 'string') {
57-
// if there is a value, set it back.
58-
if (originalValue.length) {
59-
element.setAttribute(prop, originalValue);
60-
} else {
61-
// otherwise remove the attribute entirely.
62-
element.removeAttribute(prop);
63-
}
94+
retrieveOriginalAttribute(element, ARIA);
95+
retrieveOriginalAttribute(element, TABINDEX);
96+
97+
// make sure element's tabbable children are restored as well
98+
const tabbables = element.querySelectorAll(`[${OG_PREFIX + TABINDEX}]`);
99+
let i = tabbables.length - 1;
100+
while (i >= 0) {
101+
retrieveOriginalAttribute(tabbables[i], TABINDEX);
102+
i -= 1;
64103
}
65-
// remove the prefixed attribute
66-
element.removeAttribute(prefixedProperty);
67104
};
68105

69106
/**

tests/unit/components/NavBase.spec.js

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* This source file is part of the Swift.org open source project
33
*
4-
* Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
* Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
* Licensed under Apache License v2.0 with Runtime Library Exception
66
*
77
* See https://swift.org/LICENSE.txt for license information
@@ -13,15 +13,13 @@ import { shallowMount } from '@vue/test-utils';
1313
import NavMenuItems from 'docc-render/components/NavMenuItems.vue';
1414
import BreakpointEmitter from 'docc-render/components/BreakpointEmitter.vue';
1515
import scrollLock from 'docc-render/utils/scroll-lock';
16-
import FocusTrap from 'docc-render/utils/FocusTrap';
1716
import changeElementVOVisibility from 'docc-render/utils/changeElementVOVisibility';
1817
import { baseNavStickyAnchorId, MenuLinkModifierClasses } from 'docc-render/constants/nav';
1918
import { waitFrames } from 'docc-render/utils/loading';
2019
import { createEvent } from '../../../test-utils';
2120

2221
jest.mock('docc-render/utils/changeElementVOVisibility');
2322
jest.mock('docc-render/utils/scroll-lock');
24-
jest.mock('docc-render/utils/FocusTrap');
2523

2624
const { BreakpointScopes, BreakpointName } = BreakpointEmitter.constants;
2725
const { NoBGTransitionFrames, NavStateClasses } = NavBase.constants;
@@ -454,30 +452,11 @@ describe('NavBase', () => {
454452
expect(scrollLock.unlockScroll).toHaveBeenCalledTimes(1);
455453
});
456454

457-
it('locks the focus on expand', async () => {
455+
it('focuses on the toggle on expand', async () => {
458456
wrapper = await createWrapper();
459-
expect(FocusTrap).toHaveBeenCalledTimes(1);
460-
expect(FocusTrap).toHaveBeenCalledWith(wrapper.vm.$refs.wrapper);
461457
wrapper.find({ ref: 'axToggle' }).trigger('click');
462-
await wrapper.vm.$nextTick();
463-
expect(FocusTrap.mock.results[0].value.start).toHaveBeenCalledTimes(1);
464-
});
465-
466-
it('unlocks the focus on close', async () => {
467-
wrapper = await createWrapper();
468-
wrapper.find({ ref: 'axToggle' }).trigger('click');
469-
await wrapper.vm.$nextTick();
470-
expect(FocusTrap.mock.results[0].value.start).toHaveBeenCalledTimes(1);
471-
expect(FocusTrap.mock.results[0].value.stop).toHaveBeenCalledTimes(0);
472-
wrapper.find({ ref: 'axToggle' }).trigger('click');
473-
expect(FocusTrap.mock.results[0].value.stop).toHaveBeenCalledTimes(1);
474-
});
475-
476-
it('destroys the focus instance on component destroy', async () => {
477-
wrapper = await createWrapper();
478-
expect(FocusTrap.mock.results[0].value.destroy).toHaveBeenCalledTimes(0);
479-
wrapper.destroy();
480-
expect(FocusTrap.mock.results[0].value.destroy).toHaveBeenCalledTimes(1);
458+
// assert the toggle is focused
459+
expect(document.activeElement).toEqual(wrapper.find({ ref: 'axToggle' }).element);
481460
});
482461

483462
it('changes the sibling visibility to `hidden` on expand', async () => {

tests/unit/utils/changeElementVOVisibility.spec.js

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* This source file is part of the Swift.org open source project
33
*
4-
* Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
* Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
* Licensed under Apache License v2.0 with Runtime Library Exception
66
*
77
* See https://swift.org/LICENSE.txt for license information
@@ -44,18 +44,23 @@ describe("changeElementVOVisibility", () => {
4444
expect(document.querySelector(".main .navigation").getAttribute("aria-hidden")).toBe("true");
4545
expect(document.querySelector(".main .target").getAttribute("aria-hidden")).toBeFalsy();
4646
expect(document.querySelector(".footer").getAttribute("aria-hidden")).toBe("true");
47+
48+
expect(document.querySelector(".header").getAttribute("tabindex")).toBe("-1");
49+
expect(document.querySelector(".main .navigation").getAttribute("tabindex")).toBe("-1");
50+
expect(document.querySelector(".footer").getAttribute("tabindex")).toBe("-1");
51+
4752
expect(document.body.outerHTML).toMatchInlineSnapshot(`
4853
<body>
4954
<div>
5055
<div>
51-
<div class="header" data-original-aria-hidden="" aria-hidden="true">Header</div>
56+
<div class="header" data-original-aria-hidden="" data-original-tabindex="" aria-hidden="true" tabindex="-1">Header</div>
5257
<main class="main">
53-
<nav class="navigation" data-original-aria-hidden="" aria-hidden="true">Navigation</nav>
58+
<nav class="navigation" data-original-aria-hidden="" data-original-tabindex="" aria-hidden="true" tabindex="-1">Navigation</nav>
5459
<div class="target">
5560
<div class="inside">Inside</div>
5661
</div>
5762
</main>
58-
<div class="footer" data-original-aria-hidden="" aria-hidden="true">Footer</div>
63+
<div class="footer" data-original-aria-hidden="" data-original-tabindex="" aria-hidden="true" tabindex="-1">Footer</div>
5964
</div>
6065
</div>
6166
</body>
@@ -110,14 +115,49 @@ describe("changeElementVOVisibility", () => {
110115
<body>
111116
<div>
112117
<div>
113-
<div class="header" aria-hidden="true" data-original-aria-hidden="true">Header</div>
118+
<div class="header" aria-hidden="true" data-original-aria-hidden="true" data-original-tabindex="" tabindex="-1">Header</div>
119+
<main class="main">
120+
<nav class="navigation" aria-hidden="true" data-original-aria-hidden="false" data-original-tabindex="" tabindex="-1">Navigation</nav>
121+
<div class="target">
122+
<div class="inside">Inside</div>
123+
</div>
124+
</main>
125+
<div class="footer" data-original-aria-hidden="" data-original-tabindex="" aria-hidden="true" tabindex="-1">Footer</div>
126+
</div>
127+
</div>
128+
</body>
129+
`);
130+
changeElementVOVisibility.show(target);
131+
expect(document.body.outerHTML).toEqual(cachedHTML);
132+
});
133+
134+
it("preserves previously set tabindex values", () => {
135+
DOM.querySelector(".header").setAttribute("tabindex", "2");
136+
DOM.querySelector(".navigation").setAttribute("tabindex", "-1");
137+
document.body.appendChild(DOM);
138+
const cachedHTML = document.body.outerHTML;
139+
140+
const target = document.querySelector(".target");
141+
// hide all
142+
changeElementVOVisibility.hide(target);
143+
expect(document.querySelector(".header").getAttribute("data-original-tabindex")).toBe(
144+
"2"
145+
);
146+
expect(document.querySelector(".navigation").getAttribute("data-original-tabindex")).toBe(
147+
"-1"
148+
);
149+
expect(document.body.outerHTML).toMatchInlineSnapshot(`
150+
<body>
151+
<div>
152+
<div>
153+
<div class="header" tabindex="-1" data-original-aria-hidden="" data-original-tabindex="2" aria-hidden="true">Header</div>
114154
<main class="main">
115-
<nav class="navigation" aria-hidden="true" data-original-aria-hidden="false">Navigation</nav>
155+
<nav class="navigation" tabindex="-1" data-original-aria-hidden="" data-original-tabindex="-1" aria-hidden="true">Navigation</nav>
116156
<div class="target">
117157
<div class="inside">Inside</div>
118158
</div>
119159
</main>
120-
<div class="footer" data-original-aria-hidden="" aria-hidden="true">Footer</div>
160+
<div class="footer" data-original-aria-hidden="" data-original-tabindex="" aria-hidden="true" tabindex="-1">Footer</div>
121161
</div>
122162
</div>
123163
</body>

0 commit comments

Comments
 (0)