288372dc78
In Ink (App.tsx v7.0.4, 40b3a75) the isFocusEnabled guard lives ONLY in handleTabNavigation, not in focusNext/focusPrevious. So after disableFocus() pressing Tab/Shift-Tab is a no-op, but a programmatic useFocusManager().focusNext()/focusPrevious() still moves focus. vue-tui previously short-circuited focusNext/focusPrevious on the internal `enabled` flag, making the programmatic API a no-op while focus was disabled — divergent from Ink. Move the enabled-check out of focusNext/focusPrevious and into the Tab/Shift-Tab input listener (mirroring handleTabNavigation), keeping the focusables.length === 0 short-circuit so focusing an empty/unmounted tree stays a harmless no-op. The now-redundant local `enabled` is dropped in favor of ctx.enabled as the single source of truth. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
6.1 KiB
TypeScript
203 lines
6.1 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";
|
|
|
|
// Shared helper: a focusable item that shows a checkmark when focused.
|
|
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>
|
|
);
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ink-ported: toggle focus management (enableFocus / disableFocus)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("toggle focus management — Tab does nothing while disabled", async () => {
|
|
const focusDisabled = shallowRef(false);
|
|
|
|
const App = defineComponent(() => {
|
|
const { enableFocus, disableFocus } = useFocusManager();
|
|
// React to the outer ref and call the manager API
|
|
// We use a watcher-like approach: just call them directly during render
|
|
// via a computed side-effect isn't idiomatic; use watchEffect instead.
|
|
// Actually, call them from setup once and rely on reactivity via the ref.
|
|
// The cleanest way in vue-tui: wire it up with a watch.
|
|
return () => {
|
|
// Call in render so it fires on every render triggered by focusDisabled
|
|
if (focusDisabled.value) {
|
|
disableFocus();
|
|
} else {
|
|
enableFocus();
|
|
}
|
|
return (
|
|
<Box flexDirection="column">
|
|
<FocusItem label="First" autoFocus />
|
|
<FocusItem label="Second" autoFocus />
|
|
<FocusItem label="Third" autoFocus />
|
|
</Box>
|
|
);
|
|
};
|
|
});
|
|
|
|
const { lastFrame, stdin } = await render(App);
|
|
expect(lastFrame()).toMatch(/First ✔/);
|
|
|
|
// Disable focus management
|
|
focusDisabled.value = true;
|
|
await nextTick();
|
|
// Tab should not move focus when focus management is disabled
|
|
await stdin.write("\t");
|
|
expect(lastFrame()).toMatch(/First ✔/);
|
|
|
|
// Re-enable focus management
|
|
focusDisabled.value = false;
|
|
await nextTick();
|
|
// Now Tab should move focus
|
|
await stdin.write("\t");
|
|
expect(lastFrame()).toMatch(/Second ✔/);
|
|
});
|
|
|
|
// Ink parity (App.tsx): the isFocusEnabled guard lives ONLY in handleTabNavigation,
|
|
// not in focusNext/focusPrevious. So after disableFocus(), Tab is a no-op but a
|
|
// programmatic focusNext()/focusPrevious() STILL moves focus.
|
|
test("programmatic focusNext() still moves focus while focus is disabled (Ink parity)", async () => {
|
|
let doDisableFocus!: () => void;
|
|
let doFocusNext!: () => void;
|
|
|
|
const App = defineComponent(() => {
|
|
const manager = useFocusManager();
|
|
doDisableFocus = manager.disableFocus;
|
|
doFocusNext = manager.focusNext;
|
|
return () => (
|
|
<Box flexDirection="column">
|
|
<FocusItem label="First" autoFocus />
|
|
<FocusItem label="Second" />
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
const { lastFrame } = await render(App);
|
|
expect(lastFrame()).toMatch(/First ✔/);
|
|
|
|
// Disable focus management — Tab would now be a no-op...
|
|
doDisableFocus();
|
|
await nextTick();
|
|
|
|
// ...but a programmatic focusNext() must still advance to Second (Ink parity).
|
|
doFocusNext();
|
|
await nextTick();
|
|
expect(lastFrame()).toMatch(/Second ✔/);
|
|
expect(lastFrame()).not.toMatch(/First ✔/);
|
|
});
|
|
|
|
test("programmatic focusPrevious() still moves focus while focus is disabled (Ink parity)", async () => {
|
|
let doDisableFocus!: () => void;
|
|
let doFocusPrevious!: () => void;
|
|
|
|
const App = defineComponent(() => {
|
|
const manager = useFocusManager();
|
|
doDisableFocus = manager.disableFocus;
|
|
doFocusPrevious = manager.focusPrevious;
|
|
return () => (
|
|
<Box flexDirection="column">
|
|
<FocusItem label="First" autoFocus />
|
|
<FocusItem label="Second" />
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
const { lastFrame } = await render(App);
|
|
expect(lastFrame()).toMatch(/First ✔/);
|
|
|
|
doDisableFocus();
|
|
await nextTick();
|
|
|
|
// Previous from First wraps to Second (last) — must still move while disabled.
|
|
doFocusPrevious();
|
|
await nextTick();
|
|
expect(lastFrame()).toMatch(/Second ✔/);
|
|
expect(lastFrame()).not.toMatch(/First ✔/);
|
|
});
|
|
|
|
test("does not crash when focusing next on unmounted children", async () => {
|
|
const unmountChildren = shallowRef(false);
|
|
let doFocusNext!: () => void;
|
|
|
|
const App = defineComponent(() => {
|
|
const manager = useFocusManager();
|
|
doFocusNext = manager.focusNext;
|
|
return () => {
|
|
if (unmountChildren.value) return null;
|
|
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 ✔/);
|
|
|
|
unmountChildren.value = true;
|
|
await nextTick();
|
|
|
|
// Should not throw
|
|
expect(() => doFocusNext()).not.toThrow();
|
|
await nextTick();
|
|
|
|
// Nothing rendered
|
|
expect(lastFrame()?.trim() ?? "").toBe("");
|
|
});
|
|
|
|
test("does not crash when focusing previous on unmounted children", async () => {
|
|
const unmountChildren = shallowRef(false);
|
|
let doFocusPrevious!: () => void;
|
|
|
|
const App = defineComponent(() => {
|
|
const manager = useFocusManager();
|
|
doFocusPrevious = manager.focusPrevious;
|
|
return () => {
|
|
if (unmountChildren.value) return null;
|
|
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 ✔/);
|
|
|
|
unmountChildren.value = true;
|
|
await nextTick();
|
|
|
|
// Should not throw
|
|
expect(() => doFocusPrevious()).not.toThrow();
|
|
await nextTick();
|
|
|
|
// Nothing rendered
|
|
expect(lastFrame()?.trim() ?? "").toBe("");
|
|
});
|