diff --git a/index.html b/index.html
index 68e8e2b..7d4b147 100644
--- a/index.html
+++ b/index.html
@@ -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;
diff --git a/src/driver.css b/src/driver.css
index f24f14a..5638716 100644
--- a/src/driver.css
+++ b/src/driver.css
@@ -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;
}
diff --git a/src/driver.ts b/src/driver.ts
index 9898591..55ad763 100644
--- a/src/driver.ts
+++ b/src/driver.ts
@@ -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 {
diff --git a/src/events.ts b/src/events.ts
index c5a3a26..b37769c 100644
--- a/src/events.ts
+++ b/src/events.ts
@@ -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);
}
diff --git a/src/highlight.ts b/src/highlight.ts
index b2aa203..f5582d4 100644
--- a/src/highlight.ts
+++ b/src/highlight.ts
@@ -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";
diff --git a/src/popover.ts b/src/popover.ts
index 50fa0f8..48f6746 100644
--- a/src/popover.ts
+++ b/src/popover.ts
@@ -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 = {
diff --git a/src/state.ts b/src/state.ts
index 872bc66..4faf6e5 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -14,6 +14,7 @@ export type State = {
popover?: PopoverDOM;
+ __activeOnDestroyed?: Element;
__resizeTimeout?: number;
__transitionCallback?: () => void;
__activeStagePosition?: StageDefinition;
diff --git a/src/utils.ts b/src/utils.ts
index 3649247..43a2a92 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -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);
+}