From 04cea188c6c0817e713cac83c191fc73d4cc4e6f Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 11:15:33 +0800 Subject: [PATCH] fix(runtime): default-Box screen-reader separator to space (Ink parity, G39) (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain (no explicit flexDirection) lays out as a row — yoga's Box default is FLEX_DIRECTION_ROW (host/yoga.ts) — so its screen-reader children must be joined with a space, matching Ink (which hardcodes flexDirection:'row' in Box.tsx and derives the SR separator from style). Root cause: flexDirection is a pure yoga prop. node-ops applies it to the yoga node but does NOT mirror it into node.props (it is not in STYLE_PROPS), so the yoga row default was never reflected there. screen-reader.ts read node.props["flexDirection"], which was undefined for every live-rendered box (both default AND explicit column/row), wrongly defaulting the separator to "\n" in all cases. Fix: resolve the direction from the yoga node (preferring an explicit props.flexDirection for the direct-built unit fixtures), mirroring static-channel.ts's resolvedFlexDirection so both SR linearization paths derive the separator identically. Root keeps undefined → "\n" (Ink's column-default root). row-reverse/column-reverse reversal is unaffected. Co-authored-by: Claude Opus 4.8 --- .../accessibility/screen-reader.test.tsx | 131 +++++++++++++++++- packages/runtime/src/paint/screen-reader.ts | 37 ++++- 2 files changed, 163 insertions(+), 5 deletions(-) diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx index b648f53..9bf817c 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx @@ -1,6 +1,6 @@ -import { defineComponent, type FunctionalComponent } from "vue"; +import { defineComponent, nextTick, shallowRef, type FunctionalComponent } from "vue"; import { describe, expect, test } from "vite-plus/test"; -import { renderToString, Box, Text, Transform, Static } from "@vue-tui/runtime"; +import { renderToString, Box, Text, Transform, Static, createApp } from "@vue-tui/runtime"; import { render } from "@vue-tui/testing"; import { createRoot, @@ -11,6 +11,18 @@ import { renderScreenReaderOutput, type AppContext, } from "@vue-tui/runtime/internal"; +import { + makeFakeStdin, + makeFakeWritable, + captureWrites, + getContentWrites, +} from "../lifecycle/test-streams.ts"; + +// Strip ANSI control sequences (cursor-hide, log-update erase codes) so the +// live screen-reader frames can be compared as plain text. The SR frame writer +// interleaves erase sequences between commits; we only care about the text. +// eslint-disable-next-line no-control-regex -- terminal ANSI escapes are control chars by definition +const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); // Yoga.DIRECTION_LTR = 0 const DIRECTION_LTR = 0; @@ -530,6 +542,121 @@ describe("screen reader enabled mode", () => { expect(output).toBe("Hello\nWorld"); }); + // G39 (Ink parity): a default (no explicit flexDirection) lays out as + // a row (yoga/Box default is FLEX_DIRECTION_ROW), so its SR children must be + // joined with a SPACE — matching Ink, which hardcodes flexDirection:'row' in + // Box.tsx and derives the SR separator from style. The yoga row default is NOT + // mirrored into node.props, so an absent flexDirection must be treated as + // "row" in screen-reader.ts. (Buggy behavior joined with "\n".) + test("render default Box (no flexDirection) joins children with space", () => { + const output = renderToString( + defineComponent(() => () => ( + + Hello + World + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("Hello World"); + }); + + // G39 follow-up: pin the yoga-enum mapping + reverse-order branch that + // resolveBoxFlexDirection / renderScreenReaderOutput now own. row-reverse must + // join with a SPACE *and* reverse child order (Ink parity: reverse directions + // flip the visual/announced order). + test("render Box flexDirection=row-reverse joins with space and reverses children", () => { + const output = renderToString( + defineComponent(() => () => ( + + Hello + World + + )), + { isScreenReaderEnabled: true }, + ); + // row-reverse: space separator + reversed order → "World Hello". + expect(output).toBe("World Hello"); + }); + + // G39 follow-up: column-reverse must join with a NEWLINE (non-row separator) + // *and* reverse child order. + test("render Box flexDirection=column-reverse joins with newline and reverses children", () => { + const output = renderToString( + defineComponent(() => () => ( + + Hello + World + + )), + { isScreenReaderEnabled: true }, + ); + // column-reverse: newline separator + reversed order → "World\nHello". + expect(output).toBe("World\nHello"); + }); + + // G39 stale-read guard (the core reason resolveBoxFlexDirection reads yoga, not + // node.props): when a Box's flexDirection is dynamically REMOVED, the yoga node + // resets to its row default (host/yoga.ts: flexDirection == null → ROW). Since + // node-ops never mirrors flexDirection into node.props (not a STYLE_PROP), the + // SR separator MUST be derived from the live yoga state, not a stale prop. This + // uses the live commit path (isScreenReaderEnabled mount) so the yoga reset is + // real — the old pure-props logic would read undefined → "\n" and FAIL the + // post-removal assertion below. + test.sequential("live SR Box resolves to row default (space) after flexDirection is dynamically removed", async () => { + // shallowRef holding the reactive flexDirection: start "column", then clear. + // Typed as the Box FlexDirection union (not `string`) so the JSX prop + // typechecks under `vp run ci` — FlexDirection isn't exported from the + // public index, so we inline the literal union here. + const flexDirection = shallowRef< + "row" | "row-reverse" | "column" | "column-reverse" | undefined + >("column"); + const App = defineComponent(() => () => ( + + Hello + World + + )); + + const app = createApp(App); + const stdout = makeFakeWritable({ columns: 80 }); + const stderr = makeFakeWritable({ columns: 80 }); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + app.mount({ + stdout, + stdin, + stderr, + exitOnCtrlC: false, + isScreenReaderEnabled: true, + }); + + await nextTick(); + await nextTick(); + + // column → newline separator, forward order. + const beforeRemoval = stripAnsi(getContentWrites(writes).join("")); + expect(beforeRemoval).toContain("Hello\nWorld"); + expect(beforeRemoval).not.toContain("Hello World"); + + // Drop flexDirection. node-ops resets the yoga node to its row default; + // node.props.flexDirection is undefined (never mirrored), so the SR path + // must read yoga (ROW) → space separator. + writes.length = 0; + flexDirection.value = undefined; + + await nextTick(); + await nextTick(); + + const afterRemoval = stripAnsi(getContentWrites(writes).join("")); + // Yoga reset to row → SPACE separator. (Stale-prop logic would emit "\n".) + expect(afterRemoval).toContain("Hello World"); + expect(afterRemoval).not.toContain("Hello\nWorld"); + + app.unmount(); + }); + test("render nested Box components with Text", () => { const output = renderToString( defineComponent(() => () => ( diff --git a/packages/runtime/src/paint/screen-reader.ts b/packages/runtime/src/paint/screen-reader.ts index f7c796e..8d1d4bd 100644 --- a/packages/runtime/src/paint/screen-reader.ts +++ b/packages/runtime/src/paint/screen-reader.ts @@ -36,6 +36,35 @@ function squashTextContent(node: TuiText | TuiVirtualText): string { return text; } +/** + * Resolve a box node's flexDirection as the string form the SR separator logic + * compares against ("row" | "row-reverse" | "column" | "column-reverse"). + * + * Prefer an explicit `props.flexDirection` when present (used by unit-test + * fixtures that build nodes directly without a live yoga layout), otherwise read + * the resolved direction back from the yoga node. node-ops applies flexDirection + * to yoga but does NOT mirror it into `props` (it is not in STYLE_PROPS), and the + * yoga node holds the Box default of row (host/yoga.ts sets FLEX_DIRECTION_ROW). + * Mirrors static-channel.ts's resolvedFlexDirection so both SR linearization + * paths derive the separator identically. (Ink parity, G39.) + */ +function resolveBoxFlexDirection(node: TuiBox): string { + const fromProps = node.props["flexDirection"] as string | undefined; + if (fromProps !== undefined) { + return fromProps; + } + switch (node.yoga.getFlexDirection()) { + case Yoga.FLEX_DIRECTION_ROW: + return "row"; + case Yoga.FLEX_DIRECTION_ROW_REVERSE: + return "row-reverse"; + case Yoga.FLEX_DIRECTION_COLUMN_REVERSE: + return "column-reverse"; + default: + return "column"; + } +} + export interface ScreenReaderOptions { parentRole?: string; skipStaticElements?: boolean; @@ -73,9 +102,11 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt if (node.type === "text") { output = squashTextContent(node); } else if (node.type === "box" || node.type === "root") { - // Determine separator based on flex direction - const flexDirection = - node.type === "box" ? (node.props["flexDirection"] as string | undefined) : undefined; + // Determine separator based on flex direction (resolved from yoga so the + // Box default of row yields a space separator, matching Ink — see + // resolveBoxFlexDirection / G39). Root keeps undefined → "\n" (Ink's column + // default root). + const flexDirection = node.type === "box" ? resolveBoxFlexDirection(node as TuiBox) : undefined; const separator = flexDirection === "row" || flexDirection === "row-reverse" ? " " : "\n";