Plain Tab now navigates instead of being pure dead weight: the global handler dispatches lh:kbd-cycle-fields and each composer-bearing surface answers with its own chain, gathered from live DOM at event time (the docks mount and unmount — refs would go stale): - chat: reading-thinking → the active dock's field → the composer, visual order, wrap; Tab from anywhere else focuses the first live field (navigate in) - new-session + home: the composer is the whole chain — Tab navigates into it Shift+Tab is untouched (the thinking-level cycle keeps it — owner ruling); the quiz dock's rows↔thinking Tab claim stops propagation at document level, so a claimed Tab never reaches the dispatch — no double act. 1427 tests, tsc, lint, build green.
37 lines
1.5 KiB
TypeScript
37 lines
1.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
|
|
/**
|
|
* TAB CYCLES THE ACTIVE SURFACE'S TEXT FIELDS (owner, keyboard round
|
|
* 2026-10-06): forward-only, wrapping; Tab from outside the chain
|
|
* focuses the FIRST field — "navigate in". Shift+Tab is NOT ours (the
|
|
* global thinking-level cycle owns it — owner ruling the same round),
|
|
* so there is no reverse gear; the chain wraps.
|
|
*
|
|
* The event (not a prop bridge): the global Tab handler dispatches
|
|
* `lh:kbd-cycle-fields` and each composer-bearing surface answers with
|
|
* its own chain, gathered at event time from live DOM — the fields
|
|
* mount/unmount with the docks, so a query beats stale refs. The quiz
|
|
* dock's own Tab claim (rows ↔ thinking, while an option question is
|
|
* expanded) stops propagation at the document level, so this hook only
|
|
* ever hears Tab on surfaces WITHOUT an active claim — no double act.
|
|
*/
|
|
export function useFieldCycle(fields: () => readonly HTMLElement[]) {
|
|
useEffect(() => {
|
|
const onCycle = () => {
|
|
const list = fields().filter((el) => el.isConnected);
|
|
if (list.length === 0) return;
|
|
const active = document.activeElement;
|
|
const idx = active ? list.findIndex((el) => el === active || el.contains(active)) : -1;
|
|
if (idx < 0) {
|
|
list[0]!.focus();
|
|
return;
|
|
}
|
|
list[(idx + 1) % list.length]!.focus();
|
|
};
|
|
window.addEventListener("lh:kbd-cycle-fields", onCycle);
|
|
return () => window.removeEventListener("lh:kbd-cycle-fields", onCycle);
|
|
}, [fields]);
|
|
}
|