From dab5125c90e934a00e5901fd2c6222c2063f4f01 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 13 Jun 2026 23:08:24 +0800 Subject: [PATCH] fix(runtime): type Static scoped slots --- .../pty/fixtures/jsx-children-types.tsx | 34 +++++++++++++- .../integration/public-types.test-d.ts | 21 +++++++++ packages/runtime/src/components/box.ts | 46 +++++++++++++++++++ packages/runtime/src/components/spacer.ts | 14 +++++- packages/runtime/src/components/static.ts | 39 +++++++++++++--- .../runtime/src/components/with-children.ts | 8 +++- packages/runtime/src/index.ts | 12 ++++- 7 files changed, 161 insertions(+), 13 deletions(-) diff --git a/packages/runtime-tests/integration/pty/fixtures/jsx-children-types.tsx b/packages/runtime-tests/integration/pty/fixtures/jsx-children-types.tsx index c576f46..53f784f 100644 --- a/packages/runtime-tests/integration/pty/fixtures/jsx-children-types.tsx +++ b/packages/runtime-tests/integration/pty/fixtures/jsx-children-types.tsx @@ -11,7 +11,7 @@ * - children rejected -> the `accepted` cases error * - prop validation lost -> the `@ts-expect-error` directives become unused */ -import { Box, Static, Text, Transform } from "@vue-tui/runtime"; +import { Box, Newline, Spacer, Static, Text, Transform } from "@vue-tui/runtime"; // Children are accepted (the shim's whole purpose under the automatic runtime). export const accepted = [ @@ -20,13 +20,27 @@ export const accepted = [ nested child , - static child + {({ item, index }) => { + const renderedItem = item.toFixed(0); + const renderedIndex = index.toFixed(0); + return ( + + {renderedIndex}:{renderedItem} + + ); + }} , line}> transformed child , + , + , ]; +const invalidScopedTransformSlot = { + default: ({ item }: { item: string }) => {item}, +}; + // Declared props stay validated — children must not widen the prop bag. export const rejected = [ // @ts-expect-error `display` accepts "flex" | "none", not a number @@ -39,4 +53,20 @@ export const rejected = [ x, // @ts-expect-error `items` is required x, + // @ts-expect-error `` has an ordinary default slot, not a scoped slot + {({ item }: { item: string }) => item}, + // @ts-expect-error `` has no named `foo` slot + {{ foo: () => x }}, + // @ts-expect-error `` has an ordinary default slot, not a scoped slot + line}>{invalidScopedTransformSlot}, + // @ts-expect-error `` does not accept children + x, + // @ts-expect-error `` does not accept children + x, + // @ts-expect-error `` children are Vue scoped slots: one `{ item, index }` object + {(item: number, index: number) => {item + index}}, + // @ts-expect-error `style` is the supported layout style surface, not an arbitrary object + + {({ item }) => {item}} + , ]; diff --git a/packages/runtime-tests/integration/public-types.test-d.ts b/packages/runtime-tests/integration/public-types.test-d.ts index 84a7fd8..2e022ea 100644 --- a/packages/runtime-tests/integration/public-types.test-d.ts +++ b/packages/runtime-tests/integration/public-types.test-d.ts @@ -16,10 +16,16 @@ import { shallowRef } from "vue"; import { useApp, useInput, usePaste, useStdin, useStdout, useStderr } from "@vue-tui/runtime"; import type { BoxProps, + BoxLayoutStyle, TextProps, + StaticChildren, StaticProps, + StaticSlot, + StaticSlotProps, + StaticStyle, TransformProps, NewlineProps, + SpacerProps, Key, WindowSize, CursorPosition, @@ -41,10 +47,25 @@ expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); +expectTypeOf["items"]>().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf<{ item: string; index: number }>(); +expectTypeOf>().toEqualTypeOf< + (props: StaticSlotProps) => import("vue").VNodeChild +>(); +expectTypeOf>().toEqualTypeOf< + | StaticSlot + | { + default: StaticSlot; + } +>(); expectTypeOf().toEqualTypeOf< (line: string, lineIndex: number) => string >(); expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); // Framework-neutral data shapes, mirrored from Ink exactly. expectTypeOf().toEqualTypeOf<{ readonly columns: number; readonly rows: number }>(); diff --git a/packages/runtime/src/components/box.ts b/packages/runtime/src/components/box.ts index 0e5ec51..c6910b5 100644 --- a/packages/runtime/src/components/box.ts +++ b/packages/runtime/src/components/box.ts @@ -38,6 +38,52 @@ type BorderStyle = // Exported so consumers can type their custom border objects (Ink parity, G13). export type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes]; +export type BoxLayoutStyle = Pick< + BoxProps, + | "flexDirection" + | "flexGrow" + | "flexShrink" + | "flexBasis" + | "flexWrap" + | "alignItems" + | "alignSelf" + | "justifyContent" + | "gap" + | "columnGap" + | "rowGap" + | "width" + | "height" + | "minWidth" + | "minHeight" + | "maxWidth" + | "maxHeight" + | "aspectRatio" + | "alignContent" + | "position" + | "top" + | "right" + | "bottom" + | "left" + | "margin" + | "marginX" + | "marginY" + | "marginTop" + | "marginBottom" + | "marginLeft" + | "marginRight" + | "padding" + | "paddingX" + | "paddingY" + | "paddingTop" + | "paddingBottom" + | "paddingLeft" + | "paddingRight" + | "overflow" + | "overflowX" + | "overflowY" + | "display" +>; + export type AriaRole = | "button" | "checkbox" diff --git a/packages/runtime/src/components/spacer.ts b/packages/runtime/src/components/spacer.ts index 02a43e4..f8db033 100644 --- a/packages/runtime/src/components/spacer.ts +++ b/packages/runtime/src/components/spacer.ts @@ -1,8 +1,18 @@ -import { defineComponent, h } from "vue"; +import { defineComponent, h, type ExtractPublicPropTypes } from "vue"; -export const Spacer = defineComponent({ +const spacerProps = {}; + +const SpacerImpl = defineComponent({ name: "Spacer", + props: spacerProps, setup() { return () => h("box", { flexGrow: 1, flexShrink: 1 }); }, }); + +/** Props accepted by `` — the vue-tui analogue of Ink's `SpacerProps`. */ +export type SpacerProps = ExtractPublicPropTypes; + +export const Spacer = SpacerImpl as typeof SpacerImpl & { + new (): { $props: SpacerProps & { children?: never } }; +}; diff --git a/packages/runtime/src/components/static.ts b/packages/runtime/src/components/static.ts index fa6d165..b941453 100644 --- a/packages/runtime/src/components/static.ts +++ b/packages/runtime/src/components/static.ts @@ -5,8 +5,21 @@ import { watch, type ExtractPublicPropTypes, type PropType, + type SlotsType, + type VNodeChild, } from "vue"; -import type { WithChildren } from "./with-children.ts"; +import type { BoxLayoutStyle } from "./box.ts"; + +export interface StaticSlotProps { + item: T; + index: number; +} + +export type StaticSlot = (props: StaticSlotProps) => VNodeChild; + +export type StaticChildren = StaticSlot | { default: StaticSlot }; + +export type StaticStyle = BoxLayoutStyle; const staticProps = { // `required: true as const` (not bare `true`): a standalone `const` widens @@ -14,14 +27,17 @@ const staticProps = { // 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, required: true as const }, - style: { type: Object as PropType>, default: undefined }, + style: { type: Object as PropType, default: undefined }, }; const StaticImpl = defineComponent({ name: "Static", props: staticProps, + slots: Object as SlotsType<{ + default: StaticSlot; + }>, setup(props, { slots }) { - const defaultStyle: Record = { + const defaultStyle: StaticStyle = { position: "absolute", flexDirection: "column", }; @@ -76,7 +92,18 @@ const StaticImpl = defineComponent({ }, }); -export const Static = StaticImpl as WithChildren; - /** Props accepted by `` — the vue-tui analogue of Ink's `StaticProps`. */ -export type StaticProps = ExtractPublicPropTypes; +type StaticBaseProps = ExtractPublicPropTypes; + +export type StaticProps = Omit & { + items: T[]; +}; + +export const Static = StaticImpl as typeof StaticImpl & { + new (): { + $props: StaticProps & { children?: StaticChildren }; + $slots: { + default?: StaticSlot; + }; + }; +}; diff --git a/packages/runtime/src/components/with-children.ts b/packages/runtime/src/components/with-children.ts index 324b2f8..336aeab 100644 --- a/packages/runtime/src/components/with-children.ts +++ b/packages/runtime/src/components/with-children.ts @@ -1,3 +1,9 @@ +import type { VNodeChild } from "vue"; + +export type DefaultSlot = () => VNodeChild; + +export type DefaultChildren = VNodeChild | DefaultSlot | { default: DefaultSlot }; + /** * Adds an optional `children` prop to a component's JSX `$props` so that TSX * written under the automatic runtime (`jsx: "react-jsx"` + `jsxImportSource: @@ -7,6 +13,6 @@ * that prop to the default slot at runtime, so this is a type-only shim with no * runtime effect. */ -export type WithChildren = C & { +export type WithChildren = C & { new (): { $props: { children?: T } }; }; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 31fdf2e..2fb75f1 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -5,13 +5,21 @@ export { Box, type AriaRole, type AriaState, + type BoxLayoutStyle, type BoxStyle, type BoxProps, } from "./components/box.ts"; export { Text, type TextProps } from "./components/text.ts"; export { Newline, type NewlineProps } from "./components/newline.ts"; -export { Spacer } from "./components/spacer.ts"; -export { Static, type StaticProps } from "./components/static.ts"; +export { Spacer, type SpacerProps } from "./components/spacer.ts"; +export { + Static, + type StaticChildren, + type StaticProps, + type StaticSlot, + type StaticSlotProps, + type StaticStyle, +} from "./components/static.ts"; export { Transform, type TransformProps } from "./components/transform.ts"; export { useApp, type UseAppReturn } from "./composables/useApp.ts";