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:
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user