Implement focus trapping

This commit is contained in:
Kamran Ahmed
2023-07-24 00:35:45 +01:00
parent 82a7eeceee
commit 0f198fda8a
8 changed files with 91 additions and 13 deletions
+5
View File
@@ -13,6 +13,11 @@
padding: 0;
}
*:focus {
outline: 2px solid #1a73e8;
outline-offset: 2px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
"Helvetica Neue", sans-serif;
+2 -2
View File
@@ -80,7 +80,7 @@
transition-duration: 200ms;
}
.driver-popover-close-btn:hover {
.driver-popover-close-btn:hover, .driver-popover-close-btn:focus {
color: #2d2d2d;
}
@@ -140,7 +140,7 @@
overflow: hidden !important;
}
.driver-popover-footer button:hover {
.driver-popover-footer button:hover, .driver-popover-footer button:focus {
background-color: #f7f7f7;
}
+7
View File
@@ -149,6 +149,7 @@ export function driver(options: Config = {}) {
return;
}
setState("__activeOnDestroyed", document.activeElement as HTMLElement);
setState("activeIndex", stepIndex);
const currentStep = steps[stepIndex];
@@ -215,6 +216,8 @@ export function driver(options: Config = {}) {
const activeElement = getState("activeElement");
const activeStep = getState("activeStep");
const activeOnDestroyed = getState("__activeOnDestroyed");
const onDestroyStarted = getConfig("onDestroyStarted");
// `onDestroyStarted` is used to confirm the exit of tour. If we trigger
// the hook for when user calls `destroy`, driver will get into infinite loop
@@ -257,6 +260,10 @@ export function driver(options: Config = {}) {
});
}
}
if (activeOnDestroyed) {
(activeOnDestroyed as HTMLElement).focus();
}
}
return {
+37
View File
@@ -2,6 +2,7 @@ import { refreshActiveHighlight } from "./highlight";
import { emit } from "./emitter";
import { getState, setState } from "./state";
import { getConfig } from "./config";
import { getFocusableElements, isElementVisible } from "./utils";
export function requireRefresh() {
const resizeTimeout = getState("__resizeTimeout");
@@ -12,6 +13,41 @@ export function requireRefresh() {
setState("__resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight));
}
function trapFocus(e: KeyboardEvent) {
const isActivated = getState("isInitialized");
if (!isActivated) {
return;
}
const isTabKey = e.key === "Tab" || e.keyCode === 9;
if (!isTabKey) {
return;
}
const activeElement = getState("activeElement");
const popoverEl = getState("popover")?.wrapper;
const focusableEls = getFocusableElements([
...(activeElement ? [activeElement] : []),
...(popoverEl ? [popoverEl] : []),
]);
const firstFocusableEl = focusableEls[0];
const lastFocusableEl = focusableEls[focusableEls.length - 1];
e.preventDefault();
if (e.shiftKey) {
const previousFocusableEl =
focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) - 1] || lastFocusableEl;
previousFocusableEl?.focus();
} else {
const nextFocusableEl =
focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) + 1] || firstFocusableEl;
nextFocusableEl?.focus();
}
}
function onKeyup(e: KeyboardEvent) {
const allowKeyboardControl = getConfig("allowKeyboardControl") || true;
if (!allowKeyboardControl) {
@@ -78,6 +114,7 @@ export function onDriverClick(
export function initEvents() {
window.addEventListener("keyup", onKeyup, false);
window.addEventListener("keydown", trapFocus, false);
window.addEventListener("resize", requireRefresh);
window.addEventListener("scroll", requireRefresh);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { DriveStep } from "./driver";
import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
import { getConfig } from "./config";
import { repositionPopover, renderPopover, hidePopover } from "./popover";
import { hidePopover, renderPopover, repositionPopover } from "./popover";
import { bringInView } from "./utils";
import { getState, setState } from "./state";
+20 -10
View File
@@ -1,4 +1,4 @@
import { bringInView } from "./utils";
import { bringInView, getFocusableElements } from "./utils";
import { Config, DriverHook, getConfig } from "./config";
import { getState, setState, State } from "./state";
import { DriveStep } from "./driver";
@@ -43,9 +43,9 @@ export type PopoverDOM = {
description: HTMLElement;
footer: HTMLElement;
progress: HTMLElement;
previousButton: HTMLElement;
nextButton: HTMLElement;
closeButton: HTMLElement;
previousButton: HTMLButtonElement;
nextButton: HTMLButtonElement;
closeButton: HTMLButtonElement;
footerButtons: HTMLElement;
};
@@ -100,9 +100,7 @@ export function renderPopover(element: Element, step: DriveStep) {
const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!;
const showProgressConfig = showProgress || getConfig("showProgress") || false;
const showFooter =
showButtonsConfig?.includes("next") ||
showButtonsConfig?.includes("previous") ||
showProgressConfig;
showButtonsConfig?.includes("next") || showButtonsConfig?.includes("previous") || showProgressConfig;
popover.closeButton.style.display = showButtonsConfig.includes("close") ? "block" : "none";
@@ -118,14 +116,17 @@ export function renderPopover(element: Element, step: DriveStep) {
const disabledButtonsConfig: AllowedButtons[] = disableButtons || getConfig("disableButtons")! || [];
if (disabledButtonsConfig?.includes("next")) {
popover.nextButton.disabled = true;
popover.nextButton.classList.add("driver-popover-btn-disabled");
}
if (disabledButtonsConfig?.includes("previous")) {
popover.previousButton.disabled = true;
popover.previousButton.classList.add("driver-popover-btn-disabled");
}
if (disabledButtonsConfig?.includes("close")) {
popover.closeButton.disabled = true;
popover.closeButton.classList.add("driver-popover-btn-disabled");
}
@@ -195,9 +196,11 @@ export function renderPopover(element: Element, step: DriveStep) {
target => {
// Only prevent the default action if we're clicking on a driver button
// This allows us to have links inside the popover title and description
return !popover?.description.contains(target)
&& !popover?.title.contains(target)
&& target.className.includes("driver-popover");
return (
!popover?.description.contains(target) &&
!popover?.title.contains(target) &&
target.className.includes("driver-popover")
);
}
);
@@ -213,6 +216,13 @@ export function renderPopover(element: Element, step: DriveStep) {
repositionPopover(element, step);
bringInView(popoverWrapper);
// Focus on the first focusable element in active element or popover
const isToDummyElement = element.classList.contains("driver-dummy-element");
const focusableElement = getFocusableElements([...(isToDummyElement ? [] : [element]), popoverWrapper]);
if (focusableElement.length > 0) {
focusableElement[0].focus();
}
}
type PopoverDimensions = {
+1
View File
@@ -14,6 +14,7 @@ export type State = {
popover?: PopoverDOM;
__activeOnDestroyed?: Element;
__resizeTimeout?: number;
__transitionCallback?: () => void;
__activeStagePosition?: StageDefinition;
+18
View File
@@ -7,6 +7,20 @@ export function easeInOutQuad(elapsed: number, initialValue: number, amountOfCha
return (-amountOfChange / 2) * (--elapsed * (elapsed - 2) - 1) + initialValue;
}
export function getFocusableElements(parentEls: Element[] | HTMLElement[]) {
const focusableQuery =
'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])';
return parentEls
.flatMap(parentEl => {
const isParentFocusable = parentEl.matches(focusableQuery);
const focusableEls: HTMLElement[] = Array.from(parentEl.querySelectorAll(focusableQuery));
return [...(isParentFocusable ? [parentEl as HTMLElement] : []), ...focusableEls];
})
.filter(el => isElementVisible(el));
}
export function bringInView(element: Element) {
if (!element || isElementInView(element)) {
return;
@@ -43,3 +57,7 @@ function isElementInView(element: Element) {
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
export function isElementVisible(el: HTMLElement) {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}