fix(runtime): pass child sibling index to nested <Transform> (Ink parity, G21) (#50)
* fix(runtime): pass child sibling index to nested <Transform> (Ink parity, G21) paint.ts renderTextWithInlineStyles and text-measure.ts flattenLeaves squash loops now pass the child's position index to child.transform instead of a hardcoded 0, matching Ink squash-text-nodes.ts:13,38 where internal_transform receives the plain loop counter over node.childNodes (all siblings). A nested <Transform> that is the Nth child of a <Text> therefore gets index = N. Both spots use the same index basis so paint and measurement agree. Refines the earlier G06 refutation — the inline/squash path was the real gap. Also adds the `innerText.length > 0` guard in text-measure.ts flattenLeaves so measurement skips transforms on empty text (matching paint.ts and Ink:34), and converts screen-reader.ts squashTextContent to use forEach-with-index so the nested-Transform index is correct in SR mode too (was hardcoded 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(parity): ledger — G21 pr-open, reconcile G20 merged Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -291,6 +291,22 @@ describe("Text aria props", () => {
|
||||
});
|
||||
|
||||
describe("Transform accessibility", () => {
|
||||
// G21 follow-up, finding 2: squashTextContent must pass the transform's
|
||||
// positional sibling index (not hardcoded 0) so SR output matches paint.
|
||||
test("nested <Transform> as 2nd child of <Text> gets index 1 in screen-reader mode", () => {
|
||||
const output = renderToString(
|
||||
defineComponent(() => () => (
|
||||
<Text>
|
||||
a<Transform transform={(s: string, i: number) => `${s}[${i}]`}>b</Transform>
|
||||
</Text>
|
||||
)),
|
||||
{ isScreenReaderEnabled: true },
|
||||
);
|
||||
// Transform is the 2nd child (index 1) of the Text node — must receive 1,
|
||||
// not 0, matching paint.ts and Ink squash-text-nodes.ts:13,38 behavior.
|
||||
expect(output).toBe("ab[1]");
|
||||
});
|
||||
|
||||
test("renders children normally when screen reader is disabled", () => {
|
||||
const output = renderToString(
|
||||
defineComponent(() => () => (
|
||||
|
||||
@@ -46,6 +46,46 @@ test("squash multiple text nodes — <Transform> inside <Text>", async () => {
|
||||
expect(lastFrame()).toBe("[0: {0: hello world}]");
|
||||
});
|
||||
|
||||
// G21: a nested <Transform> receives its POSITIONAL sibling index among the
|
||||
// parent <Text>'s children, matching Ink squash-text-nodes.ts:13,38 (the index
|
||||
// is the plain loop counter over ALL childNodes, including text-leaf siblings).
|
||||
test("nested <Transform> as 2nd child of <Text> gets index 1", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Text>
|
||||
a<Transform transform={(s: string, i: number) => `${s}[${i}]`}>b</Transform>
|
||||
</Text>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame()).toBe("ab[1]");
|
||||
});
|
||||
|
||||
test("nested <Transform> as 3rd child of <Text> gets index 2", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Text>
|
||||
a<Text>b</Text>
|
||||
<Transform transform={(s: string, i: number) => `${s}[${i}]`}>c</Transform>
|
||||
</Text>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame()).toBe("abc[2]");
|
||||
});
|
||||
|
||||
test("sole/first-child nested <Transform> still gets index 0", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Text>
|
||||
<Transform transform={(s: string, i: number) => `${s}[${i}]`}>a</Transform>b
|
||||
</Text>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame()).toBe("a[0]b");
|
||||
});
|
||||
|
||||
// Resolved: Transform nodes are now yoga carriers, so multi-line text
|
||||
// under a Transform node is properly laid out. See transform-yoga.test.tsx.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineComponent, h } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import stringWidth from "string-width";
|
||||
import { createText, createTextLeaf, createVirtualText } from "./nodes.ts";
|
||||
import { createText, createTextLeaf, createTransform, createVirtualText } from "./nodes.ts";
|
||||
import { flattenLeaves, measureTextNatural, wrapText } from "./text-measure.ts";
|
||||
import { renderToString } from "../render-to-string.ts";
|
||||
import { Box } from "../components/Box.ts";
|
||||
@@ -36,6 +36,26 @@ test("flattenLeaves recurses into virtual-text", () => {
|
||||
expect(flattenLeaves(t)).toBe("ab");
|
||||
});
|
||||
|
||||
// G21 follow-up, finding 1: flattenLeaves must NOT apply a transform to empty
|
||||
// inner text — matches paint.ts `innerText.length > 0` guard and Ink
|
||||
// squash-text-nodes.ts:34 (`nodeText.length > 0`). Without the guard,
|
||||
// a transform that adds chars to an empty string inflates the measured width
|
||||
// relative to what paint actually renders, causing layout/wrapping mismatch.
|
||||
test("flattenLeaves skips transform on empty nested text (length guard)", () => {
|
||||
// Transform that adds chars to any input (including empty string).
|
||||
const addCharsTransform = (s: string, _i: number) => s + "[X]";
|
||||
const t = createText();
|
||||
const leaf = createTextLeaf("ab");
|
||||
leaf.parent = t;
|
||||
// Empty transform child: no text leaves inside it.
|
||||
const emptyTransform = createTransform(addCharsTransform);
|
||||
emptyTransform.parent = t;
|
||||
t.children = [leaf, emptyTransform];
|
||||
// With the guard: flattenLeaves("ab" + skip-empty-transform) = "ab".
|
||||
// Without the guard: flattenLeaves("ab" + "[X]") = "ab[X]" (inflated width).
|
||||
expect(flattenLeaves(t)).toBe("ab");
|
||||
});
|
||||
|
||||
test("wrapText splits on width", () => {
|
||||
expect(wrapText("hello world", 5, "wrap")).toEqual(["hello", " ", "world"]);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,12 @@ import type { TextProps, TuiText, TuiVirtualText } from "./nodes.ts";
|
||||
export function flattenLeaves(node: TuiText | TuiVirtualText): string {
|
||||
if (!node.children || node.children.length === 0) return "";
|
||||
let out = "";
|
||||
for (const child of node.children) {
|
||||
// `index` is the child's POSITIONAL index among ALL siblings — the plain loop
|
||||
// counter over node.children, matching Ink squash-text-nodes.ts:13,38 (index
|
||||
// is the loop position over node.childNodes). Must use the SAME index basis as
|
||||
// paint.ts renderTextWithInlineStyles so measurement and paint agree on what a
|
||||
// nested <Transform> receives as its second argument.
|
||||
node.children.forEach((child, index) => {
|
||||
if (child.type === "text-leaf") {
|
||||
out += child.value;
|
||||
} else if (child.type === "virtual-text") {
|
||||
@@ -23,11 +28,15 @@ export function flattenLeaves(node: TuiText | TuiVirtualText): string {
|
||||
innerText += flattenLeaves(grandchild);
|
||||
}
|
||||
}
|
||||
if (child.transform) innerText = child.transform(innerText, 0);
|
||||
// Only apply the transform when there is actual text content — matches
|
||||
// paint.ts `innerText.length > 0` guard and Ink squash-text-nodes.ts:34
|
||||
// (`nodeText.length > 0`). Without this guard, a transform that adds chars
|
||||
// to empty text inflates measured width relative to what paint renders.
|
||||
if (innerText.length > 0 && child.transform) innerText = child.transform(innerText, index);
|
||||
out += innerText;
|
||||
}
|
||||
// Skip comments inserted by Vue for null/undefined renders
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +292,12 @@ function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextPro
|
||||
const defined = Object.fromEntries(Object.entries(node.props).filter(([, v]) => v !== undefined));
|
||||
const merged: TextProps = { ...acc, ...defined };
|
||||
let out = "";
|
||||
for (const child of node.children) {
|
||||
// `index` is the child's POSITIONAL index among ALL siblings (text-leaves,
|
||||
// virtual-text, transforms, comments alike) — it is the plain loop counter,
|
||||
// matching Ink squash-text-nodes.ts:13,38 where `internal_transform(text,
|
||||
// index)` receives the loop index over `node.childNodes`. A nested <Transform>
|
||||
// that is the Nth child therefore gets `index = N`, not a hardcoded 0.
|
||||
node.children.forEach((child, index) => {
|
||||
if (child.type === "text-leaf") {
|
||||
out += applyChalk(child.value, merged);
|
||||
} else if (child.type === "virtual-text") {
|
||||
@@ -311,12 +316,12 @@ function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextPro
|
||||
}
|
||||
}
|
||||
if (innerText.length > 0 && child.transform) {
|
||||
innerText = child.transform(innerText, 0);
|
||||
innerText = child.transform(innerText, index);
|
||||
}
|
||||
out += innerText;
|
||||
}
|
||||
// Skip comments inserted by Vue for null/undefined renders
|
||||
}
|
||||
});
|
||||
return sanitizeAnsi(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import type { TuiNode, TuiText, TuiVirtualText, TuiBox } from "../host/nodes.ts"
|
||||
*/
|
||||
function squashTextContent(node: TuiText | TuiVirtualText): string {
|
||||
let text = "";
|
||||
for (const child of node.children) {
|
||||
// Use forEach so `index` is the child's POSITIONAL index among ALL siblings —
|
||||
// matching paint.ts renderTextWithInlineStyles and Ink squash-text-nodes.ts:13,38
|
||||
// (index is the plain loop counter over node.childNodes). A nested <Transform>
|
||||
// must receive its sibling position, not a hardcoded 0.
|
||||
node.children.forEach((child, index) => {
|
||||
if (child.type === "text-leaf") {
|
||||
text += child.value;
|
||||
} else if (child.type === "virtual-text") {
|
||||
@@ -23,12 +27,12 @@ function squashTextContent(node: TuiText | TuiVirtualText): string {
|
||||
}
|
||||
}
|
||||
if (innerText.length > 0 && child.transform) {
|
||||
innerText = child.transform(innerText, 0);
|
||||
innerText = child.transform(innerText, index);
|
||||
}
|
||||
text += innerText;
|
||||
}
|
||||
// Skip comments
|
||||
}
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user