Files
vue-tui/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx
T
Yunfei He 835e026336 fix(runtime): sync raw-mode disable at teardown + same-tick swap & focus-nav Ink parity (#117)
Three small raw-mode/focus corrections, each aligned to Ink v7.0.4 and
covered by a test (TDD red→green where reachable).

1. Raw mode left ON after a synchronous signal exit (Ctrl+C). The terminal
   raw-mode disable is deferred to a microtask (so it survives same-tick
   component swaps), but on the signal-exit path teardown(true) re-raises the
   signal synchronously without draining microtasks, so the disable never ran
   and the shell stopped echoing after Ctrl+C. dispose() now forces the disable
   SYNCHRONOUSLY when raw mode is no longer owned (state.refs === 0 and either
   this dispose released the last ref or a release left pendingDisable set),
   mirroring Ink's unmount-cleanup guard `rawModeEnabledCount > 0 ||
   pendingDisableRawModeRef.current` (App.tsx:626-631). The disable stays gated
   on the SHARED refcount, so a multi-app teardown can't disable while another
   app still holds raw mode.

2. Same-tick useInput swap re-issued setRawMode(true) + stdin.ref() and leaked
   a libuv ref (the deferred disable bailed on refs>0 and never unref'd). Added
   a pendingDisable flag to RawModeState mirroring Ink's pendingDisableRawModeRef
   (App.tsx:331-344): on re-acquire while a disable is pending, skip
   ref()/setRawMode(true) and cancel the queued disable.

3. focusNext/focusPrevious start-index logic factored into a shared
   startSearchIndex() helper so the two directions stay symmetric. Behavior is
   identical for all reachable states; it additionally folds the (unreachable
   while the activeId invariant holds) "activeId not in list" case into the
   same branch instead of diverging per-direction.

Tests: raw-mode-lifecycle.test.tsx (swap no-op, replacement still receives
input, synchronous teardown disable); programmatic-focus.test.tsx (no-active
first/last targeting + active-focus step/wrap via the manager API).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:29:14 +08:00

475 lines
14 KiB
TypeScript

import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, useFocus, useFocusManager } from "@vue-tui/runtime";
test("focus(id) programmatically focuses another component", async () => {
let focusFn!: (id: string) => void;
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const { isFocused, focus } = useFocus({ id: props.id });
if (props.id === "a") focusFn = focus;
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const { lastFrame, stdin } = await render(() => (
<Box flexDirection="column">
<Item id="a" />
<Item id="b" />
<Item id="c" />
</Box>
));
// Need to send a Tab to activate focus system (raw mode)
await stdin.write("\t");
focusFn("c");
// Need to wait for Vue reactivity
const { nextTick } = await import("vue");
await nextTick();
expect(lastFrame()).toContain("▶ c");
expect(lastFrame()).not.toContain("▶ a");
});
test("isActive=false prevents component from receiving focus", async () => {
const active = shallowRef(false);
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const opts = props.id === "skip" ? { id: props.id, isActive: active } : { id: props.id };
const { isFocused } = useFocus(opts);
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const { lastFrame, stdin } = await render(() => (
<Box flexDirection="column">
<Item id="first" />
<Item id="skip" />
<Item id="last" />
</Box>
));
// Tab to first
await stdin.write("\t");
expect(lastFrame()).toContain("▶ first");
// Tab should skip "skip" and go to "last"
await stdin.write("\t");
expect(lastFrame()).toContain("▶ last");
expect(lastFrame()).not.toContain("▶ skip");
});
test("autoFocus + isActive=false does not focus at mount", async () => {
const active = shallowRef(false);
const App = defineComponent(() => {
const { isFocused } = useFocus({ id: "item", autoFocus: true, isActive: active });
return () => <Text>{isFocused.value ? "focused" : "unfocused"}</Text>;
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("unfocused");
active.value = true;
await nextTick();
// Becoming active doesn't auto-focus retroactively
expect(lastFrame()).toContain("unfocused");
});
test("flipping isActive to false on focused item blurs it", async () => {
const active = shallowRef(true);
const App = defineComponent(() => {
const { isFocused } = useFocus({ id: "item", autoFocus: true, isActive: active });
return () => <Text>{isFocused.value ? "focused" : "unfocused"}</Text>;
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("focused");
active.value = false;
await nextTick();
expect(lastFrame()).toContain("unfocused");
});
// ---------------------------------------------------------------------------
// Ink-ported: programmatic focus, unregister, focusNext/focusPrevious
// ---------------------------------------------------------------------------
const FocusItem = defineComponent({
props: {
label: { type: String, required: true as const },
autoFocus: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
},
setup(props) {
const { isFocused } = useFocus({
autoFocus: props.autoFocus,
isActive: () => !props.disabled,
});
return () => (
<Text>
{props.label}
{isFocused.value ? " ✔" : ""}
</Text>
);
},
});
test("reset focus when focused component unregisters", async () => {
const showFirst = shallowRef(true);
const App = defineComponent(() => {
return () => (
<Box flexDirection="column">
{showFirst.value ? <FocusItem label="First" autoFocus /> : null}
<FocusItem label="Second" autoFocus />
<FocusItem label="Third" autoFocus />
</Box>
);
});
const { lastFrame } = await render(App);
expect(lastFrame()).toMatch(/First ✔/);
showFirst.value = false;
await nextTick();
expect(lastFrame()).not.toMatch(/✔/);
});
test("focus first component after focused component unregisters", async () => {
const showFirst = shallowRef(true);
const App = defineComponent(() => {
return () => (
<Box flexDirection="column">
{showFirst.value ? <FocusItem label="First" autoFocus /> : null}
<FocusItem label="Second" autoFocus />
<FocusItem label="Third" autoFocus />
</Box>
);
});
const { lastFrame, stdin } = await render(App);
expect(lastFrame()).toMatch(/First ✔/);
showFirst.value = false;
await nextTick();
expect(lastFrame()).not.toMatch(/✔/);
await stdin.write("\t");
expect(lastFrame()).toMatch(/Second ✔/);
});
test("manually focus next component via focusNext()", async () => {
let doFocusNext!: () => void;
const App = defineComponent(() => {
const manager = useFocusManager();
doFocusNext = manager.focusNext;
return () => (
<Box flexDirection="column">
<FocusItem label="First" autoFocus />
<FocusItem label="Second" autoFocus />
<FocusItem label="Third" autoFocus />
</Box>
);
});
const { lastFrame } = await render(App);
expect(lastFrame()).toMatch(/First ✔/);
doFocusNext();
await nextTick();
expect(lastFrame()).toMatch(/Second ✔/);
});
test("manually focus previous component via focusPrevious()", async () => {
let doFocusPrevious!: () => void;
const App = defineComponent(() => {
const manager = useFocusManager();
doFocusPrevious = manager.focusPrevious;
return () => (
<Box flexDirection="column">
<FocusItem label="First" autoFocus />
<FocusItem label="Second" autoFocus />
<FocusItem label="Third" autoFocus />
</Box>
);
});
const { lastFrame } = await render(App);
expect(lastFrame()).toMatch(/First ✔/);
doFocusPrevious();
await nextTick();
expect(lastFrame()).toMatch(/Third ✔/);
});
// Ink parity (App.tsx:455-470 / 472-487): with NO focus yet (activeId null),
// focusNext targets the FIRST focusable and focusPrevious targets the LAST. This
// pins the reachable "no current focus" boundary for BOTH directions so the
// shared start-index logic (which folds the null case and the unreachable
// activeId-not-in-list case into one symmetric branch) cannot regress one
// direction without the other.
test("focusNext() with no active focus targets the first focusable", async () => {
let doFocusNext!: () => void;
const App = defineComponent(() => {
const manager = useFocusManager();
doFocusNext = manager.focusNext;
return () => (
<Box flexDirection="column">
<FocusItem label="First" />
<FocusItem label="Second" />
<FocusItem label="Third" />
</Box>
);
});
const { lastFrame } = await render(App);
// No autoFocus anywhere → nothing is focused at mount.
expect(lastFrame()).not.toMatch(/✔/);
// From "no current focus", focusNext lands on the first focusable.
doFocusNext();
await nextTick();
expect(lastFrame()).toMatch(/First ✔/);
expect(lastFrame()).not.toMatch(/Second ✔|Third ✔/);
});
test("focusPrevious() with no active focus targets the last focusable", async () => {
let doFocusPrevious!: () => void;
const App = defineComponent(() => {
const manager = useFocusManager();
doFocusPrevious = manager.focusPrevious;
return () => (
<Box flexDirection="column">
<FocusItem label="First" />
<FocusItem label="Second" />
<FocusItem label="Third" />
</Box>
);
});
const { lastFrame } = await render(App);
expect(lastFrame()).not.toMatch(/✔/);
// From "no current focus", a backward step wraps to the LAST focusable.
doFocusPrevious();
await nextTick();
expect(lastFrame()).toMatch(/Third ✔/);
expect(lastFrame()).not.toMatch(/First ✔|Second ✔/);
});
// Exercise startSearchIndex's VALID-index path (activeId non-null) via the
// manager API directly: focusPrevious from a middle item steps to its
// predecessor, and from the first item wraps to the last; focusNext from the
// last wraps to the first. (Tab-cycling covers the same code path through the
// keypress handler; this pins it through the programmatic API so a regression in
// the shared start-index helper can't slip through either entry point.)
test("focusPrevious()/focusNext() with an active focus step to the adjacent item and wrap", async () => {
let manager!: ReturnType<typeof useFocusManager>;
const App = defineComponent(() => {
manager = useFocusManager();
return () => (
<Box flexDirection="column">
<FocusItem label="First" autoFocus />
<FocusItem label="Second" />
<FocusItem label="Third" />
</Box>
);
});
const { lastFrame } = await render(App);
// First autoFocuses → activeId is the first item.
expect(lastFrame()).toMatch(/First ✔/);
// focusNext from the first → second.
manager.focusNext();
await nextTick();
expect(lastFrame()).toMatch(/Second ✔/);
// focusPrevious from the middle → back to the first (predecessor, not a wrap).
manager.focusPrevious();
await nextTick();
expect(lastFrame()).toMatch(/First ✔/);
// focusPrevious from the first → wraps to the last.
manager.focusPrevious();
await nextTick();
expect(lastFrame()).toMatch(/Third ✔/);
// focusNext from the last → wraps back to the first.
manager.focusNext();
await nextTick();
expect(lastFrame()).toMatch(/First ✔/);
});
// Ink parity (App.tsx:455-470 focusNext / 472-487 focusPrevious): focusNext is
// `findNextFocusable(...) ?? firstFocusableId` and ALWAYS reassigns activeFocusId.
// When NO focusable is active, both branches are undefined → activeFocusId is
// cleared. focus(id) moves activeId onto an item regardless of its isActive, so an
// activeId can point at an isActive=false item; a subsequent focusNext() must clear
// that stale id rather than leave it pinned. (vue's sentinel for "none" is null.)
test("focusNext() clears a stale activeId when no focusable is active (Ink parity)", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
let focusFn!: (id: string) => void;
let doFocusNext!: () => void;
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
// All items inactive — none can be reached by Tab/focusNext.
const { isFocused } = useFocus({ id: props.id, isActive: false });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
focusFn = manager.focus;
doFocusNext = manager.focusNext;
return () => (
<Box flexDirection="column">
<Item id="a" />
<Item id="b" />
<Item id="c" />
</Box>
);
});
await render(App);
// focus(id) ignores isActive and pins activeId onto 'b'.
focusFn("b");
await nextTick();
expect(activeId.value).toBe("b");
// No focusable is active, so focusNext() must clear the stale activeId.
doFocusNext();
await nextTick();
expect(activeId.value).toBe(null);
});
test("focusPrevious() clears a stale activeId when no focusable is active (Ink parity)", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
let focusFn!: (id: string) => void;
let doFocusPrevious!: () => void;
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const { isFocused } = useFocus({ id: props.id, isActive: false });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
focusFn = manager.focus;
doFocusPrevious = manager.focusPrevious;
return () => (
<Box flexDirection="column">
<Item id="a" />
<Item id="b" />
<Item id="c" />
</Box>
);
});
await render(App);
focusFn("b");
await nextTick();
expect(activeId.value).toBe("b");
doFocusPrevious();
await nextTick();
expect(activeId.value).toBe(null);
});
// Ink parity (use-focus.ts: id via useMemo([customId]), add/remove effect keyed on
// [id]): changing the id prop must re-register the component under the new id.
// Focus is driven purely by focus(id) here (no Tab) so the assertions reflect
// id-addressed focus, not position-based Tab cycling.
test("useFocus reacts to id prop changes (Ink parity)", async () => {
const dynId = shallowRef("alpha");
let focusFn!: (id: string) => void;
const Other = defineComponent(() => {
const { isFocused } = useFocus({ id: "other", autoFocus: true });
return () => <Text>O:{isFocused.value ? "1" : "0"}</Text>;
});
const Dynamic = defineComponent(() => {
const { isFocused, focus } = useFocus({ id: () => dynId.value });
focusFn = focus;
return () => <Text>D:{isFocused.value ? "1" : "0"}</Text>;
});
const { lastFrame } = await render(() => (
<Box flexDirection="column">
<Other />
<Dynamic />
</Box>
));
// Other autoFocuses at mount; Dynamic is not focused.
expect(lastFrame()).toContain("O:1");
expect(lastFrame()).toContain("D:0");
// Focus Dynamic by its current id.
focusFn("alpha");
await nextTick();
expect(lastFrame()).toContain("D:1");
// Change the id → Dynamic must re-register under "beta".
dynId.value = "beta";
await nextTick();
// Focusing by the NEW id focuses it.
focusFn("beta");
await nextTick();
expect(lastFrame()).toContain("D:1");
// Move focus away by a known id, then the OLD id must NOT refocus Dynamic.
focusFn("other");
await nextTick();
expect(lastFrame()).toContain("D:0");
focusFn("alpha");
await nextTick();
expect(lastFrame()).toContain("D:0");
});