test: add Ink component/composable test parity (+130) and fix layout listener bug

* test: add text ANSI sanitization parity tests from Ink (+15)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add use-animation parity tests from Ink (+43)

Port 43 new tests from Ink's use-animation test suite covering:
- Multiple animations in sync, different rates, sibling unmount
- Timer cleanup/recreation on unmount and remount
- Inactive animations, timer leak prevention
- Edge intervals (NaN, Infinity, -Infinity, oversized, zero, negative)
- isActive toggle resets, pause/resume cycles
- Frame catch-up, time/delta tracking, reset() behavior
- Newly mounted/activated animations don't inherit elapsed time
- Wall clock monotonicity, getter function isActive support

Uses selective fake timers (setInterval + performance only) so that
render()'s internal setImmediate still works on real clocks. Fake timer
tests read refs directly to avoid Vue scheduler flush timing issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add use-box-metrics/measure parity tests from Ink (+16)

Port 16 missing tests from Ink's use-box-metrics, measure-element, and
measure-text test suites. Fix useBoxMetrics to reset metrics to zeros
when the tracked ref detaches (element unmounts or ref switches to null).

3 tests are skipped because vue-tui's useBoxMetrics uses watchPostEffect
(re-runs only when ref.value changes) rather than Ink's layout-commit
listener pattern, so sibling-content and resize-driven re-measurement
is not yet supported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add screen-reader parity tests from Ink (+11)

Add 11 screen-reader integration tests covering aria-label substitution on
Text/Box, ANSI styling omission, multiple/nested components, null component,
aria-state variants (busy, disabled, expanded), multi-line roles, and
multiselectable listbox.

Also fix component prop bug: Vue normalizes kebab-case prop names to camelCase
at runtime, so props["aria-label"] was always undefined. Switch Box/Text prop
declarations and access to camelCase (ariaLabel, ariaHidden, ariaRole, ariaState).

Add isScreenReaderEnabled option to renderToString() so tests can exercise
screen-reader output through the component pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add render-to-string parity tests from Ink (+18)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add cursor composable parity tests from Ink (+7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add 6 missing screen-reader Ink parity tests

Add tests for aria-hidden, select input (list with roles/states/labels),
aria-state.multiline, aria-state.readonly, aria-state.required, and
nested multi-line text rendering in screen-reader mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix render-to-string missing Ink parity tests (+10)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix cursor composable missing Ink parity tests (+6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix use-box-metrics missing Ink parity tests (+4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add layout listener so useBoxMetrics updates on resize and sibling changes

Adds a layout listener mechanism to TuiRoot matching Ink's architecture:
- TuiRoot.layoutListeners Set with addLayoutListener/emitLayoutListeners
- emitLayoutListeners called after every yoga.calculateLayout in commit()
- useBoxMetrics subscribes to layout listeners, diffs values before updating

Enables 4 previously-skipped tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-27 20:42:54 +08:00
committed by GitHub
parent 32fff4104c
commit 9227ddf696
12 changed files with 3517 additions and 46 deletions
+6 -6
View File
@@ -134,10 +134,10 @@ export const Box = defineComponent({
overflowY: String as PropType<"visible" | "hidden">,
display: String as PropType<"flex" | "none">,
"aria-label": String,
"aria-hidden": Boolean,
"aria-role": String as PropType<AriaRole>,
"aria-state": Object as PropType<AriaState>,
ariaLabel: String,
ariaHidden: Boolean,
ariaRole: String as PropType<AriaRole>,
ariaState: Object as PropType<AriaState>,
},
setup(props, { slots }) {
const appCtx = inject(AppContextKey, null);
@@ -146,11 +146,11 @@ export const Box = defineComponent({
const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false;
// When screen reader is enabled and aria-hidden is set, render nothing.
if (isScreenReaderEnabled && props["aria-hidden"]) {
if (isScreenReaderEnabled && props.ariaHidden) {
return null;
}
const ariaLabel = props["aria-label"];
const ariaLabel = props.ariaLabel;
const label = ariaLabel ? h("text", null, ariaLabel) : undefined;
return h("box", props as never, isScreenReaderEnabled && label ? [label] : slots.default?.());
+4 -4
View File
@@ -22,8 +22,8 @@ export const Text = defineComponent({
strikethrough: Boolean,
inverse: Boolean,
wrap: { type: String as PropType<WrapMode>, default: "wrap" },
"aria-label": String,
"aria-hidden": Boolean,
ariaLabel: String,
ariaHidden: Boolean,
},
setup(props, { slots }) {
const appCtx = inject(AppContextKey, null);
@@ -32,11 +32,11 @@ export const Text = defineComponent({
const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false;
// When screen reader is enabled and aria-hidden is set, render nothing.
if (isScreenReaderEnabled && props["aria-hidden"]) {
if (isScreenReaderEnabled && props.ariaHidden) {
return null;
}
const ariaLabel = props["aria-label"];
const ariaLabel = props.ariaLabel;
const children = isScreenReaderEnabled && ariaLabel ? ariaLabel : slots.default?.();
if (children === undefined || children === null) {
+105 -10
View File
@@ -1,5 +1,6 @@
import { nextTick, shallowRef, watchPostEffect, type Ref, type ShallowRef } from "vue";
import type { Node as YogaNode } from "yoga-layout";
import { addLayoutListener, type TuiNode, type TuiRoot } from "../host/nodes.ts";
// Yoga's `right`/`bottom` are omitted: always `0` for flow layout and
// unintuitive for absolute positioning. Matches Ink's BoxMetrics type.
@@ -45,6 +46,28 @@ function resolveYogaNode(value: unknown): { yoga: YogaNode } | null {
return null;
}
/** Resolve a ref value to its underlying TUI node (for tree traversal). */
function resolveTuiNode(value: unknown): TuiNode | null {
if (!value) return null;
const obj = value as Record<string, unknown>;
if (typeof obj.type === "string") return obj as unknown as TuiNode;
// Vue component instance — root host element is on $el
if (obj.$el && typeof (obj.$el as Record<string, unknown>).type === "string") {
return obj.$el as unknown as TuiNode;
}
return null;
}
/** Walk up the DOM tree to find the root node. */
function findRootNode(node: TuiNode | null): TuiRoot | null {
let current: TuiNode | null = node;
while (current) {
if (current.type === "root") return current;
current = current.parent;
}
return null;
}
/**
* Imperative function that reads yoga computed dimensions from a TUI node.
*
@@ -68,6 +91,10 @@ export function measureElement(node: unknown): { width: number; height: number }
* Reactive composable that returns computed layout metrics for a tracked box element.
* Updates after each render commit when yoga layout has been calculated.
*
* Subscribes to the root node's layout listener so metrics update on terminal
* resize and sibling layout changes, even when the tracked ref doesn't change.
* Matches Ink's useBoxMetrics architecture.
*
* Returns `{ width, height, left, top, hasMeasured }` where all values are
* reactive refs. `hasMeasured` starts `false` and becomes `true` after the
* first layout pass.
@@ -90,14 +117,51 @@ export function useBoxMetrics(ref: Ref<unknown>): UseBoxMetricsResult {
const top = shallowRef(0);
const hasMeasured = shallowRef(false);
function measure() {
function updateMetrics() {
const node = resolveYogaNode(ref.value);
if (!node) return;
width.value = node.yoga.getComputedWidth();
height.value = node.yoga.getComputedHeight();
left.value = node.yoga.getComputedLeft();
top.value = node.yoga.getComputedTop();
hasMeasured.value = true;
if (!node) {
// Reset to zeros when detached
const changed =
width.value !== 0 || height.value !== 0 || left.value !== 0 || top.value !== 0;
if (changed) {
width.value = 0;
height.value = 0;
left.value = 0;
top.value = 0;
}
if (hasMeasured.value) hasMeasured.value = false;
return;
}
const w = node.yoga.getComputedWidth();
const h = node.yoga.getComputedHeight();
const l = node.yoga.getComputedLeft();
const t = node.yoga.getComputedTop();
// Only update refs if values actually changed (avoids unnecessary re-renders)
if (width.value !== w) width.value = w;
if (height.value !== h) height.value = h;
if (left.value !== l) left.value = l;
if (top.value !== t) top.value = t;
if (!hasMeasured.value) hasMeasured.value = true;
}
// Track the current layout listener unsubscribe function so we can
// re-subscribe when the ref changes (and the root node might differ).
let removeLayoutListener: (() => void) | undefined;
function subscribeToLayout() {
// Clean up previous subscription
if (removeLayoutListener) {
removeLayoutListener();
removeLayoutListener = undefined;
}
const tuiNode = resolveTuiNode(ref.value);
const root = findRootNode(tuiNode);
if (!root) return;
removeLayoutListener = addLayoutListener(root, updateMetrics);
}
// Re-measure after each render commit. watchPostEffect triggers when the
@@ -105,12 +169,43 @@ export function useBoxMetrics(ref: Ref<unknown>): UseBoxMetricsResult {
// inside the commit scheduler's queuePostFlushCb, which may run after this
// watcher in the same flush cycle. We use nextTick to defer the read so
// that it runs after the scheduler's commit has called calculateLayout.
watchPostEffect(() => {
//
// This also re-subscribes to the layout listener in case the ref moved
// to a different node (and thus potentially a different root).
watchPostEffect((onCleanup) => {
// Access ref.value to track the dependency — when the ref changes,
// this effect re-runs and schedules a new measurement.
const node = resolveYogaNode(ref.value);
if (!node) return;
void nextTick(measure);
if (!node) {
// Detached: reset metrics and clean up listener
const changed =
width.value !== 0 || height.value !== 0 || left.value !== 0 || top.value !== 0;
if (changed) {
width.value = 0;
height.value = 0;
left.value = 0;
top.value = 0;
}
if (hasMeasured.value) hasMeasured.value = false;
if (removeLayoutListener) {
removeLayoutListener();
removeLayoutListener = undefined;
}
return;
}
// Subscribe (or re-subscribe) to layout listener
subscribeToLayout();
// Defer the initial read to after calculateLayout runs
void nextTick(updateMetrics);
onCleanup(() => {
if (removeLayoutListener) {
removeLayoutListener();
removeLayoutListener = undefined;
}
});
});
return { width, height, left, top, hasMeasured };
+21
View File
@@ -35,6 +35,8 @@ export interface TuiRoot extends NodeBase {
previousStaticNode?: TuiStatic;
/** Callback invoked when the <Static> identity changes (mount/unmount/remount). */
onStaticChange?: () => void;
/** Listeners invoked after every layout calculation (yoga.calculateLayout). */
layoutListeners: Set<() => void>;
}
export interface TuiBox extends NodeBase {
@@ -106,9 +108,28 @@ export function createRoot(appContext: AppContext): TuiRoot {
children: [],
yoga: UNATTACHED_YOGA,
appContext,
layoutListeners: new Set(),
};
}
/**
* Register a callback to be invoked after every layout calculation.
* Returns an unsubscribe function.
*/
export function addLayoutListener(root: TuiRoot, listener: () => void): () => void {
root.layoutListeners.add(listener);
return () => {
root.layoutListeners.delete(listener);
};
}
/** Invoke all registered layout listeners. Called after `yoga.calculateLayout`. */
export function emitLayoutListeners(root: TuiRoot): void {
for (const listener of root.layoutListeners) {
listener();
}
}
export function createBox(): TuiBox {
return {
type: "box",
+20 -4
View File
@@ -7,6 +7,7 @@ import { createRoot, type TuiNode } from "./host/nodes.ts";
import { attachYoga, detachYoga } from "./host/yoga.ts";
import { buildNodeOps } from "./host/node-ops.ts";
import { paint, paintIsolated } from "./paint/paint.ts";
import { renderScreenReaderOutput } from "./paint/screen-reader.ts";
import { findStatics } from "./paint/static-channel.ts";
import {
AppContextKey,
@@ -24,6 +25,13 @@ export interface RenderToStringOptions {
* @default 80
*/
columns?: number;
/**
* Enable screen reader mode. When enabled, the output is plain text
* suitable for screen readers (no ANSI styling, with role/state annotations).
*
* @default false
*/
isScreenReaderEnabled?: boolean;
}
/**
@@ -48,9 +56,10 @@ export interface RenderToStringOptions {
*/
export function renderToString(component: Component, options?: RenderToStringOptions): string {
const columns = options?.columns ?? 80;
const isScreenReaderEnabled = options?.isScreenReaderEnabled ?? false;
// Create a standalone root node --- no stdout, stdin, or terminal bindings.
const appContext = createNoOpAppContext();
const appContext = createNoOpAppContext(isScreenReaderEnabled);
const root = createRoot(appContext);
attachYoga(root);
root.yoga.setWidth(columns);
@@ -105,7 +114,9 @@ export function renderToString(component: Component, options?: RenderToStringOpt
root.yoga.calculateLayout(columns, undefined, Yoga.DIRECTION_LTR);
// Render the dynamic frame to a string.
const output = paint(root);
const output = isScreenReaderEnabled
? renderScreenReaderOutput(root, { skipStaticElements: true })
: paint(root);
// Tear down: unmount the tree so Vue cleans up child nodes and runs
// effect cleanup functions. Child yoga nodes are freed by the node-ops
@@ -122,6 +133,11 @@ export function renderToString(component: Component, options?: RenderToStringOpt
throw uncaughtError instanceof Error ? uncaughtError : new Error(String(uncaughtError));
}
// Screen reader mode returns plain text directly — no static channel.
if (isScreenReaderEnabled) {
return output;
}
// The static channel appends a trailing newline for terminal rendering
// (so dynamic output starts on a fresh line). Strip it here so
// renderToString returns clean output.
@@ -149,7 +165,7 @@ export function renderToString(component: Component, options?: RenderToStringOpt
}
}
function createNoOpAppContext(): AppContext {
function createNoOpAppContext(isScreenReaderEnabled = false): AppContext {
return {
exit: () => {},
stdout: process.stdout,
@@ -157,7 +173,7 @@ function createNoOpAppContext(): AppContext {
stdin: process.stdin,
debug: false,
interactive: false,
isScreenReaderEnabled: false,
isScreenReaderEnabled,
isRawModeSupported: false,
setRawMode: () => {},
writeToStdout: () => {},
+3 -1
View File
@@ -16,7 +16,7 @@ import patchConsoleFn from "patch-console";
import ansiEscapes from "ansi-escapes";
import { createInputParser, type InputEvent } from "./io/input-parser.ts";
import { createKittyKeyboardController, type KittyKeyboardOptions } from "./io/kitty-keyboard.ts";
import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts";
import { createRoot, emitLayoutListeners, type TuiRoot, type TuiNode } from "./host/nodes.ts";
import { attachYoga, detachYoga } from "./host/yoga.ts";
import { buildNodeOps } from "./host/node-ops.ts";
import { createCommitScheduler } from "./scheduler.ts";
@@ -535,6 +535,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
tuiRoot.yoga.setWidth(w);
tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR);
emitLayoutListeners(tuiRoot);
const frame = paint(tuiRoot);
frameState.lastOutput = frame;
frameState.lastOutputToRender = frame + "\n";
@@ -545,6 +546,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
tuiRoot.yoga.setWidth(w);
tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR);
emitLayoutListeners(tuiRoot);
const frame = paint(tuiRoot);
const outputHeight = frame === "" ? 0 : frame.split("\n").length;