Skip to content

dynamic slots are untyped #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
sandros94 opened this issue Apr 11, 2025 · 3 comments
Closed

dynamic slots are untyped #23

sandros94 opened this issue Apr 11, 2025 · 3 comments
Labels
bug Something isn't working

Comments

@sandros94
Copy link

🐛 The bug

We found another bug while working on Nuxt UI, and this one is even harder to provide a native vue-sfc-transformer reproduction (in fact I'm not even able to make the linked one run at all).

Nuxt UI has a number of components that other than static slots (eg: label, content, leading, etc..) also provides dynamic slots, meaning that you can directly access a specific item in your input array.

For example using UTabs:

<script setup lang="ts">
import type { TabsItem } from '@nuxt/ui'

const tabs = [
  {
    label: 'Tab 1',
    slot: 'tab1' as const,
    content: 'Content 1'
  },
  {
    label: 'Tab 2',
    slot: 'tab2' as const,
    content: 'Content 2'
  },
  {
    label: 'Tab 3',
    slot: 'tab3' as const,
    content: 'Content 3'
  }
] satisfies TabsItem[]
</script>

<template>
  <UTabs :items="tabs">
    <template #tab1="{ item }">
      {{ item.content }}
    </template>
  </UTabs>
</template>

but once parsed with vue-sfc-transformer it no longer works, and item falls back to the default TabsItem type (screenshot from an actual project installing @nuxt/ui)

Image

🛠️ To reproduce

https://stackblitz.com/edit/github-sadsa3hd?file=index.js

🌈 Expected behaviour

should provide the following auto-complete and correctly type item (these screenshots are from within the @nuxt/ui playground)

Image

Image

ℹ️ Additional context

I'm not using the latest @nuxt/ui release as I was fixing other stuff related to dynamic slots, please use the latest CI https://pkg.pr.new/@nuxt/ui@9817465 (coming from nuxt/ui#3857)

I also tried directly accessing the types and they do seem to work (screenshot from an actual project installing @nuxt/ui)
Image

src

  • TabsSlots type

  • DynamicSlots utility

  • currently generated Tabs.vue.d.ts
    import type { VariantProps } from 'tailwind-variants';
    import type { TabsRootProps, TabsRootEmits } from 'reka-ui';
    import type { AvatarProps } from '../types';
    import type { DynamicSlots, PartialString } from '../types/utils';
    declare const tabs: import("tailwind-variants").TVReturnType<{
        color: {
            primary: string;
            secondary: string;
            success: string;
            info: string;
            warning: string;
            error: string;
            neutral: string;
        };
        variant: {
            pill: {
                list: string;
                trigger: string;
                indicator: string;
            };
            link: {
                list: string;
                indicator: string;
            };
        };
        orientation: {
            horizontal: {
                root: string;
                list: string;
                indicator: string;
                trigger: string;
            };
            vertical: {
                list: string;
                indicator: string;
            };
        };
        size: {
            xs: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            sm: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            md: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            lg: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            xl: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
        };
    }, {
        root: string;
        list: string;
        indicator: string;
        trigger: string[];
        content: string;
        leadingIcon: string;
        leadingAvatar: string;
        leadingAvatarSize: string;
        label: string;
    }, undefined, {
        color: {
            primary: string;
            secondary: string;
            success: string;
            info: string;
            warning: string;
            error: string;
            neutral: string;
        };
        variant: {
            pill: {
                list: string;
                trigger: string;
                indicator: string;
            };
            link: {
                list: string;
                indicator: string;
            };
        };
        orientation: {
            horizontal: {
                root: string;
                list: string;
                indicator: string;
                trigger: string;
            };
            vertical: {
                list: string;
                indicator: string;
            };
        };
        size: {
            xs: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            sm: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            md: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            lg: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            xl: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
        };
    }, {
        root: string;
        list: string;
        indicator: string;
        trigger: string[];
        content: string;
        leadingIcon: string;
        leadingAvatar: string;
        leadingAvatarSize: string;
        label: string;
    }, import("tailwind-variants").TVReturnType<{
        color: {
            primary: string;
            secondary: string;
            success: string;
            info: string;
            warning: string;
            error: string;
            neutral: string;
        };
        variant: {
            pill: {
                list: string;
                trigger: string;
                indicator: string;
            };
            link: {
                list: string;
                indicator: string;
            };
        };
        orientation: {
            horizontal: {
                root: string;
                list: string;
                indicator: string;
                trigger: string;
            };
            vertical: {
                list: string;
                indicator: string;
            };
        };
        size: {
            xs: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            sm: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            md: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            lg: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            xl: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
        };
    }, {
        root: string;
        list: string;
        indicator: string;
        trigger: string[];
        content: string;
        leadingIcon: string;
        leadingAvatar: string;
        leadingAvatarSize: string;
        label: string;
    }, undefined, {
        color: {
            primary: string;
            secondary: string;
            success: string;
            info: string;
            warning: string;
            error: string;
            neutral: string;
        };
        variant: {
            pill: {
                list: string;
                trigger: string;
                indicator: string;
            };
            link: {
                list: string;
                indicator: string;
            };
        };
        orientation: {
            horizontal: {
                root: string;
                list: string;
                indicator: string;
                trigger: string;
            };
            vertical: {
                list: string;
                indicator: string;
            };
        };
        size: {
            xs: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            sm: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            md: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            lg: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            xl: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
        };
    }, {
        root: string;
        list: string;
        indicator: string;
        trigger: string[];
        content: string;
        leadingIcon: string;
        leadingAvatar: string;
        leadingAvatarSize: string;
        label: string;
    }, import("tailwind-variants").TVReturnType<{
        color: {
            primary: string;
            secondary: string;
            success: string;
            info: string;
            warning: string;
            error: string;
            neutral: string;
        };
        variant: {
            pill: {
                list: string;
                trigger: string;
                indicator: string;
            };
            link: {
                list: string;
                indicator: string;
            };
        };
        orientation: {
            horizontal: {
                root: string;
                list: string;
                indicator: string;
                trigger: string;
            };
            vertical: {
                list: string;
                indicator: string;
            };
        };
        size: {
            xs: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            sm: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            md: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            lg: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
            xl: {
                trigger: string;
                leadingIcon: string;
                leadingAvatarSize: string;
            };
        };
    }, {
        root: string;
        list: string;
        indicator: string;
        trigger: string[];
        content: string;
        leadingIcon: string;
        leadingAvatar: string;
        leadingAvatarSize: string;
        label: string;
    }, undefined, unknown, unknown, undefined>>>;
    export interface TabsItem {
        label?: string;
        /**
         * @IconifyIcon
         */
        icon?: string;
        avatar?: AvatarProps;
        slot?: string;
        content?: string;
        /** A unique value for the tab item. Defaults to the index. */
        value?: string | number;
        disabled?: boolean;
        [key: string]: any;
    }
    type TabsVariants = VariantProps<typeof tabs>;
    export interface TabsProps<T extends TabsItem = TabsItem> extends Pick<TabsRootProps<string | number>, 'defaultValue' | 'modelValue' | 'activationMode' | 'unmountOnHide'> {
        /**
         * The element or component this component should render as.
         * @defaultValue 'div'
         */
        as?: any;
        items?: T[];
        /**
         * @defaultValue 'primary'
         */
        color?: TabsVariants['color'];
        /**
         * @defaultValue 'pill'
         */
        variant?: TabsVariants['variant'];
        /**
         * @defaultValue 'md'
         */
        size?: TabsVariants['size'];
        /**
         * The orientation of the tabs.
         * @defaultValue 'horizontal'
         */
        orientation?: TabsRootProps['orientation'];
        /**
         * The content of the tabs, can be disabled to prevent rendering the content.
         * @defaultValue true
         */
        content?: boolean;
        /**
         * The key used to get the label from the item.
         * @defaultValue 'label'
         */
        labelKey?: string;
        class?: any;
        ui?: PartialString<typeof tabs.slots>;
    }
    export interface TabsEmits extends TabsRootEmits<string | number> {
    }
    type SlotProps<T extends TabsItem> = (props: {
        item: T;
        index: number;
    }) => any;
    export type TabsSlots<T extends TabsItem = TabsItem> = {
        'leading': SlotProps<T>;
        'default': SlotProps<T>;
        'trailing': SlotProps<T>;
        'content': SlotProps<T>;
        'list-leading': (props?: {}) => any;
        'list-trailing': (props?: {}) => any;
    } & DynamicSlots<T, undefined, {
        index: number;
    }>;
    declare const _default: <T extends TabsItem>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>>["props"], __VLS_ctx?: __VLS_PrettifyLocal<Pick<NonNullable<Awaited<typeof __VLS_setup>>, "attrs" | "emit" | "slots">>, __VLS_expose?: NonNullable<Awaited<typeof __VLS_setup>>["expose"], __VLS_setup?: Promise<{
        props: __VLS_PrettifyLocal<any & TabsProps<T> & Partial<{}>> & (import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps);
        expose(exposed: import("vue").ShallowUnwrapRef<{}>): void;
        attrs: any;
        slots: Readonly<{
            leading: SlotProps<T>;
            default: SlotProps<T>;
            trailing: SlotProps<T>;
            content: SlotProps<T>;
            'list-leading': (props?: {}) => any;
            'list-trailing': (props?: {}) => any;
        } & { [K in NonNullable<T["slot"]> as K extends string ? K : never]: (props: {
            item: Extract<T, {
                slot: K;
            }>;
        } & {
            index: number;
        }) => any; } & {
            [key: string]: (props: {
                item: T;
            } & {
                index: number;
            }) => any;
        }> & {
            leading: SlotProps<T>;
            default: SlotProps<T>;
            trailing: SlotProps<T>;
            content: SlotProps<T>;
            'list-leading': (props?: {}) => any;
            'list-trailing': (props?: {}) => any;
        } & { [K in NonNullable<T["slot"]> as K extends string ? K : never]: (props: {
            item: Extract<T, {
                slot: K;
            }>;
        } & {
            index: number;
        }) => any; } & {
            [key: string]: (props: {
                item: T;
            } & {
                index: number;
            }) => any;
        };
        emit: (evt: "update:modelValue", payload: string | number) => void;
    }>) => import("vue").VNode & {
        __ctx?: Awaited<typeof __VLS_setup>;
    };
    export default _default;
    type __VLS_PrettifyLocal<T> = {
        [K in keyof T]: T[K];
    } & {};
@sandros94 sandros94 added the bug Something isn't working label Apr 11, 2025
@Teages
Copy link
Collaborator

Teages commented Apr 11, 2025

I think it is a upstream problem, the module has no affect to the generation of .vue.d.ts.

The issue should be tracked in mkdist or vuejs/language-tools


Update: there was a fix vuejs/language-tools#5247

@Teages
Copy link
Collaborator

Teages commented Apr 11, 2025

As for the reproduce, the playground not the correct way to use vue-sfc-transformer .

I have updated the playground, your reproduce should work fine in the new playground, feel free to reopen it if you found the output were not expected.

updated reproduce
import { parse as parseSFC } from '@vue/compiler-sfc'
import { transform } from 'esbuild'

import { preTranspileScriptSetup, transpileVueTemplate } from 'vue-sfc-transformer'

const src = `
<script lang="ts">
export type DynamicSlots<
  T extends { slot?: string },
  S extends string | undefined = undefined,
  D extends object = {}
> = {
  [
  K in NonNullable<T['slot']> as K extends string
    ? S extends undefined
      ? K
      : (K | \`\${K}-\${S & string}\`)
    : never
  ]: (props: {
    item: Extract<T, { slot: S extends undefined
      ? K
      : K extends \`\${infer Base}-\${S & string}\`
        ? Base
        : K }>
  } & D) => any
} & {
  [key: string]: (props: { item: T } & D) => any
}

export interface Item {
  label?: string
  slot?: string
  [key: string]: any
}

export type Slots<T extends Item = Item> = {
  test: (props: { item: T, index: number }) => any
} & DynamicSlots<T, undefined, { index: number }>
</script>

<script setup lang="ts" generic="T extends Item">
defineProps<{
  items: T[]
}>()
defineSlots<Slots<T>>()
</script>

<template>
  <div v-if="(items as any)">
    {{ items }}
  </div>
</template>
`

const sfc = parseSFC(src, {
  filename: 'test.vue',
  ignoreEmpty: true,
})

// transpile template block
const templateBlockContents = await transpileVueTemplate(
  sfc.descriptor.template.content,
  sfc.descriptor.template.ast,
  sfc.descriptor.template.loc.start.offset,
  async (code) => {
    const res = await transform(code, { loader: 'ts', target: 'esnext' })
    return res.code
  },
)
console.log(`transpiled <template> block:`)
console.log(`\`\`\`\n<template>${templateBlockContents}</template>\n\`\`\`\n`)

// transpile script block
// notice: it is still in typescript, you need to transpile it to javascript later
const { content: scriptBlockContents } = await preTranspileScriptSetup(sfc.descriptor, 'test.vue')
console.log(`transpiled <script setup> block:`)
console.log(`\`\`\`\n<script setup lang="ts">${scriptBlockContents}</script>\n\`\`\`\n`)

output:

➜  vue-sfc-transformer git:(main) pnpm -C playground dev

> @ dev <~>/vue-sfc-transformer/playground
> node index.js

transpiled <template> block:
```
<template>
  <div v-if="items">
    {{ items }}
  </div>
</template>
```

transpiled <script setup> block:
```
<script setup lang="ts">
defineProps({
    items: { type: Array, required: true }
  })
defineSlots<Slots<T>>()
</script>
```

@Teages Teages closed this as completed Apr 11, 2025
@Teages Teages closed this as not planned Won't fix, can't repro, duplicate, stale Apr 11, 2025
@sandros94
Copy link
Author

Makes sense. Will dig into it a bit more, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants