diff --git a/docs/rest/shared/_optimisticTransform.mdx b/docs/rest/shared/_optimisticTransform.mdx index 0d0dfa930a50..3b6f5e44002e 100644 --- a/docs/rest/shared/_optimisticTransform.mdx +++ b/docs/rest/shared/_optimisticTransform.mdx @@ -4,15 +4,16 @@ import {RestEndpoint} from '@data-client/rest'; export const raceDemo = [ { delay: 800, store: 'open' }, { delay: 1000, click: 'button.upvote' }, - { delay: 75, click: 'button.upvote' }, - { delay: 75, click: 'button.upvote' }, - { delay: 75, click: 'button.upvote' }, + ...Array.from({ length: 4 }, () => ({ + delay: 120, + click: 'button.upvote', + })), // past the slowest increment response (the fixture's 5s), so the // timeline holds every response { wait: 5500 }, { click: { in: 'store', selector: 'button[aria-label="Diff"]' } }, // the first action (the count's load), then on through each change, time - // to read each diff: 5 = the changes the 4 clicks make, as Diff's "Next + // to read each diff: 6 = the changes the 5 clicks make, as Diff's "Next // change" counts them (more would idle at the end before the close) { click: { @@ -20,7 +21,7 @@ export const raceDemo = [ selector: '[role="group"][aria-label^="Scrubber"] button[data-id]', }, }, - ...Array.from({ length: 5 }, () => ({ + ...Array.from({ length: 6 }, () => ({ delay: 1500, click: { in: 'store', selector: 'button[aria-label="Next change"]' }, })), diff --git a/website/src/components/Playground/preview/__tests__/autorun.test.ts b/website/src/components/Playground/preview/__tests__/autorun.test.ts index 54a51512f806..aa73b6d65ac0 100644 --- a/website/src/components/Playground/preview/__tests__/autorun.test.ts +++ b/website/src/components/Playground/preview/__tests__/autorun.test.ts @@ -22,7 +22,10 @@ function fakeStage( const calls: string[] = []; const stage: Stage = { find: target => (target === STORE_TOGGLE ? toggle : found), - point: el => void calls.push(el === toggle ? 'point toggle' : 'point'), + point: el => { + calls.push(el === toggle ? 'point toggle' : 'point'); + return true; + }, click: () => void calls.push('click'), append: (_, text) => void calls.push(`append ${text}`), store: (open, { pressed, view } = {}) => @@ -59,6 +62,19 @@ it('plays steps in order, at their times', async () => { expect(player.getState()).toBe('done'); }); +it("clicks again without a glide when the pointer hasn't moved", async () => { + const player = createPlayer(); + const { stage, calls } = fakeStage(); + stage.point = () => { + calls.push('point'); + return false; + }; + const done = player.play([{ delay: 20, click: 'button' }], stage); + await jest.advanceTimersByTimeAsync(20); + await done; + expect(calls).toEqual(['point', 'click', 'end']); +}); + it('pausing keeps the time left', async () => { const player = createPlayer(); const { stage, calls } = fakeStage(); @@ -211,6 +227,25 @@ describe('stage', () => { } }); + it('says whether the pointer moved', () => { + const root = playground(); + const stage = createStage(root, () => {}); + const [one, two] = root.querySelectorAll('button'); + one.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 20, height: 20 }) as DOMRect; + two.getBoundingClientRect = () => + ({ left: 40, top: 0, width: 20, height: 20 }) as DOMRect; + expect(stage.point(one)).toBe(true); + expect(stage.point(one)).toBe(false); + expect(stage.point(two)).toBe(true); + // the page scrolled under a target that stays put (a sticky Store): + // the layer moved, so the pointer must glide back onto it + const layer = root.lastElementChild as HTMLElement; + layer.getBoundingClientRect = () => + ({ left: 0, top: -50, width: 0, height: 0 }) as DOMRect; + expect(stage.point(two)).toBe(true); + }); + it('the pointer follows its target when the playground resizes', () => { let resized: () => void = () => {}; (globalThis as any).ResizeObserver = class { diff --git a/website/src/components/Playground/preview/autorun/player.ts b/website/src/components/Playground/preview/autorun/player.ts index 6e43fde27479..3e9e2c00247a 100644 --- a/website/src/components/Playground/preview/autorun/player.ts +++ b/website/src/components/Playground/preview/autorun/player.ts @@ -5,8 +5,9 @@ import type { StoreView } from '../store/storeView'; /** What a script acts on: the playground's DOM (./stage.ts) */ export interface Stage { find(target: Target): HTMLElement | null; - /** Brings `el` into view and moves the pointer onto it */ - point(el: HTMLElement): void; + /** Brings `el` into view and moves the pointer onto it; false when it + * was there already (a repeated click), so there's no glide to wait out */ + point(el: HTMLElement): boolean; click(el: HTMLElement): void; append(el: HTMLElement, text: string): void; /** Opens or closes the Store, in `view` if given; `pressed`: the pointer @@ -95,10 +96,7 @@ async function perform( // onto the Store's toggle first, as a reader would; where the layout // shows one const toggle = stage.find(STORE_TOGGLE); - if (toggle) { - stage.point(toggle); - await sleep(MOVE_MS); - } + if (toggle && stage.point(toggle)) await sleep(MOVE_MS); return stage.store(step.store === 'open', { pressed: !!toggle, view: step.view, @@ -115,8 +113,7 @@ async function perform( throw new Error(`nothing matches ${JSON.stringify(target)}`); }; const target = 'click' in step ? step.click : step.type; - stage.point(await find(target)); - await sleep(MOVE_MS); + if (stage.point(await find(target))) await sleep(MOVE_MS); if ('click' in step) return stage.click(await find(target)); for (const char of step.text) { stage.append(await find(target), char); diff --git a/website/src/components/Playground/preview/autorun/stage.ts b/website/src/components/Playground/preview/autorun/stage.ts index c831cfc53b61..fcc409b3d3a8 100644 --- a/website/src/components/Playground/preview/autorun/stage.ts +++ b/website/src/components/Playground/preview/autorun/stage.ts @@ -19,7 +19,9 @@ export function createStage(root: HTMLElement, showStore: ShowStore): Stage { let at = { x: 0, y: 0 }; let pointedAt: HTMLElement | undefined; - /** Moves the pointer onto `el`, the tip at its center */ + /** Moves the pointer onto `el`, the tip at its center; false when it was + * drawn there already (in the layer's coordinates, which scroll with the + * page) */ const place = (el: HTMLElement) => { const box = el.getBoundingClientRect(); const origin = layer.getBoundingClientRect(); @@ -34,12 +36,14 @@ export function createStage(root: HTMLElement, showStore: ShowStore): Stage { pointer.innerHTML = POINTER_SVG; layer.append(pointer); } + const previous = pointer.style.transform; pointer.style.transform = `translate(${at.x - origin.left}px, ${at.y - origin.top}px)`; // the first point fades in where it is, then glides from there if (first) { void pointer.offsetWidth; pointer.dataset.shown = ''; } + return first || pointer.style.transform !== previous; }; /** A press: a ring spreads from the tip (none without Web Animations, * e.g. jsdom) */ @@ -87,7 +91,7 @@ export function createStage(root: HTMLElement, showStore: ShowStore): Stage { revealIn(root, el); revealOnPage(el); pointedAt = el; - place(el); + return place(el); }, click(el) {