Skip to content

Commit

Permalink
feat(volar/jsx-directive): support type for v-model (#584)
Browse files Browse the repository at this point in the history
  • Loading branch information
zhiyuanzmj committed Dec 14, 2023
1 parent dc5ed48 commit 75567f2
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 51 deletions.
5 changes: 5 additions & 0 deletions .changeset/lovely-poets-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vue-macros/volar': patch
---

define-models and defineSlots co-usage
5 changes: 5 additions & 0 deletions .changeset/seven-icons-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vue-macros/volar': patch
---

support type for v-model
58 changes: 58 additions & 0 deletions packages/volar/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type Segment,
type Sfc,
type VueCompilerOptions,
getSlotsPropertyName,
replaceAll,
} from '@vue/language-core'
import type { VolarOptions } from '..'
Expand Down Expand Up @@ -84,3 +85,60 @@ export function getImportNames(

return names
}

export function getPropsType(codes: Segment<FileRangeCapabilities>[]) {
if (codes.toString().includes('type __VLS_getProps')) return

codes.push(`
type __VLS_getProps<T> = T extends new () => { $props: infer P }
? NonNullable<P>
: T extends (props: infer P, ctx: any) => any
? NonNullable<P>
: {};`)
}

export function getEmitsType(codes: Segment<FileRangeCapabilities>[]) {
if (codes.toString().includes('type __VLS_getEmits')) return

codes.push(`
type __VLS_getEmits<T> = T extends new () => { $emit: infer E }
? NonNullable<__VLS_NormalizeEmits<E>>
: T extends (
props: any,
ctx: { slots: any; attrs: any; emit: infer E },
) => any
? NonNullable<__VLS_NormalizeEmits<E>>
: {};`)
}

export function getModelsType(codes: Segment<FileRangeCapabilities>[]) {
if (codes.toString().includes('type __VLS_getModels')) return
getEmitsType(codes)
getPropsType(codes)

codes.push(`
type __VLS_RemoveUpdatePrefix<T> = T extends \`update:modelValue\`
? never
: T extends \`update:\${infer R}\`
? \`\${R}\`
: T;
type __VLS_getModels<T> = T extends object
? {
[K in keyof __VLS_getEmits<T> as __VLS_RemoveUpdatePrefix<K>]: __VLS_getProps<T>[__VLS_RemoveUpdatePrefix<K>]
}
: {};`)
}

export function getSlotsType(
codes: Segment<FileRangeCapabilities>[],
vueVersion?: number,
) {
if (codes.toString().includes('type __VLS_getSlots')) return
codes.push(`
type __VLS_getSlots<T> = T extends new () => { '${getSlotsPropertyName(
vueVersion || 3,
)}': infer S } ? NonNullable<S>
: T extends (props: any, ctx: { slots: infer S; attrs: any; emit: any }) => any
? NonNullable<S>
: {};`)
}
2 changes: 1 addition & 1 deletion packages/volar/src/define-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function transformDefineModels({
type __VLS_GetEventKey<K extends string | number> = K extends 'modelValue'${
unified ? '' : ' & never'
} ? 'input' : \`update:\${K}\`
type __VLS_ModelToEmits<T> = T extends Record<string | number, any> ? { [K in keyof T & (string | number) as __VLS_GetEventKey<K>]: (value: T[K]) => void } : T`,
type __VLS_ModelToEmits<T> = T extends Record<string | number, any> ? { [K in keyof T & (string | number) as __VLS_GetEventKey<K>]: (value: T[K]) => void } : T;`,
)

function mergeProps() {
Expand Down
28 changes: 12 additions & 16 deletions packages/volar/src/jsx-directive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function transformJsxDirective({
const vSlotNodeSet = new Set<
import('typescript/lib/tsserverlibrary').JsxElement
>()
const vModelAttributes: JsxAttributeNode[] = []
const vModelAttributeMap = new Map<any, JsxAttributeNode[]>()
const vOnAttributes: JsxAttributeNode[] = []

function walkJsxDirective(
Expand All @@ -45,7 +45,6 @@ export function transformJsxDirective({
: []
let vIfAttribute
let vForAttribute
let vModelAttribute

for (const attribute of properties) {
if (!ts.isJsxAttribute(attribute)) continue
Expand Down Expand Up @@ -73,14 +72,15 @@ export function transformJsxDirective({
)
}
if (
/^v-model(_.*)?$/.test(
(ts.isJsxNamespacedName(attribute.name)
? attribute.name.namespace
: attribute.name
).getText(sfc[source]?.ast),
)
ts.isJsxNamespacedName(attribute.name)
? attribute.name.namespace.getText(sfc[source]?.ast) === 'v-model'
: /^v-model(_\S+)?$/.test(attribute.name.getText(sfc[source]?.ast))
) {
vModelAttribute = attribute
vModelAttributeMap.has(node) || vModelAttributeMap.set(node, [])
vModelAttributeMap.get(node)!.push({
node,
attribute,
})
}
if (attribute.name.getText(sfc[source]?.ast) === 'v-on') {
vOnAttributes.push({ node, attribute })
Expand All @@ -102,12 +102,6 @@ export function transformJsxDirective({
parent: vIfAttribute ? undefined : parent,
})
}
if (vModelAttribute) {
vModelAttributes.push({
node,
attribute: vModelAttribute,
})
}

node.forEachChild((child) => {
walkJsxDirective(
Expand All @@ -130,6 +124,8 @@ export function transformJsxDirective({
vIfAttributeMap.forEach((nodes) =>
transformVIf({ nodes, codes, sfc, ts, source }),
)
transformVModel({ nodes: vModelAttributes, codes, sfc, ts, source })
vModelAttributeMap.forEach((nodes) =>
transformVModel({ nodes, codes, sfc, ts, source }),
)
transformVOn({ nodes: vOnAttributes, codes, sfc, ts, source })
}
90 changes: 73 additions & 17 deletions packages/volar/src/jsx-directive/v-model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { replaceSourceRange } from '@vue/language-core'
import {
FileRangeCapabilities,
type Segment,
replaceSourceRange,
} from '@vue/language-core'
import { getModelsType } from '../common'
import type { JsxAttributeNode, TransformOptions } from './index'

export function transformVModel({
Expand All @@ -8,23 +13,74 @@ export function transformVModel({
sfc,
source,
}: TransformOptions & { nodes: JsxAttributeNode[] }) {
nodes.forEach(({ attribute }) => {
const start = attribute.getStart(sfc[source]?.ast)
let end = start + 7
let name = 'modelValue'

let firstNamespacedNode: (JsxAttributeNode & { name: string }) | undefined
const result: Segment<FileRangeCapabilities>[] = []
for (const { attribute, node } of nodes) {
if (ts.isJsxNamespacedName(attribute.name)) {
name = attribute.name.name.getText(sfc[source]?.ast)
end += 1 + name.length
const name = attribute.name.name.getText(sfc[source]?.ast).split('_')[0]
firstNamespacedNode ??= { attribute, node, name }
if (firstNamespacedNode.attribute !== attribute) {
replaceSourceRange(
codes,
source,
attribute.getStart(sfc[source]?.ast),
attribute.getEnd(),
`onUpdate:${name}={() => {}}`,
)
}

result.push(
[
name,
source,
attribute.name.getStart(sfc[source]?.ast) + 8,
FileRangeCapabilities.full,
],
attribute.initializer && attribute.name.name.getText?.(sfc[source]?.ast)
? [
`:${attribute.initializer
.getText(sfc[source]?.ast)
.slice(1, -1)},`,
source,
attribute.initializer.getStart(sfc[source]?.ast),
FileRangeCapabilities.full,
]
: '',
)
} else {
replaceSourceRange(
codes,
source,
attribute.name.getStart(sfc[source]?.ast),
attribute.name.getEnd() + 1,
`onUpdate:modelValue={() => {}} `,
[
'modelValue',
source,
[attribute.name.getStart(sfc[source]?.ast), attribute.name.getEnd()],
FileRangeCapabilities.full,
],
'=',
)
}
}

if (!firstNamespacedNode) return
const { node, attribute, name } = firstNamespacedNode
getModelsType(codes)

replaceSourceRange(
codes,
source,
start,
end,
`onUpdate:${name}={() => {}} `,
name,
)
})
const tagName = ts.isJsxSelfClosingElement(node)
? node.tagName.getText(sfc[source]?.ast)
: ts.isJsxElement(node)
? node.openingElement.tagName.getText(sfc[source]?.ast)
: null
replaceSourceRange(
codes,
source,
attribute.getStart(sfc[source]?.ast),
attribute.getEnd(),
`onUpdate:${name}={() => {}} {...{`,
...result,
`} satisfies __VLS_getModels<typeof ${tagName}>}`,
)
}
11 changes: 3 additions & 8 deletions packages/volar/src/jsx-directive/v-on.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { replaceSourceRange } from '@vue/language-core'
import { getEmitsType } from '../common'
import type { JsxAttributeNode, TransformOptions } from './index'

export function transformVOn({
Expand All @@ -9,26 +10,20 @@ export function transformVOn({
source,
}: TransformOptions & { nodes: JsxAttributeNode[] }) {
if (nodes.length === 0) return
codes.push(`
type __VLS_getEmits<T> = T extends new () => { $emit: infer E } ? NonNullable<E>
: T extends (props: any, ctx: { slots: any; attrs: any; emit: infer E }, ...args: any) => any
? NonNullable<E>
: {};`)
getEmitsType(codes)

for (const { node, attribute } of nodes) {
const tagName = ts.isJsxSelfClosingElement(node)
? node.tagName.getText(sfc[source]?.ast)
: ts.isJsxElement(node)
? node.openingElement.tagName.getText(sfc[source]?.ast)
: null
if (!tagName) continue

replaceSourceRange(
codes,
source,
attribute.getEnd() - 1,
attribute.getEnd() - 1,
` satisfies __VLS_NormalizeEmits<__VLS_getEmits<typeof ${tagName}>>`,
` satisfies __VLS_getEmits<typeof ${tagName}>`,
)
}
}
9 changes: 2 additions & 7 deletions packages/volar/src/jsx-directive/v-slot.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
FileRangeCapabilities,
type Segment,
getSlotsPropertyName,
replaceSourceRange,
} from '@vue/language-core'
import { getSlotsType } from '../common'
import type { TransformOptions } from './index'

export function transformVSlot({
Expand All @@ -17,12 +17,7 @@ export function transformVSlot({
nodes: import('typescript/lib/tsserverlibrary').JsxElement[]
}) {
if (nodes.length === 0) return
codes.push(`type __VLS_getSlots<T> = T extends new () => { '${getSlotsPropertyName(
vueVersion || 3,
)}': infer S } ? NonNullable<S>
: T extends (props: any, ctx: infer Ctx extends { slots: any }) => any
? NonNullable<Ctx['slots']>
: {};`)
getSlotsType(codes, vueVersion)

nodes.forEach((node) => {
if (!ts.isIdentifier(node.openingElement.tagName)) return
Expand Down
12 changes: 11 additions & 1 deletion playground/vue3/src/examples/jsx-directive/v-model/child.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@ const slots = defineSlots<{
title: (scope: { value: string; 'onUpdate:value': (e: any) => void }) => any
}>()
defineProps<{
bar: string
}>()
defineModels<{
modelValue: number
title: number
bottom: number
}>()
const value = $ref('')
defineRender(() => (
<>
<slots.title v-model_trim={[value, 'value']}></slots.title>
<slots.title v-model:value_trim={value}></slots.title>
<slots.default value={value}></slots.default>
</>
Expand Down
5 changes: 4 additions & 1 deletion playground/vue3/src/examples/jsx-directive/v-model/index.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<script setup lang="tsx">
import Child from './child.vue'
const foo = $ref(1)
const bar = $ref('')
defineRender(() => (
<Child>
<Child bar={bar} v-model:title={foo} v-model:bottom={foo} v-model={foo}>
<template v-slot:title={{ value, ...emits }}>
<input
value={value}
Expand Down

0 comments on commit 75567f2

Please sign in to comment.