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:
@@ -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?.());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user