From addb8f537855387f933e3903e3202af4061fe4b3 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Sat, 3 Oct 2026 10:24:01 +0100 Subject: [PATCH] Make keyboard control follow the buttons --- apps/docs/src/content/guides/changelog.mdx | 4 ++ .../docs/src/content/guides/configuration.mdx | 2 + packages/driver/src/driver.ts | 36 ++++++++++- packages/driver/src/step.ts | 10 ++- packages/driver/tests/keyboard.test.ts | 62 +++++++++++++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/apps/docs/src/content/guides/changelog.mdx b/apps/docs/src/content/guides/changelog.mdx index b37c5d3..70abe69 100644 --- a/apps/docs/src/content/guides/changelog.mdx +++ b/apps/docs/src/content/guides/changelog.mdx @@ -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`. diff --git a/apps/docs/src/content/guides/configuration.mdx b/apps/docs/src/content/guides/configuration.mdx index a10f1de..2bf70d9 100644 --- a/apps/docs/src/content/guides/configuration.mdx +++ b/apps/docs/src/content/guides/configuration.mdx @@ -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) diff --git a/packages/driver/src/driver.ts b/packages/driver/src/driver.ts index efb9665..629cba8 100644 --- a/packages/driver/src/driver.ts +++ b/packages/driver/src/driver.ts @@ -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); diff --git a/packages/driver/src/step.ts b/packages/driver/src/step.ts index 8966415..39a02fc 100644 --- a/packages/driver/src/step.ts +++ b/packages/driver/src/step.ts @@ -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), diff --git a/packages/driver/tests/keyboard.test.ts b/packages/driver/tests/keyboard.test.ts index db4cf87..2a0832c 100644 --- a/packages/driver/tests/keyboard.test.ts +++ b/packages/driver/tests/keyboard.test.ts @@ -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); + }); +});