mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-10-31 03:52:13 +08:00
Implement focus trapping
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -14,6 +14,7 @@ export type State = {
|
||||
|
||||
popover?: PopoverDOM;
|
||||
|
||||
__activeOnDestroyed?: Element;
|
||||
__resizeTimeout?: number;
|
||||
__transitionCallback?: () => void;
|
||||
__activeStagePosition?: StageDefinition;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user