fix(runtime): dedup screen-reader role against immediate parent only (Ink parity, G22) (#51)
* fix(runtime): dedup screen-reader role against immediate parent only (Ink parity, G22) Drop the `?? options.parentRole` grandparent fallback so a role-less intermediate resets inherited parentRole to undefined for its children, matching Ink's immediate-parent-only role dedup (render-node-to-output.ts:68-69 passes only `node.internal_accessibility?.role`, no fallback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(parity): ledger — G22 pr-open, reconcile G21 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:
@@ -55,8 +55,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
||||
| G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 |
|
||||
| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | merged | `fix/parity-yoga-reset` | #48 |
|
||||
| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | merged | `fix/parity-write-after-unmount` | #49 |
|
||||
| G21 | text-wrap-transform | Nested <Transform> in <Text> gets hardcoded index 0 vs child sibling position (squash path) | P3 | pr-open | `fix/parity-transform-index` | #50 |
|
||||
| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | todo | — | — |
|
||||
| G21 | text-wrap-transform | Nested <Transform> in <Text> gets hardcoded index 0 vs child sibling position (squash path) | P3 | merged | `fix/parity-transform-index` | #50 |
|
||||
| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | pr-open | `fix/parity-sr-role-dedup` | #51 |
|
||||
| G23 | app-exit-instances-animation-sr | <Transform> under <Box> SR-joins children with newline; Ink concatenates | P3 | todo | — | — |
|
||||
| G24 | render-lifecycle-reconciler | Renders ALL <Static> nodes; Ink renders only the most-recent staticNode (multi-Static support — candidate) | P3 | candidate | — (see ink-parity.md) | — |
|
||||
|
||||
|
||||
@@ -223,6 +223,100 @@ describe("renderScreenReaderOutput (unit)", () => {
|
||||
|
||||
root.yoga.freeRecursive();
|
||||
});
|
||||
|
||||
// G22 (Ink parity): dedup is only against the IMMEDIATE parent's role.
|
||||
// A role-less intermediate box must reset the inherited parentRole to undefined
|
||||
// so a grandchild with the same role as the grandparent IS still announced.
|
||||
// Ink passes `node.internal_accessibility?.role` (no ?? fallback) to children.
|
||||
test("G22: grandchild with same role as grandparent IS announced when immediate parent has no role", () => {
|
||||
// Structure: grandparent[role=list] → middle(no role) → grandchild[role=list]
|
||||
// Expected: both "list" annotations appear (grandchild is NOT wrongly deduped
|
||||
// against the grandparent through the role-less intermediate).
|
||||
const root = createRoot(createTestAppContext());
|
||||
attachYoga(root);
|
||||
root.yoga.setWidth(80);
|
||||
|
||||
const grandparent = createBox();
|
||||
attachYoga(grandparent);
|
||||
grandparent.internal_accessibility = { role: "list" };
|
||||
grandparent.parent = root;
|
||||
root.children.push(grandparent);
|
||||
root.yoga.insertChild(grandparent.yoga, 0);
|
||||
|
||||
// Role-less intermediate box — must reset parentRole to undefined for its children.
|
||||
const middle = createBox();
|
||||
attachYoga(middle);
|
||||
// No internal_accessibility on middle (no role).
|
||||
middle.parent = grandparent;
|
||||
grandparent.children.push(middle);
|
||||
grandparent.yoga.insertChild(middle.yoga, 0);
|
||||
|
||||
const grandchild = createBox();
|
||||
attachYoga(grandchild);
|
||||
grandchild.internal_accessibility = { role: "list" };
|
||||
grandchild.parent = middle;
|
||||
middle.children.push(grandchild);
|
||||
middle.yoga.insertChild(grandchild.yoga, 0);
|
||||
|
||||
const text = createText();
|
||||
attachYoga(text);
|
||||
const leaf = createTextLeaf("Item");
|
||||
leaf.parent = text;
|
||||
text.children.push(leaf);
|
||||
text.parent = grandchild;
|
||||
grandchild.children.push(text);
|
||||
grandchild.yoga.insertChild(text.yoga, 0);
|
||||
|
||||
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
||||
|
||||
const output = renderScreenReaderOutput(root);
|
||||
// grandchild shares role with grandparent but NOT with its immediate parent
|
||||
// (which has no role). It must NOT be deduped — both annotations must appear.
|
||||
expect(output).toBe("list: list: Item");
|
||||
|
||||
root.yoga.freeRecursive();
|
||||
});
|
||||
|
||||
// G22 control: immediate-parent dedup is still in effect.
|
||||
// A child whose DIRECT parent has the same role must still be suppressed.
|
||||
test("G22 control: child role matching immediate parent is still deduped", () => {
|
||||
// Structure: parent[role=nav] → child[role=nav]
|
||||
// Expected: only one "nav:" annotation (child is deduped against immediate parent).
|
||||
const root = createRoot(createTestAppContext());
|
||||
attachYoga(root);
|
||||
root.yoga.setWidth(80);
|
||||
|
||||
const parent = createBox();
|
||||
attachYoga(parent);
|
||||
parent.internal_accessibility = { role: "nav" };
|
||||
parent.parent = root;
|
||||
root.children.push(parent);
|
||||
root.yoga.insertChild(parent.yoga, 0);
|
||||
|
||||
const child = createBox();
|
||||
attachYoga(child);
|
||||
child.internal_accessibility = { role: "nav" };
|
||||
child.parent = parent;
|
||||
parent.children.push(child);
|
||||
parent.yoga.insertChild(child.yoga, 0);
|
||||
|
||||
const text = createText();
|
||||
attachYoga(text);
|
||||
const leaf = createTextLeaf("Link");
|
||||
leaf.parent = text;
|
||||
text.children.push(leaf);
|
||||
text.parent = child;
|
||||
child.children.push(text);
|
||||
child.yoga.insertChild(text.yoga, 0);
|
||||
|
||||
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
||||
|
||||
const output = renderScreenReaderOutput(root);
|
||||
// Child shares role with immediate parent — deduped to single annotation.
|
||||
expect(output).toBe("nav: Link");
|
||||
|
||||
root.yoga.freeRecursive();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Box aria props", () => {
|
||||
|
||||
@@ -86,12 +86,17 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt
|
||||
: node.children;
|
||||
|
||||
const boxNode = node as TuiBox;
|
||||
// Ink parity (G22): pass only the CURRENT node's own role to children —
|
||||
// no `?? options.parentRole` fallback. When this box has no role, `undefined`
|
||||
// is forwarded, resetting the inherited parentRole so a grandchild with the
|
||||
// same role as its grandparent is NOT wrongly deduped (dedup is immediate-
|
||||
// parent-only, matching Ink render-node-to-output.ts:68-69).
|
||||
const parentRole = boxNode.internal_accessibility?.role;
|
||||
|
||||
output = children
|
||||
.map((childNode) =>
|
||||
renderScreenReaderOutput(childNode, {
|
||||
parentRole: parentRole ?? options.parentRole,
|
||||
parentRole: parentRole,
|
||||
skipStaticElements: options.skipStaticElements,
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user