835e026336
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>
475 lines
14 KiB
TypeScript
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");
|
|
});
|