Implement focus trapping
Browse files- index.html +5 -0
- src/driver.css +2 -2
- src/driver.ts +7 -0
- src/events.ts +37 -0
- src/highlight.ts +1 -1
- src/popover.ts +20 -10
- src/state.ts +1 -0
- src/utils.ts +18 -0
index.html
CHANGED
@@ -13,6 +13,11 @@
|
|
13 |
padding: 0;
|
14 |
}
|
15 |
|
|
|
|
|
|
|
|
|
|
|
16 |
body {
|
17 |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
|
18 |
"Helvetica Neue", sans-serif;
|
|
|
13 |
padding: 0;
|
14 |
}
|
15 |
|
16 |
+
*:focus {
|
17 |
+
outline: 2px solid #1a73e8;
|
18 |
+
outline-offset: 2px;
|
19 |
+
}
|
20 |
+
|
21 |
body {
|
22 |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
|
23 |
"Helvetica Neue", sans-serif;
|
src/driver.css
CHANGED
@@ -80,7 +80,7 @@
|
|
80 |
transition-duration: 200ms;
|
81 |
}
|
82 |
|
83 |
-
.driver-popover-close-btn:hover {
|
84 |
color: #2d2d2d;
|
85 |
}
|
86 |
|
@@ -140,7 +140,7 @@
|
|
140 |
overflow: hidden !important;
|
141 |
}
|
142 |
|
143 |
-
.driver-popover-footer button:hover {
|
144 |
background-color: #f7f7f7;
|
145 |
}
|
146 |
|
|
|
80 |
transition-duration: 200ms;
|
81 |
}
|
82 |
|
83 |
+
.driver-popover-close-btn:hover, .driver-popover-close-btn:focus {
|
84 |
color: #2d2d2d;
|
85 |
}
|
86 |
|
|
|
140 |
overflow: hidden !important;
|
141 |
}
|
142 |
|
143 |
+
.driver-popover-footer button:hover, .driver-popover-footer button:focus {
|
144 |
background-color: #f7f7f7;
|
145 |
}
|
146 |
|
src/driver.ts
CHANGED
@@ -149,6 +149,7 @@ export function driver(options: Config = {}) {
|
|
149 |
return;
|
150 |
}
|
151 |
|
|
|
152 |
setState("activeIndex", stepIndex);
|
153 |
|
154 |
const currentStep = steps[stepIndex];
|
@@ -215,6 +216,8 @@ export function driver(options: Config = {}) {
|
|
215 |
const activeElement = getState("activeElement");
|
216 |
const activeStep = getState("activeStep");
|
217 |
|
|
|
|
|
218 |
const onDestroyStarted = getConfig("onDestroyStarted");
|
219 |
// `onDestroyStarted` is used to confirm the exit of tour. If we trigger
|
220 |
// the hook for when user calls `destroy`, driver will get into infinite loop
|
@@ -257,6 +260,10 @@ export function driver(options: Config = {}) {
|
|
257 |
});
|
258 |
}
|
259 |
}
|
|
|
|
|
|
|
|
|
260 |
}
|
261 |
|
262 |
return {
|
|
|
149 |
return;
|
150 |
}
|
151 |
|
152 |
+
setState("__activeOnDestroyed", document.activeElement as HTMLElement);
|
153 |
setState("activeIndex", stepIndex);
|
154 |
|
155 |
const currentStep = steps[stepIndex];
|
|
|
216 |
const activeElement = getState("activeElement");
|
217 |
const activeStep = getState("activeStep");
|
218 |
|
219 |
+
const activeOnDestroyed = getState("__activeOnDestroyed");
|
220 |
+
|
221 |
const onDestroyStarted = getConfig("onDestroyStarted");
|
222 |
// `onDestroyStarted` is used to confirm the exit of tour. If we trigger
|
223 |
// the hook for when user calls `destroy`, driver will get into infinite loop
|
|
|
260 |
});
|
261 |
}
|
262 |
}
|
263 |
+
|
264 |
+
if (activeOnDestroyed) {
|
265 |
+
(activeOnDestroyed as HTMLElement).focus();
|
266 |
+
}
|
267 |
}
|
268 |
|
269 |
return {
|
src/events.ts
CHANGED
@@ -2,6 +2,7 @@ import { refreshActiveHighlight } from "./highlight";
|
|
2 |
import { emit } from "./emitter";
|
3 |
import { getState, setState } from "./state";
|
4 |
import { getConfig } from "./config";
|
|
|
5 |
|
6 |
export function requireRefresh() {
|
7 |
const resizeTimeout = getState("__resizeTimeout");
|
@@ -12,6 +13,41 @@ export function requireRefresh() {
|
|
12 |
setState("__resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight));
|
13 |
}
|
14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
function onKeyup(e: KeyboardEvent) {
|
16 |
const allowKeyboardControl = getConfig("allowKeyboardControl") || true;
|
17 |
if (!allowKeyboardControl) {
|
@@ -78,6 +114,7 @@ export function onDriverClick(
|
|
78 |
|
79 |
export function initEvents() {
|
80 |
window.addEventListener("keyup", onKeyup, false);
|
|
|
81 |
window.addEventListener("resize", requireRefresh);
|
82 |
window.addEventListener("scroll", requireRefresh);
|
83 |
}
|
|
|
2 |
import { emit } from "./emitter";
|
3 |
import { getState, setState } from "./state";
|
4 |
import { getConfig } from "./config";
|
5 |
+
import { getFocusableElements, isElementVisible } from "./utils";
|
6 |
|
7 |
export function requireRefresh() {
|
8 |
const resizeTimeout = getState("__resizeTimeout");
|
|
|
13 |
setState("__resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight));
|
14 |
}
|
15 |
|
16 |
+
function trapFocus(e: KeyboardEvent) {
|
17 |
+
const isActivated = getState("isInitialized");
|
18 |
+
if (!isActivated) {
|
19 |
+
return;
|
20 |
+
}
|
21 |
+
|
22 |
+
const isTabKey = e.key === "Tab" || e.keyCode === 9;
|
23 |
+
if (!isTabKey) {
|
24 |
+
return;
|
25 |
+
}
|
26 |
+
|
27 |
+
const activeElement = getState("activeElement");
|
28 |
+
const popoverEl = getState("popover")?.wrapper;
|
29 |
+
|
30 |
+
const focusableEls = getFocusableElements([
|
31 |
+
...(activeElement ? [activeElement] : []),
|
32 |
+
...(popoverEl ? [popoverEl] : []),
|
33 |
+
]);
|
34 |
+
|
35 |
+
const firstFocusableEl = focusableEls[0];
|
36 |
+
const lastFocusableEl = focusableEls[focusableEls.length - 1];
|
37 |
+
|
38 |
+
e.preventDefault();
|
39 |
+
|
40 |
+
if (e.shiftKey) {
|
41 |
+
const previousFocusableEl =
|
42 |
+
focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) - 1] || lastFocusableEl;
|
43 |
+
previousFocusableEl?.focus();
|
44 |
+
} else {
|
45 |
+
const nextFocusableEl =
|
46 |
+
focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) + 1] || firstFocusableEl;
|
47 |
+
nextFocusableEl?.focus();
|
48 |
+
}
|
49 |
+
}
|
50 |
+
|
51 |
function onKeyup(e: KeyboardEvent) {
|
52 |
const allowKeyboardControl = getConfig("allowKeyboardControl") || true;
|
53 |
if (!allowKeyboardControl) {
|
|
|
114 |
|
115 |
export function initEvents() {
|
116 |
window.addEventListener("keyup", onKeyup, false);
|
117 |
+
window.addEventListener("keydown", trapFocus, false);
|
118 |
window.addEventListener("resize", requireRefresh);
|
119 |
window.addEventListener("scroll", requireRefresh);
|
120 |
}
|
src/highlight.ts
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
import { DriveStep } from "./driver";
|
2 |
import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
|
3 |
import { getConfig } from "./config";
|
4 |
-
import {
|
5 |
import { bringInView } from "./utils";
|
6 |
import { getState, setState } from "./state";
|
7 |
|
|
|
1 |
import { DriveStep } from "./driver";
|
2 |
import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
|
3 |
import { getConfig } from "./config";
|
4 |
+
import { hidePopover, renderPopover, repositionPopover } from "./popover";
|
5 |
import { bringInView } from "./utils";
|
6 |
import { getState, setState } from "./state";
|
7 |
|
src/popover.ts
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import { bringInView } from "./utils";
|
2 |
import { Config, DriverHook, getConfig } from "./config";
|
3 |
import { getState, setState, State } from "./state";
|
4 |
import { DriveStep } from "./driver";
|
@@ -43,9 +43,9 @@ export type PopoverDOM = {
|
|
43 |
description: HTMLElement;
|
44 |
footer: HTMLElement;
|
45 |
progress: HTMLElement;
|
46 |
-
previousButton:
|
47 |
-
nextButton:
|
48 |
-
closeButton:
|
49 |
footerButtons: HTMLElement;
|
50 |
};
|
51 |
|
@@ -100,9 +100,7 @@ export function renderPopover(element: Element, step: DriveStep) {
|
|
100 |
const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!;
|
101 |
const showProgressConfig = showProgress || getConfig("showProgress") || false;
|
102 |
const showFooter =
|
103 |
-
showButtonsConfig?.includes("next") ||
|
104 |
-
showButtonsConfig?.includes("previous") ||
|
105 |
-
showProgressConfig;
|
106 |
|
107 |
popover.closeButton.style.display = showButtonsConfig.includes("close") ? "block" : "none";
|
108 |
|
@@ -118,14 +116,17 @@ export function renderPopover(element: Element, step: DriveStep) {
|
|
118 |
|
119 |
const disabledButtonsConfig: AllowedButtons[] = disableButtons || getConfig("disableButtons")! || [];
|
120 |
if (disabledButtonsConfig?.includes("next")) {
|
|
|
121 |
popover.nextButton.classList.add("driver-popover-btn-disabled");
|
122 |
}
|
123 |
|
124 |
if (disabledButtonsConfig?.includes("previous")) {
|
|
|
125 |
popover.previousButton.classList.add("driver-popover-btn-disabled");
|
126 |
}
|
127 |
|
128 |
if (disabledButtonsConfig?.includes("close")) {
|
|
|
129 |
popover.closeButton.classList.add("driver-popover-btn-disabled");
|
130 |
}
|
131 |
|
@@ -195,9 +196,11 @@ export function renderPopover(element: Element, step: DriveStep) {
|
|
195 |
target => {
|
196 |
// Only prevent the default action if we're clicking on a driver button
|
197 |
// This allows us to have links inside the popover title and description
|
198 |
-
return
|
199 |
-
|
200 |
-
|
|
|
|
|
201 |
}
|
202 |
);
|
203 |
|
@@ -213,6 +216,13 @@ export function renderPopover(element: Element, step: DriveStep) {
|
|
213 |
|
214 |
repositionPopover(element, step);
|
215 |
bringInView(popoverWrapper);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
216 |
}
|
217 |
|
218 |
type PopoverDimensions = {
|
|
|
1 |
+
import { bringInView, getFocusableElements } from "./utils";
|
2 |
import { Config, DriverHook, getConfig } from "./config";
|
3 |
import { getState, setState, State } from "./state";
|
4 |
import { DriveStep } from "./driver";
|
|
|
43 |
description: HTMLElement;
|
44 |
footer: HTMLElement;
|
45 |
progress: HTMLElement;
|
46 |
+
previousButton: HTMLButtonElement;
|
47 |
+
nextButton: HTMLButtonElement;
|
48 |
+
closeButton: HTMLButtonElement;
|
49 |
footerButtons: HTMLElement;
|
50 |
};
|
51 |
|
|
|
100 |
const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!;
|
101 |
const showProgressConfig = showProgress || getConfig("showProgress") || false;
|
102 |
const showFooter =
|
103 |
+
showButtonsConfig?.includes("next") || showButtonsConfig?.includes("previous") || showProgressConfig;
|
|
|
|
|
104 |
|
105 |
popover.closeButton.style.display = showButtonsConfig.includes("close") ? "block" : "none";
|
106 |
|
|
|
116 |
|
117 |
const disabledButtonsConfig: AllowedButtons[] = disableButtons || getConfig("disableButtons")! || [];
|
118 |
if (disabledButtonsConfig?.includes("next")) {
|
119 |
+
popover.nextButton.disabled = true;
|
120 |
popover.nextButton.classList.add("driver-popover-btn-disabled");
|
121 |
}
|
122 |
|
123 |
if (disabledButtonsConfig?.includes("previous")) {
|
124 |
+
popover.previousButton.disabled = true;
|
125 |
popover.previousButton.classList.add("driver-popover-btn-disabled");
|
126 |
}
|
127 |
|
128 |
if (disabledButtonsConfig?.includes("close")) {
|
129 |
+
popover.closeButton.disabled = true;
|
130 |
popover.closeButton.classList.add("driver-popover-btn-disabled");
|
131 |
}
|
132 |
|
|
|
196 |
target => {
|
197 |
// Only prevent the default action if we're clicking on a driver button
|
198 |
// This allows us to have links inside the popover title and description
|
199 |
+
return (
|
200 |
+
!popover?.description.contains(target) &&
|
201 |
+
!popover?.title.contains(target) &&
|
202 |
+
target.className.includes("driver-popover")
|
203 |
+
);
|
204 |
}
|
205 |
);
|
206 |
|
|
|
216 |
|
217 |
repositionPopover(element, step);
|
218 |
bringInView(popoverWrapper);
|
219 |
+
|
220 |
+
// Focus on the first focusable element in active element or popover
|
221 |
+
const isToDummyElement = element.classList.contains("driver-dummy-element");
|
222 |
+
const focusableElement = getFocusableElements([...(isToDummyElement ? [] : [element]), popoverWrapper]);
|
223 |
+
if (focusableElement.length > 0) {
|
224 |
+
focusableElement[0].focus();
|
225 |
+
}
|
226 |
}
|
227 |
|
228 |
type PopoverDimensions = {
|
src/state.ts
CHANGED
@@ -14,6 +14,7 @@ export type State = {
|
|
14 |
|
15 |
popover?: PopoverDOM;
|
16 |
|
|
|
17 |
__resizeTimeout?: number;
|
18 |
__transitionCallback?: () => void;
|
19 |
__activeStagePosition?: StageDefinition;
|
|
|
14 |
|
15 |
popover?: PopoverDOM;
|
16 |
|
17 |
+
__activeOnDestroyed?: Element;
|
18 |
__resizeTimeout?: number;
|
19 |
__transitionCallback?: () => void;
|
20 |
__activeStagePosition?: StageDefinition;
|
src/utils.ts
CHANGED
@@ -7,6 +7,20 @@ export function easeInOutQuad(elapsed: number, initialValue: number, amountOfCha
|
|
7 |
return (-amountOfChange / 2) * (--elapsed * (elapsed - 2) - 1) + initialValue;
|
8 |
}
|
9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
export function bringInView(element: Element) {
|
11 |
if (!element || isElementInView(element)) {
|
12 |
return;
|
@@ -43,3 +57,7 @@ function isElementInView(element: Element) {
|
|
43 |
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
44 |
);
|
45 |
}
|
|
|
|
|
|
|
|
|
|
7 |
return (-amountOfChange / 2) * (--elapsed * (elapsed - 2) - 1) + initialValue;
|
8 |
}
|
9 |
|
10 |
+
export function getFocusableElements(parentEls: Element[] | HTMLElement[]) {
|
11 |
+
const focusableQuery =
|
12 |
+
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';
|
13 |
+
|
14 |
+
return parentEls
|
15 |
+
.flatMap(parentEl => {
|
16 |
+
const isParentFocusable = parentEl.matches(focusableQuery);
|
17 |
+
const focusableEls: HTMLElement[] = Array.from(parentEl.querySelectorAll(focusableQuery));
|
18 |
+
|
19 |
+
return [...(isParentFocusable ? [parentEl as HTMLElement] : []), ...focusableEls];
|
20 |
+
})
|
21 |
+
.filter(el => isElementVisible(el));
|
22 |
+
}
|
23 |
+
|
24 |
export function bringInView(element: Element) {
|
25 |
if (!element || isElementInView(element)) {
|
26 |
return;
|
|
|
57 |
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
58 |
);
|
59 |
}
|
60 |
+
|
61 |
+
export function isElementVisible(el: HTMLElement) {
|
62 |
+
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
63 |
+
}
|