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:
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user