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