Make keyboard control follow the buttons

This commit is contained in:
Kamran Ahmed
2026-10-03 10:24:01 +01:00
parent 15cd79ebe9
commit addb8f5378
5 changed files with 109 additions and 5 deletions
@@ -18,6 +18,10 @@ ships, these entries move under a versioned heading.
### Changed
- Keyboard control follows the popover buttons: arrow keys do nothing when
their button is hidden or disabled, and Escape does nothing when the close
button is disabled.
([#589](https://github.com/nilbuild/driver.js/issues/589))
- Only one instance is on screen at a time. Starting a tour or highlight closes
any other active instance; it gets `onDeselected` and `onDestroyed` but not
`onDestroyStarted`.
@@ -59,6 +59,8 @@ type Config = {
stageRadius?: number;
// Whether to allow keyboard navigation. (default: true)
// Arrow keys are ignored when their button is hidden or disabled,
// and Escape is ignored when the close button is disabled.
allowKeyboardControl?: boolean;
// Whether to disable interaction with the highlighted element. (default: false)
+33 -3
View File
@@ -1,9 +1,16 @@
import { destroyPopover, Popover } from "./popover";
import { AllowedButtons, destroyPopover, Popover } from "./popover";
import { destroyOverlay } from "./overlay";
import { destroyEvents, initEvents, requireRefresh } from "./events";
import { Config, createContext, DriverHook } from "./context";
import { destroyHighlight, highlight } from "./highlight";
import { findReachableIndex, resolveNextHook, resolvePrevHook, resolveTourStep, shouldSkipStep } from "./step";
import {
findReachableIndex,
resolveNextHook,
resolvePrevHook,
resolveStepButtons,
resolveTourStep,
shouldSkipStep,
} from "./step";
import { resolveElement } from "./utils";
import "./driver.css";
@@ -64,6 +71,21 @@ export function driver(options: Config = {}): Driver {
destroy();
}
function handleEscape() {
const { disableButtons } = resolveStepButtons(ctx, ctx.getState("__activeStep"));
if (disableButtons.includes("close")) {
return;
}
handleClose();
}
function canUseButton(button: AllowedButtons) {
const { showButtons, disableButtons } = resolveStepButtons(ctx, ctx.getState("__activeStep"));
return showButtons.includes(button) && !disableButtons.includes(button);
}
function handleOverlayClick() {
const overlayClickBehavior = ctx.getConfig("overlayClickBehavior");
@@ -179,6 +201,10 @@ export function driver(options: Config = {}): Driver {
return;
}
if (!canUseButton("previous")) {
return;
}
const onPrevClick = resolvePrevHook(ctx, activeStep);
if (onPrevClick) {
return onPrevClick(activeElement, activeStep, ctx.getHookOpts());
@@ -200,6 +226,10 @@ export function driver(options: Config = {}): Driver {
return;
}
if (!canUseButton("next")) {
return;
}
const onNextClick = resolveNextHook(ctx, activeStep);
if (onNextClick) {
return onNextClick(activeElement, activeStep, ctx.getHookOpts());
@@ -229,7 +259,7 @@ export function driver(options: Config = {}): Driver {
ctx.listen("overlayClick", handleOverlayClick);
ctx.listen("activeElementClick", handleActiveElementClick);
ctx.listen("escapePress", handleClose);
ctx.listen("escapePress", handleEscape);
ctx.listen("closeClick", handleClose);
ctx.listen("arrowLeftPress", handleArrowLeft);
ctx.listen("arrowRightPress", handleArrowRight);
+8 -2
View File
@@ -114,6 +114,13 @@ export function resolveTourStep(ctx: Context, stepIndex: number, defaults: TourS
};
}
export function resolveStepButtons(ctx: Context, step?: DriveStep) {
return {
showButtons: step?.popover?.showButtons || ctx.getConfig("showButtons") || [],
disableButtons: step?.popover?.disableButtons || ctx.getConfig("disableButtons") || [],
};
}
function resolveStepPosition(ctx: Context, element: Element, step: DriveStep): PositionOptions {
const stagePadding = ctx.getConfig("stagePadding") || 0;
@@ -140,8 +147,7 @@ function resolveStepPopover(ctx: Context, element: Element, step: DriveStep): Po
title: popover.title,
description: popover.description,
showButtons: popover.showButtons || ctx.getConfig("showButtons")!,
disableButtons: popover.disableButtons || ctx.getConfig("disableButtons")! || [],
...resolveStepButtons(ctx, step),
showProgress: popover.showProgress || ctx.getConfig("showProgress") || false,
progressText: popover.progressText ?? (ctx.getConfig("progressText") || DEFAULT_PROGRESS_TEXT),
+62
View File
@@ -106,3 +106,65 @@ describe("keyboard control", () => {
expect(d.getActiveIndex()).toBe(0);
});
});
describe("keyboard control follows the buttons", () => {
it("ignores ArrowRight when the next button is hidden", async () => {
const d = createDriver({ animate: false, showButtons: ["previous", "close"], steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
pressKey("ArrowRight");
await nextFrame();
expect(d.getActiveIndex()).toBe(0);
});
it("ignores ArrowLeft when the previous button is hidden", async () => {
const d = createDriver({ animate: false, showButtons: ["next", "close"], steps: SAMPLE_STEPS });
d.drive(1);
await nextFrame();
pressKey("ArrowLeft");
await nextFrame();
expect(d.getActiveIndex()).toBe(1);
});
it("ignores ArrowRight when the next button is disabled", async () => {
const d = createDriver({
animate: false,
steps: [
{ element: "#intro", popover: { title: "Step 1", disableButtons: ["next"] } },
{ element: "#card-1", popover: { title: "Step 2" } },
],
});
d.drive();
await nextFrame();
pressKey("ArrowRight");
await nextFrame();
expect(d.getActiveIndex()).toBe(0);
});
it("ignores Escape when the close button is disabled", () => {
const d = createDriver({ animate: false, disableButtons: ["close"], steps: SAMPLE_STEPS });
d.drive();
pressKey("Escape");
expect(d.isActive()).toBe(true);
});
it("still closes with Escape when the close button is only hidden", () => {
const d = createDriver({ animate: false, showButtons: ["next", "previous"], steps: SAMPLE_STEPS });
d.drive();
pressKey("Escape");
expect(d.isActive()).toBe(false);
});
it("closes a highlight with Escape", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
pressKey("Escape");
expect(d.isActive()).toBe(false);
});
});