Skip to content

5 · Keyed list with focus survival

The list reconciler matches rows by data-key (or id), preserves the matched DOM node, and only inserts/removes/moves what changed. The user-visible payoff: an <input> inside a row keeps its focus, value, and caret position even when the list reorders around it.

What to look at: click into one of the row inputs and start typing — the list reshuffles every 1.5 seconds in the background, but your focus and your text survive each pass. That’s the morph identifying the same data-key and reusing the live node.

Why a timer? Because clicking a Shuffle button would move focus to the button before reconciliation runs, and there’s no longer a focused input to preserve. The expanded section in the demo has manual Insert / Shuffle now buttons so you can see that contrast.

src/main.tsx
import { signal, mount, each, delegate } from 'kerfjs';
interface Row { id: string; label: string }
const rows = signal<Row[]>([
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Bravo' },
{ id: 'c', label: 'Charlie' },
]);
const ticking = signal(true);
let nextId = 1;
let timer: ReturnType<typeof setInterval> | undefined;
function shuffle(): void {
rows.value = [...rows.value].sort(() => Math.random() - 0.5);
}
function startTicking(): void {
if (timer !== undefined) return;
timer = setInterval(shuffle, 1500);
}
function stopTicking(): void {
if (timer === undefined) return;
clearInterval(timer);
timer = undefined;
}
if (ticking.value) startTicking();
const root = document.getElementById('app')!;
mount(root, () => (
<div class="kerf-stack" style="max-width: 26rem;">
<p class="kerf-helper-text">
Type into a row's input. The list reshuffles every 1.5 s — your focus, your text, and your caret all survive.
</p>
<div class="kerf-toolbar">
<button data-action="toggle-tick">
{ticking.value ? 'Pause auto-shuffle' : 'Resume auto-shuffle'}
</button>
</div>
<ul style="list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.4rem;">
{each(
rows.value,
(r) => (
<li data-key={r.id} style="display: flex; align-items: center; gap: 0.5rem;">
<input value={r.label} data-id={r.id} style="flex: 1;" />
<button data-action="delete" data-id={r.id} aria-label={`Delete ${r.label}`}>×</button>
</li>
),
(r) => r.id,
)}
</ul>
<details data-morph-skip class="kerf-helper-text">
<summary style="cursor: pointer;">Manual triggers (these will steal focus when clicked)</summary>
<div class="kerf-toolbar" style="margin-top: 0.5rem;">
<button data-action="insert">Insert at top</button>
<button data-action="shuffle-now">Shuffle now</button>
</div>
<p style="margin-top: 0.5rem;">
Clicks transfer focus to the button itself, so by the time the list reconciles there's no focused input to preserve. The auto-shuffle above is what actually demos focus survival.
</p>
</details>
</div>
));
delegate(root, 'click', '[data-action="toggle-tick"]', () => {
ticking.value = !ticking.value;
if (ticking.value) startTicking(); else stopTicking();
});
delegate(root, 'click', '[data-action="insert"]', () => {
rows.value = [{ id: `new-${nextId++}`, label: `Row ${nextId}` }, ...rows.value];
});
delegate(root, 'click', '[data-action="shuffle-now"]', shuffle);
delegate(root, 'click', '[data-action="delete"]', (_, btn) => {
const id = (btn as HTMLElement).dataset.id!;
rows.value = rows.value.filter((r) => r.id !== id);
});