diff --git a/.changeset/virtual-tick-hook.md b/.changeset/virtual-tick-hook.md new file mode 100644 index 0000000..47aa906 --- /dev/null +++ b/.changeset/virtual-tick-hook.md @@ -0,0 +1,5 @@ +--- +"tabspot": minor +--- + +feat: add optional `tick` hook on the virtual adapter to await a framework's render flush diff --git a/README.md b/README.md index d55c5d7..e2e6003 100644 --- a/README.md +++ b/README.md @@ -220,12 +220,14 @@ import { tabspotVirtual } from "tabspot"; const detach = tabspotVirtual(listEl, { scrollToIndex: (index) => rowVirtualizer.scrollToIndex(index), // sync or async count: () => total, // or omit and use aria-setsize/aria-rowcount + tick: () => nextTick(), // optional: Vue nextTick / Svelte tick / etc. }); // cleanup: detach() ``` - Each rendered item carries its real index via `data-index` (preferred) or `aria-posinset` / `aria-rowindex`; the total via `aria-setsize` / `aria-rowcount` or `adapter.count()`. - When an arrow clamps at the rendered edge, Tabspot calls `scrollToIndex(target)`, waits for that index to render, then activates it (with coalescing for held keys). +- By default the render wait uses a `MutationObserver` bounded by a ~1s timeout. Provide `tick` (a `() => Promise` such as Vue's `nextTick` or Svelte's `tick`) and Tabspot awaits your framework's render flush instead — no timeout in the happy path. If the row still isn't rendered when `tick` resolves, it falls back to the observer. - For grids, rows are virtualized by `data-index` on the `` and the column is preserved via `data-colindex` on cells. - Use `activation: "activedescendant"` or `"controlled"` for virtual lists — `"focus"` is fragile because a focused row can unmount on scroll. - `tabspotVirtual` is keyed by element (no instance argument) and tree-shakeable. diff --git a/src/core.ts b/src/core.ts index 78c0ab2..545c42b 100644 --- a/src/core.ts +++ b/src/core.ts @@ -195,7 +195,8 @@ export function tabspot(options: TabspotOptions = {}): TabspotInstance { return; } if (pendingVirtual.get(rootEl) !== idx) return; // superseded - const rendered = await waitForRendered(rootEl, idx, 1000); + // Bind `tick` to correctly handle any `this` inside the adapter implementation + const rendered = await waitForRendered(rootEl, idx, 1000, adapter.tick?.bind(adapter)); if (!rendered || pendingVirtual.get(rootEl) !== idx) return; pendingVirtual.delete(rootEl); diff --git a/src/tests/virtual.test.ts b/src/tests/virtual.test.ts index f6b0795..4b83b5b 100644 --- a/src/tests/virtual.test.ts +++ b/src/tests/virtual.test.ts @@ -12,6 +12,14 @@ function byId(id: string): HTMLElement { function tick(ms = 40): Promise { return new Promise((r) => setTimeout(r, ms)); } +function renderRow(root: HTMLElement, i: number): void { + if (root.querySelector(`[data-index="${i}"]`)) return; + const li = document.createElement("li"); + li.setAttribute("data-index", String(i)); + li.setAttribute("tabindex", "-1"); + li.textContent = String(i); + root.appendChild(li); +} beforeEach(() => { document.body.innerHTML = ""; @@ -134,6 +142,65 @@ describe("virtualization: linear list with cyclic", () => { }); }); +describe("virtualization: tick (framework render-flush hook)", () => { + const TOTAL = 100; + + function baseMarkup(): HTMLElement { + document.body.innerHTML = ` + `; + return byId("root"); + } + + it("renders via the awaited tick (scrollToIndex defers, tick flushes)", async () => { + const root = baseMarkup(); + // scrollToIndex only records intent; the row appears solely because tick() + // flushes it. If Tabspot didn't await tick, the node would never be found. + let pending: number | null = null; + const adapter: VirtualAdapter = { + count: () => TOTAL, + scrollToIndex: (i) => { + pending = i; + }, + tick: () => { + if (pending !== null) renderRow(root, pending); + pending = null; + return Promise.resolve(); + }, + }; + instance = tabspot(); + setTabspotAttributes({ element: root, config: { root: {}, mover: { axis: "vertical" } } }); + detach = tabspotVirtual(root, adapter); + + press(root.querySelector('[data-index="2"]') as HTMLElement, "ArrowDown"); + await tick(); + expect((document.activeElement as HTMLElement).getAttribute("data-index")).toBe("3"); + }); + + it("falls back to the observer when tick resolves before the row renders", async () => { + const root = baseMarkup(); + // A scroll-event-driven virtualizer: the row lands a tick later than the + // flush hook resolves, so the MutationObserver safety net must catch it. + const adapter: VirtualAdapter = { + count: () => TOTAL, + scrollToIndex: (i) => { + setTimeout(() => renderRow(root, i), 5); + }, + tick: () => Promise.resolve(), + }; + instance = tabspot(); + setTabspotAttributes({ element: root, config: { root: {}, mover: { axis: "vertical" } } }); + detach = tabspotVirtual(root, adapter); + + press(root.querySelector('[data-index="2"]') as HTMLElement, "ArrowDown"); + await tick(); + expect((document.activeElement as HTMLElement).getAttribute("data-index")).toBe("3"); + }); +}); + describe("virtualization: dataTable (rows virtualized, column preserved)", () => { const TOTAL = 100; diff --git a/src/virtual.ts b/src/virtual.ts index 5a591e2..ee2b302 100644 --- a/src/virtual.ts +++ b/src/virtual.ts @@ -15,6 +15,14 @@ export interface VirtualAdapter { scrollToIndex(index: number): void | Promise; /** Total real item count. Falls back to aria-setsize/aria-rowcount. */ count?(): number; + /** + * Resolves once the framework has flushed pending DOM updates + * (Vue `nextTick`, Svelte `tick`, Angular `afterNextRender`, …). When + * provided, Tabspot awaits it after `scrollToIndex` instead of polling for + * the row to appear. If the row still isn't rendered once it resolves, + * Tabspot falls back to the MutationObserver/timeout wait. + */ + tick?(): Promise; } /** What a boundary-crossing navigation is trying to reach, by real index. */ @@ -89,15 +97,42 @@ export function findVirtualTarget(rootEl: HTMLElement, target: VirtualTarget): H return cell instanceof HTMLElement ? cell : null; } -/** Resolve once `[data-index=dataIndex]` exists under `rootEl` (with timeout). */ +/** + * Resolve once `[data-index=dataIndex]` exists under `rootEl`. + * + * When `tick` is given, await the framework's render flush and check once; if + * the row is there we're done with no timer. Otherwise (or if `tick` left it + * unrendered) fall back to a MutationObserver bounded by `timeoutMs`. + */ export function waitForRendered( rootEl: HTMLElement, dataIndex: number, timeoutMs: number, + tick?: () => Promise, ): Promise { const sel = `[data-index="${dataIndex}"]`; - const present = rootEl.querySelector(sel); - if (present instanceof HTMLElement) return Promise.resolve(present); + const find = (): HTMLElement | null => { + const el = rootEl.querySelector(sel); + return el instanceof HTMLElement ? el : null; + }; + const present = find(); + if (present) return Promise.resolve(present); + if (tick) { + return tick().then(() => { + // tick resolved but the row isn't here yet (e.g. a scroll-event-driven + // virtualizer recomputes a frame later): fall back to the observer. + return find() ?? observeForRendered(rootEl, sel, timeoutMs); + }); + } + return observeForRendered(rootEl, sel, timeoutMs); +} + +/** Resolve once `sel` matches under `rootEl` via MutationObserver, else null after `timeoutMs`. */ +function observeForRendered( + rootEl: HTMLElement, + sel: string, + timeoutMs: number, +): Promise { return new Promise((resolve) => { let done = false; const finish = (el: HTMLElement | null) => {