keyboard: Tab cycles the surface's text fields, forward-only (keyboard 3 of 4)
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.
This commit is contained in:
@@ -27,6 +27,7 @@ import { useEnterSubmits } from "@/components/enter-submits";
|
|||||||
import { dockStateKey } from "@/components/dock-state-key";
|
import { dockStateKey } from "@/components/dock-state-key";
|
||||||
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
||||||
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
||||||
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
import { JobsContext } from "@/components/jobs-context";
|
import { JobsContext } from "@/components/jobs-context";
|
||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
@@ -591,6 +592,22 @@ export function Chat({
|
|||||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of
|
// Voice dictation (STT wave, 2026-09-17): finals append at the end of
|
||||||
// the current text (the page owns the separator, dictate-flush style);
|
// the current text (the page owns the separator, dictate-flush style);
|
||||||
// interims ghost in the stats bar's middle slot next to the meter.
|
// interims ghost in the stats bar's middle slot next to the meter.
|
||||||
|
// TAB CYCLES THE CHAT'S FIELDS (owner, keyboard round 2026-10-06):
|
||||||
|
// reading-thinking → the active dock's field → the composer, in
|
||||||
|
// visual order, forward-only with wrap; Tab from anywhere else
|
||||||
|
// focuses the first live field (navigate IN). Gathered from live
|
||||||
|
// DOM at event time — the docks mount and unmount, refs would go
|
||||||
|
// stale. The quiz dock's rows↔thinking claim never reaches here.
|
||||||
|
const cycleChatFields = useCallback((): readonly HTMLElement[] => {
|
||||||
|
const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel));
|
||||||
|
return [
|
||||||
|
...pick('[data-testid="reading-thinking-field"] textarea'),
|
||||||
|
...pick('[data-testid="question-dock"] textarea'),
|
||||||
|
...pick('[data-testid="practice-dock"] textarea'),
|
||||||
|
...pick('[data-testid="composer"] textarea'),
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
useFieldCycle(cycleChatFields);
|
||||||
const dictation = useDictation({
|
const dictation = useDictation({
|
||||||
// Ref-first (see textRef): the join is computed from the mirror so a
|
// Ref-first (see textRef): the join is computed from the mirror so a
|
||||||
// send resuming after `await finish()` reads the dictated tail.
|
// send resuming after `await finish()` reads the dictated tail.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"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]);
|
||||||
|
}
|
||||||
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react";
|
import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react";
|
||||||
import { GraphView } from "@/components/graph-view";
|
import { GraphView } from "@/components/graph-view";
|
||||||
@@ -8,6 +8,7 @@ import { MinimalHome } from "@/components/minimal-home";
|
|||||||
import { SetupDialog } from "@/components/setup-dialog";
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||||
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
||||||
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import type { TurnUsage } from "@/src/core";
|
import type { TurnUsage } from "@/src/core";
|
||||||
@@ -51,6 +52,12 @@ export function Home({
|
|||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
||||||
// topic draft; the strip lives in the stats bar's middle slot.
|
// topic draft; the strip lives in the stats bar's middle slot.
|
||||||
|
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
|
||||||
|
// the only text field on this surface — the cycle's whole chain.
|
||||||
|
const cycleComposer = useCallback((): readonly HTMLElement[] =>
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] textarea')),
|
||||||
|
[]);
|
||||||
|
useFieldCycle(cycleComposer);
|
||||||
const dictation = useDictation({
|
const dictation = useDictation({
|
||||||
// Ref-first: the join reads the mirror so the send resuming after
|
// Ref-first: the join reads the mirror so the send resuming after
|
||||||
// `await finish()` sees the dictated tail.
|
// `await finish()` sees the dictated tail.
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ export function KeyboardShortcuts() {
|
|||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) window.dispatchEvent(new CustomEvent("lh:kbd-cycle-thinking"));
|
if (e.shiftKey) window.dispatchEvent(new CustomEvent("lh:kbd-cycle-thinking"));
|
||||||
|
// Plain Tab cycles the active surface's text fields (owner,
|
||||||
|
// keyboard round 2026-10-06) — forward-only, wrapping; each
|
||||||
|
// composer-bearing page answers with its own chain. The quiz
|
||||||
|
// dock's rows↔thinking claim stops propagation at document
|
||||||
|
// level, so a claimed Tab never reaches this dispatch.
|
||||||
|
else if (!e.metaKey && !e.ctrlKey && !e.altKey)
|
||||||
|
window.dispatchEvent(new CustomEvent("lh:kbd-cycle-fields"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
||||||
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import { SetupDialog } from "@/components/setup-dialog";
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||||
@@ -112,6 +113,12 @@ export function DraftShell({
|
|||||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
||||||
// draft text (via the parent's onChange — the truth lives there); the
|
// draft text (via the parent's onChange — the truth lives there); the
|
||||||
// strip lives in the stats bar's middle slot.
|
// strip lives in the stats bar's middle slot.
|
||||||
|
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
|
||||||
|
// the only text field on this surface — the cycle's whole chain.
|
||||||
|
const cycleComposer = useCallback((): readonly HTMLElement[] =>
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] textarea')),
|
||||||
|
[]);
|
||||||
|
useFieldCycle(cycleComposer);
|
||||||
const dictation = useDictation({
|
const dictation = useDictation({
|
||||||
append: (t) => onChange(joinDictation(text, t)),
|
append: (t) => onChange(joinDictation(text, t)),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user