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 |
+
}
|