From 63b7bef5678846c10b4ba2d72c710029563c27ea Mon Sep 17 00:00:00 2001 From: serendipitous_syntax Date: Wed, 13 May 2026 01:45:04 -0400 Subject: [PATCH 1/2] Fix mobile input focus during repositioning --- shepherd.js/src/utils/floating-ui.ts | 26 ++++- .../test/unit/utils/floating-ui.spec.js | 103 ++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 shepherd.js/test/unit/utils/floating-ui.spec.js diff --git a/shepherd.js/src/utils/floating-ui.ts b/shepherd.js/src/utils/floating-ui.ts index acec7b78f..0b5ebafa7 100644 --- a/shepherd.js/src/utils/floating-ui.ts +++ b/shepherd.js/src/utils/floating-ui.ts @@ -31,6 +31,7 @@ export function setupTooltip(step: Step): ComputePositionConfig { let target = attachToOptions.element as HTMLElement; const floatingUIOptions = getFloatingUIOptions(attachToOptions, step); const shouldCenter = shouldCenterStep(attachToOptions); + let shouldFocusAfterRender = true; if (shouldCenter) { target = document.body; @@ -45,7 +46,14 @@ export function setupTooltip(step: Step): ComputePositionConfig { return; } - setPosition(target, step, floatingUIOptions, shouldCenter); + setPosition( + target, + step, + floatingUIOptions, + shouldCenter, + shouldFocusAfterRender + ); + shouldFocusAfterRender = false; }); step.target = attachToOptions.element as HTMLElement; @@ -90,11 +98,21 @@ function setPosition( target: HTMLElement, step: Step, floatingUIOptions: ComputePositionConfig, - shouldCenter: boolean + shouldCenter: boolean, + shouldFocusAfterRender: boolean ) { + const positionPromise = computePosition( + target, + step.el as HTMLElement, + floatingUIOptions + ).then(floatingUIposition(step, shouldCenter)); + + if (!shouldFocusAfterRender) { + return positionPromise; + } + return ( - computePosition(target, step.el as HTMLElement, floatingUIOptions) - .then(floatingUIposition(step, shouldCenter)) + positionPromise // Wait before forcing focus. .then( (step: Step) => diff --git a/shepherd.js/test/unit/utils/floating-ui.spec.js b/shepherd.js/test/unit/utils/floating-ui.spec.js new file mode 100644 index 000000000..044a4041d --- /dev/null +++ b/shepherd.js/test/unit/utils/floating-ui.spec.js @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const floatingUIMock = vi.hoisted(() => ({ + autoUpdate: vi.fn(), + computePosition: vi.fn(), + updateCallbacks: [] +})); + +vi.mock('@floating-ui/dom', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + autoUpdate: floatingUIMock.autoUpdate, + computePosition: floatingUIMock.computePosition + }; +}); + +import { setupTooltip } from '../../../src/utils/floating-ui'; + +describe('Floating UI Utils', function () { + let input; + let step; + let stepElement; + let target; + + beforeEach(() => { + vi.useFakeTimers(); + + floatingUIMock.updateCallbacks.length = 0; + floatingUIMock.autoUpdate.mockImplementation( + (_target, _stepElement, update) => { + floatingUIMock.updateCallbacks.push(update); + update(); + + return vi.fn(); + } + ); + floatingUIMock.computePosition.mockResolvedValue({ + middlewareData: {}, + placement: 'bottom', + x: 12, + y: 34 + }); + + target = document.createElement('div'); + input = document.createElement('input'); + target.appendChild(input); + document.body.appendChild(target); + + stepElement = document.createElement('div'); + document.body.appendChild(stepElement); + + step = { + cleanup: null, + el: stepElement, + options: { + arrow: false, + attachTo: { element: target, on: 'bottom' }, + floatingUIOptions: {} + }, + shepherdElementComponent: { + element: stepElement + }, + _getResolvedAttachToOptions() { + return this.options.attachTo; + } + }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('only focuses the step element after the first render', async () => { + const focusSpy = vi.spyOn(stepElement, 'focus'); + + setupTooltip(step); + + await flushPositioning(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + + input.focus(); + expect(document.activeElement).toBe(input); + + floatingUIMock.updateCallbacks[0](); + + await flushPositioning(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + }); +}); + +async function flushPositioning() { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); +} From 2a231690846273b3127a7c5c4d5e8ed6e173b95f Mon Sep 17 00:00:00 2001 From: serendipitous_syntax Date: Wed, 13 May 2026 14:30:48 -0400 Subject: [PATCH 2/2] fix: pin eslint-plugin-cypress for lint config --- shepherd.js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shepherd.js/package.json b/shepherd.js/package.json index 45c518d92..5660cfe86 100644 --- a/shepherd.js/package.json +++ b/shepherd.js/package.json @@ -93,7 +93,7 @@ "del": "^8.0.1", "dts-bundle-generator": "^9.5.1", "eslint": "^10.1.0", - "eslint-plugin-cypress": "^5.3.0", + "eslint-plugin-cypress": "5.3.0", "eslint-plugin-vitest": "^0.5.4", "execa": "^9.6.1", "globals": "^17.4.0",