Files
vue-tui/packages/runtime/src/components/static.ts
T
2026-06-14 01:12:13 +08:00

110 lines
4.0 KiB
TypeScript

import {
defineComponent,
h,
shallowRef,
watch,
type ExtractPublicPropTypes,
type PropType,
type SlotsType,
type VNodeChild,
} from "vue";
import type { BoxLayoutStyle } from "./box.ts";
export interface StaticSlotProps<T = unknown> {
item: T;
index: number;
}
export type StaticSlot<T = unknown> = (props: StaticSlotProps<T>) => VNodeChild;
export type StaticChildren<T = unknown> = StaticSlot<T> | { default: StaticSlot<T> };
export type StaticStyle = BoxLayoutStyle;
const staticProps = {
// `required: true as const` (not bare `true`): a standalone `const` widens
// `true` → `boolean`, which would drop `items` from ExtractPublicPropTypes'
// required keys (and from the component's own `props.items` typing). The
// literal keeps `items` required, matching Ink's `StaticProps`.
items: { type: Array as PropType<unknown[]>, required: true as const },
style: { type: Object as PropType<StaticStyle>, default: undefined },
};
const StaticImpl = defineComponent({
name: "Static",
props: staticProps,
slots: Object as SlotsType<{
default: StaticSlot;
}>,
setup(props, { slots }) {
const defaultStyle: StaticStyle = {
position: "absolute",
flexDirection: "column",
};
// Mirrors Ink's `const [index, setIndex] = useState(0)`. Only items at or
// after `cursor` are rendered; once written, the renderer advances the
// cursor (via the onWritten callback below) so written items unmount.
// shallowRef is sufficient — we only ever reassign the number.
const cursor = shallowRef(0);
// Invoked by the renderer AFTER a commit has painted the freshly-written
// items. Together with the watch below this is the vue-tui analogue of Ink's
// post-commit `useLayoutEffect(() => setIndex(items.length), [items.length])`.
//
// This callback handles the GROW / steady-state direction: advancing the
// cursor only AFTER paint guarantees freshly-appended items are written
// before they are sliced out and unmounted. It must run post-paint, never
// during render, which is why it can't be a plain watcher. We SET the cursor
// to items.length (not max-with-current); assigning an equal number is a
// reactivity no-op (Vue triggers on Object.is inequality), so the common
// resync-to-same-length case can't loop.
const onWritten = () => {
cursor.value = (props.items as unknown[]).length;
};
// Handles the SHRINK direction, mirroring Ink's effect firing on every
// [items.length] change — including decreases. When items shrink, the
// already-rendered Static children may already be empty (sliced out), so no
// host mutation occurs and no commit/onWritten fires; the cursor would stay
// stranded above the new length and silently drop any later-appended items
// (e.g. [A,B] cursor→2, shrink to [A], grow to [A,C] → slice(2)=[] drops C).
// Lowering the cursor on shrink is safe without waiting for a paint: shrinking
// never needs to write anything, it only re-syncs the slice window down.
watch(
() => (props.items as unknown[]).length,
(len) => {
if (len < cursor.value) cursor.value = len;
},
);
return () => {
const merged = { ...defaultStyle, ...props.style };
const items = props.items as unknown[];
const start = cursor.value;
const itemsToRender = items.slice(start);
return h(
"static",
{ ...merged, internal_onWritten: onWritten },
itemsToRender.map((item, i) => slots.default?.({ item, index: start + i })),
);
};
},
});
/** Props accepted by `<Static>` — the vue-tui analogue of Ink's `StaticProps`. */
type StaticBaseProps = ExtractPublicPropTypes<typeof staticProps>;
export type StaticProps<T = unknown> = Omit<StaticBaseProps, "items"> & {
items: T[];
};
export const Static = StaticImpl as typeof StaticImpl & {
new <T = unknown>(): {
$props: StaticProps<T> & { children?: StaticChildren<T> };
$slots: {
default?: StaticSlot<T>;
};
};
};