fix(runtime): keep programmatic focusNext/Previous live while focus disabled (Ink parity, G45) (#57)
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>
This commit is contained in:
@@ -73,6 +73,68 @@ test("toggle focus management — Tab does nothing while disabled", async () =>
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user