fix(runtime): default-Box screen-reader separator to space (Ink parity, G39) (#56)

A plain <Box> (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 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 11:15:33 +08:00
committed by GitHub
parent f78121f6e7
commit 04cea188c6
2 changed files with 163 additions and 5 deletions
+34 -3
View File
@@ -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";