feat: add useBoxMetrics composable and measureElement function

- useBoxMetrics returns reactive { width, height, left, top, hasMeasured }
- measureElement returns imperative { width, height } snapshot
- Handles Vue component instance refs via $el resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 16:08:30 +08:00
parent 95ba2f5796
commit ca8ba65300
3 changed files with 240 additions and 0 deletions
@@ -0,0 +1,117 @@
import { defineComponent, nextTick, ref, shallowRef, watchEffect } from "vue";
import { describe, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, useBoxMetrics, measureElement } from "@vue-tui/runtime";
describe("useBoxMetrics", () => {
test("returns layout dimensions after render", async () => {
const dims = shallowRef({ w: 0, h: 0 });
const App = defineComponent(() => {
const boxRef = ref(null);
const metrics = useBoxMetrics(boxRef);
watchEffect(() => {
dims.value = { w: metrics.width.value, h: metrics.height.value };
});
return () => (
<Box ref={boxRef} width={20} height={5}>
<Text>test</Text>
</Box>
);
});
await render(App);
// useBoxMetrics defers measurement to nextTick after the commit
await nextTick();
expect(dims.value.w).toBe(20);
expect(dims.value.h).toBe(5);
});
test("returns left and top positions", async () => {
const pos = shallowRef({ l: -1, t: -1 });
const App = defineComponent(() => {
const boxRef = ref(null);
const metrics = useBoxMetrics(boxRef);
watchEffect(() => {
pos.value = { l: metrics.left.value, t: metrics.top.value };
});
return () => (
<Box width={40} height={10}>
<Box ref={boxRef} width={20} height={5}>
<Text>inner</Text>
</Box>
</Box>
);
});
await render(App);
await nextTick();
expect(pos.value.l).toBe(0);
expect(pos.value.t).toBe(0);
});
test("hasMeasured starts false", async () => {
let measured = false;
const App = defineComponent(() => {
const boxRef = ref(null);
const metrics = useBoxMetrics(boxRef);
// During setup, hasMeasured is false (layout not computed yet)
measured = metrics.hasMeasured.value;
return () => (
<Box ref={boxRef} width={10} height={3}>
<Text>x</Text>
</Box>
);
});
await render(App);
// During setup, hasMeasured was false
expect(measured).toBe(false);
});
test("hasMeasured becomes true after layout", async () => {
const hasMeasuredRef = shallowRef(false);
const App = defineComponent(() => {
const boxRef = ref(null);
const metrics = useBoxMetrics(boxRef);
watchEffect(() => {
hasMeasuredRef.value = metrics.hasMeasured.value;
});
return () => (
<Box ref={boxRef} width={10} height={3}>
<Text>x</Text>
</Box>
);
});
await render(App);
await nextTick();
expect(hasMeasuredRef.value).toBe(true);
});
});
describe("measureElement", () => {
test("returns { width: 0, height: 0 } for null", () => {
expect(measureElement(null)).toEqual({ width: 0, height: 0 });
});
test("returns { width: 0, height: 0 } for undefined", () => {
expect(measureElement(undefined)).toEqual({ width: 0, height: 0 });
});
test("returns { width: 0, height: 0 } for object without yoga", () => {
expect(measureElement({})).toEqual({ width: 0, height: 0 });
});
test("returns dimensions from a node with yoga property", () => {
const fakeYoga = {
getComputedWidth: () => 42,
getComputedHeight: () => 17,
};
expect(measureElement({ yoga: fakeYoga })).toEqual({ width: 42, height: 17 });
});
test("resolves through $el for component instances", () => {
const fakeYoga = {
getComputedWidth: () => 30,
getComputedHeight: () => 10,
};
const fakeInstance = { $el: { yoga: fakeYoga } };
expect(measureElement(fakeInstance)).toEqual({ width: 30, height: 10 });
});
});
@@ -0,0 +1,117 @@
import { nextTick, shallowRef, watchPostEffect, type Ref, type ShallowRef } from "vue";
import type { Node as YogaNode } from "yoga-layout";
// Yoga's `right`/`bottom` are omitted: always `0` for flow layout and
// unintuitive for absolute positioning. Matches Ink's BoxMetrics type.
export interface BoxMetrics {
/** Element width. */
readonly width: number;
/** Element height. */
readonly height: number;
/** Distance from the left edge of the parent. */
readonly left: number;
/** Distance from the top edge of the parent. */
readonly top: number;
}
export interface UseBoxMetricsResult {
/** Reactive element width. */
readonly width: ShallowRef<number>;
/** Reactive element height. */
readonly height: ShallowRef<number>;
/** Reactive distance from the left edge of the parent. */
readonly left: ShallowRef<number>;
/** Reactive distance from the top edge of the parent. */
readonly top: ShallowRef<number>;
/** Whether the currently tracked element has been measured in the latest layout pass. */
readonly hasMeasured: ShallowRef<boolean>;
}
/**
* Resolve a ref value to the underlying TUI node with a yoga property.
* Handles both direct TUI node refs and Vue component instance refs (where
* the TUI node is accessible via `$el`).
*/
function resolveYogaNode(value: unknown): { yoga: YogaNode } | null {
if (!value) return null;
const obj = value as Record<string, unknown>;
// Direct TUI node (e.g. from host element ref)
if (obj.yoga) return obj as { yoga: YogaNode };
// Vue component instance — root host element is on $el
if (obj.$el && (obj.$el as Record<string, unknown>).yoga) {
return obj.$el as { yoga: YogaNode };
}
return null;
}
/**
* Imperative function that reads yoga computed dimensions from a TUI node.
*
* Returns `{ width: 0, height: 0 }` before layout (when yoga node doesn't
* exist or hasn't been calculated).
*
* Note: `measureElement()` returns `{width: 0, height: 0}` when called during
* render (before layout is calculated). Call it from post-render code, such as
* `watchPostEffect`, `onMounted`, input handlers, or timer callbacks.
*/
export function measureElement(node: unknown): { width: number; height: number } {
const tuiNode = resolveYogaNode(node);
if (!tuiNode) return { width: 0, height: 0 };
return {
width: tuiNode.yoga.getComputedWidth() ?? 0,
height: tuiNode.yoga.getComputedHeight() ?? 0,
};
}
/**
* Reactive composable that returns computed layout metrics for a tracked box element.
* Updates after each render commit when yoga layout has been calculated.
*
* Returns `{ width, height, left, top, hasMeasured }` where all values are
* reactive refs. `hasMeasured` starts `false` and becomes `true` after the
* first layout pass.
*
* @example
* ```tsx
* const boxRef = ref(null);
* const { width, height, left, top, hasMeasured } = useBoxMetrics(boxRef);
* return () => (
* <Box ref={boxRef}>
* <Text>{hasMeasured.value ? `${width.value}x${height.value}` : "Measuring..."}</Text>
* </Box>
* );
* ```
*/
export function useBoxMetrics(ref: Ref<unknown>): UseBoxMetricsResult {
const width = shallowRef(0);
const height = shallowRef(0);
const left = shallowRef(0);
const top = shallowRef(0);
const hasMeasured = shallowRef(false);
function measure() {
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;
}
// Re-measure after each render commit. watchPostEffect triggers when the
// ref changes (component mount / unmount). The yoga layout is calculated
// 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(() => {
// 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);
});
return { width, height, left, top, hasMeasured };
}
+6
View File
@@ -17,4 +17,10 @@ export { useStdin } from "./composables/useStdin.ts";
export { useStdout } from "./composables/useStdout.ts";
export { useStderr } from "./composables/useStderr.ts";
export { useTerminalSize } from "./composables/useTerminalSize.ts";
export {
useBoxMetrics,
measureElement,
type BoxMetrics,
type UseBoxMetricsResult,
} from "./composables/useBoxMetrics.ts";
export type { DevState, DevErrorInfo } from "./hmr.ts";