Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/virtual-tick-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tabspot": minor
---

feat: add optional `tick` hook on the virtual adapter to await a framework's render flush
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>` 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 `<tr>` 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.
Expand Down
3 changes: 2 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
67 changes: 67 additions & 0 deletions src/tests/virtual.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ function byId(id: string): HTMLElement {
function tick(ms = 40): Promise<void> {
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 = "";
Expand Down Expand Up @@ -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 = `
<ul id="root">
<li data-index="0" tabindex="0">0</li>
<li data-index="1" tabindex="-1">1</li>
<li data-index="2" tabindex="-1">2</li>
</ul>`;
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;

Expand Down
41 changes: 38 additions & 3 deletions src/virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
scrollToIndex(index: number): void | Promise<void>;
/** 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<void>;
}

/** What a boundary-crossing navigation is trying to reach, by real index. */
Expand Down Expand Up @@ -89,15 +97,42 @@
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<void>,
): Promise<HTMLElement | null> {
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<HTMLElement | null> {
return new Promise((resolve) => {
let done = false;
const finish = (el: HTMLElement | null) => {
Expand All @@ -105,7 +140,7 @@
done = true;
obs.disconnect();
clearTimeout(timer);
resolve(el);

Check warning on line 143 in src/virtual.ts

View workflow job for this annotation

GitHub Actions / Lint

promise(no-multiple-resolved)

Promise should not be resolved multiple times. Promise is already resolved on line 142.
};
const obs = new MutationObserver(() => {
const el = rootEl.querySelector(sel);
Expand Down
Loading