test(runtime): lock focus activeId + screen-reader coverage, mirroring Ink (#113)

Round-2 test-only locks (behaviors already at parity):
- focus (Ink test/focus.tsx): activeId resets to null on Esc, updates on programmatic
  focus(id), resets on unmount of the focused item, is null initially then Tab lands on
  first; Esc does NOT clear focus while focus management is disabled; focus(id) targets
  a deactivated (isActive=false) item (membership-only, ignoring isActive).
- screen-reader (Ink test/screen-reader.tsx) via the live renderToString component path:
  aria-label-only Text/Box, display:none subtree skipped, column>row space-join,
  single-box aria-states (checked/selected/multiselectable + multi-state ', '-join),
  role-only button, Transform accessibilityLabel replaces children.

Codex-reviewed GENUINE (expected strings match vue output + Ink intent).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 02:30:28 +08:00
committed by GitHub
parent d5c3f39871
commit eebbb37118
2 changed files with 401 additions and 1 deletions
@@ -1125,3 +1125,151 @@ describe("screen reader enabled mode", () => {
expect(output).toBe("Alpha\nBeta");
});
});
// Ink-parity LOCKS for the component (renderToString) SR path. Each mirrors an
// assertion in Ink's test/screen-reader.tsx (v7.0.4 @40b3a75) that the existing
// suite above did not already cover.
describe("screen reader: Ink test/screen-reader.tsx parity (component path)", () => {
// Ink screen-reader.tsx:78-84 — aria-label-only <Text> (no children) emits the
// label. Component path: Text.ts substitutes ariaLabel for an absent default slot.
test("aria-label-only Text (no children) emits the label", () => {
const output = renderToString(
defineComponent(() => () => <Text aria-label="Screen-reader only" />),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Screen-reader only");
});
// Ink screen-reader.tsx:86-92 — aria-label-only <Box> (no children) emits the
// label. Component path: Box.ts builds a label text node when SR + ariaLabel and
// there is no default slot.
test("aria-label-only Box (no children) emits the label", () => {
const output = renderToString(
defineComponent(() => () => <Box aria-label="Screen-reader only" />),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Screen-reader only");
});
// Ink screen-reader.tsx:110-122 — a display:none subtree is skipped in SR
// output via the LIVE component path (renderToString runs a real yoga layout, so
// the DISPLAY_NONE check in screen-reader.ts is exercised against live yoga, not
// a hand-built fixture).
test("display:none subtree is skipped (live component path)", () => {
const output = renderToString(
defineComponent(() => () => (
<Box>
<Box display="none">
<Text>Hidden</Text>
</Box>
<Text>Visible</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Visible");
});
// Ink screen-reader.tsx:320-334 ("render nested row") — a COLUMN parent whose
// child is a ROW joins the grandchildren with a SPACE (the row separator),
// distinct from the column case at :304-318 which joins with "\n".
test("column parent containing a row child joins grandchildren with a space", () => {
const output = renderToString(
defineComponent(() => () => (
<Box flexDirection="column">
<Box flexDirection="row">
<Text>Line 1</Text>
<Text>Line 2</Text>
</Box>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Line 1 Line 2");
});
// Ink screen-reader.tsx:186-196 — single box, checkbox role + checked state.
test("single box checkbox + checked", () => {
const output = renderToString(
defineComponent(() => () => (
<Box aria-role="checkbox" aria-state={{ checked: true }}>
<Text>Accept terms</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("checkbox: (checked) Accept terms");
});
// Ink screen-reader.tsx:277-287 — single box, option role + selected state.
test("single box option + selected", () => {
const output = renderToString(
defineComponent(() => () => (
<Box aria-role="option" aria-state={{ selected: true }}>
<Text>Blue</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("option: (selected) Blue");
});
// Ink screen-reader.tsx:238-248 — single box, listbox role + multiselectable.
test("single box listbox + multiselectable", () => {
const output = renderToString(
defineComponent(() => () => (
<Box aria-role="listbox" aria-state={{ multiselectable: true }}>
<Text>Options</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("listbox: (multiselectable) Options");
});
// Multiple truthy aria-state keys join with ", " (screen-reader.ts:216). Insertion
// order is preserved (Object.keys), so checked precedes disabled. This pins the
// multi-state join format, which Ink derives the same way from its aria-state object.
test("multiple truthy aria-state keys join with comma-space", () => {
const output = renderToString(
defineComponent(() => () => (
<Box aria-role="checkbox" aria-state={{ checked: true, disabled: true }}>
<Text>X</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("checkbox: (checked, disabled) X");
});
// Ink screen-reader.tsx:32-43 — role-only box (no state) via the component path
// (the existing suite only covered this through the hand-built unit fixture).
test("role-only box (button) via component path", () => {
const output = renderToString(
defineComponent(() => () => (
<Box aria-role="button">
<Text>Click me</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("button: Click me");
});
// Ink Transform.tsx:37-39 — in SR mode, accessibilityLabel REPLACES the children,
// and because the label is substituted as the transform node's content, the
// transform's OWN fn is NOT applied at the top level (screen-reader.ts leaves the
// top-level transform fn to its caller). So a lowercase child + an uppercasing
// transform still yields the bare label, NOT "X" uppercased nor "LOWERCASE".
test("Transform accessibilityLabel replaces children; transform not applied at top level", () => {
const output = renderToString(
defineComponent(() => () => (
<Transform transform={(s: string) => s.toUpperCase()} accessibilityLabel="X">
<Text>lowercase</Text>
</Transform>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("X");
});
});
@@ -1,4 +1,4 @@
import { defineComponent } from "vue";
import { defineComponent, 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";
@@ -62,3 +62,255 @@ test("useFocusManager().activeId is null when nothing is focused", async () => {
expect(activeId.value).toBeNull();
});
// LOCK: Esc resets activeId. Mirrors Ink focus.tsx:621-646 ("activeId resets to
// undefined on Esc"), with vue's sentinel `null` where Ink uses `undefined`.
test("useFocusManager().activeId resets to null on Esc", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const { isFocused } = useFocus({ id: props.id, autoFocus: props.id === "first" });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
return () => (
<Box flexDirection="column">
<Item id="first" />
</Box>
);
});
const { stdin } = await render(App);
expect(activeId.value).toBe("first");
// Bare Esc (\x1b) — the harness write() waits out the pending-escape flush.
await stdin.write("\x1b");
expect(activeId.value).toBeNull();
});
// LOCK: programmatic focus(id) updates activeId. Mirrors Ink focus.tsx:670-706
// ("activeId updates when focus is changed programmatically").
test("useFocusManager().activeId updates on programmatic focus(id)", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
let focus!: ReturnType<typeof useFocusManager>["focus"];
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const { isFocused } = useFocus({ id: props.id });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
focus = manager.focus;
return () => (
<Box flexDirection="column">
<Item id="first" />
<Item id="second" />
</Box>
);
});
await render(App);
// No autoFocus → nothing active initially.
expect(activeId.value).toBeNull();
focus("second");
expect(activeId.value).toBe("second");
focus("first");
expect(activeId.value).toBe("first");
});
// LOCK: unmounting the focused item resets activeId. Mirrors Ink focus.tsx:708-742
// ("activeId resets to undefined when focused component unmounts"). Vue uses a
// v-if (`show`) toggle in place of Ink's rerender-without-the-child.
test("useFocusManager().activeId resets to null when the focused item unmounts", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
const showFirst = shallowRef(true);
const Item = defineComponent({
props: { id: { type: String, required: true }, autoFocus: Boolean },
setup(props) {
const { isFocused } = useFocus({ id: props.id, autoFocus: props.autoFocus });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
return () => (
<Box flexDirection="column">
{showFirst.value ? <Item id="first" autoFocus /> : null}
<Item id="second" />
</Box>
);
});
const { waitUntilRenderFlush } = await render(App);
expect(activeId.value).toBe("first");
// Unmount the focused item — remove() clears activeId when it was active.
showFirst.value = false;
await waitUntilRenderFlush();
expect(activeId.value).toBeNull();
});
// LOCK: initial activeId is null and Tab from no-focus lands on the first
// focusable, then advances. Mirrors Ink focus.tsx:591-619 ("activeId from
// useFocusManager reflects currently focused component"); vue's empty sentinel
// is `null` where Ink starts at `undefined`.
test("initial activeId is null; Tab from no focus lands on the first focusable then advances", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const { isFocused } = useFocus({ id: props.id });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
return () => (
<Box flexDirection="column">
<Item id="a" />
<Item id="b" />
</Box>
);
});
const { stdin } = await render(App);
// No autoFocus → nothing active.
expect(activeId.value).toBeNull();
await stdin.write("\t");
expect(activeId.value).toBe("a");
await stdin.write("\t");
expect(activeId.value).toBe("b");
});
// LOCK: Esc must NOT clear focus while focus management is disabled. Mirrors Ink
// App.tsx:250-252 — the Esc reset is gated on `isFocusEnabled`. After
// disableFocus(), the bare Esc handler is a no-op and the focused item stays
// focused.
test("Esc does not clear focus while focus management is disabled", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
let disableFocus!: ReturnType<typeof useFocusManager>["disableFocus"];
const Item = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const { isFocused } = useFocus({ id: props.id, autoFocus: props.id === "first" });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
disableFocus = manager.disableFocus;
return () => (
<Box flexDirection="column">
<Item id="first" />
</Box>
);
});
const { stdin } = await render(App);
expect(activeId.value).toBe("first");
disableFocus();
await stdin.write("\x1b");
// Focus is disabled → Esc is a no-op → focus is STILL shown.
expect(activeId.value).toBe("first");
});
// LOCK: focus(id) targets a DEACTIVATED (isActive=false) item. Mirrors Ink
// App.tsx:519-531 — `focus` only checks membership (`hasFocusableId`), NOT
// isActive, so a programmatically-focused item that is currently inactive still
// becomes the active focus. (Tab navigation, by contrast, skips inactive items.)
test("focus(id) targets a deactivated (isActive=false) item", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
let focus!: ReturnType<typeof useFocusManager>["focus"];
const Item = defineComponent({
props: { id: { type: String, required: true }, active: { type: Boolean, default: true } },
setup(props) {
const { isFocused } = useFocus({ id: props.id, isActive: () => props.active });
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.id}
</Text>
);
},
});
const App = defineComponent(() => {
const manager = useFocusManager();
activeId = manager.activeId;
focus = manager.focus;
return () => (
<Box flexDirection="column">
<Item id="first" />
<Item id="middle" active={false} />
<Item id="last" />
</Box>
);
});
await render(App);
// Nothing active initially.
expect(activeId.value).toBeNull();
// focus() ignores isActive — the deactivated middle item still becomes active.
focus("middle");
expect(activeId.value).toBe("middle");
});