TypeScript
Inferring props, refs, and value types.
ShardsUI is written in TypeScript, so types flow through v-model, event payloads, and scoped slots with no annotations. The cases below are the ones where you do name a type.
Inferring a component's props
Each part declares its props inline, so there's no props interface to import. When you build a wrapper that should accept exactly what the underlying part accepts, index the part's instance type for its $props:
<script setup lang="ts">
import { Tooltip } from '@shardsui/vue/tooltip'
const props = defineProps<InstanceType<typeof Tooltip.Root>['$props']>()
</script>
<template>
<Tooltip.Root v-bind="props" />
</template>InstanceType<typeof X>['$props'] resolves to the full prop surface of X: as, the model props, and every part-specific prop. class and style aren't props — they fall through as attributes and need no declaration. Index into it to reuse or narrow one of them:
import { Tooltip } from '@shardsui/vue/tooltip'
type TooltipDelay = InstanceType<typeof Tooltip.Trigger>['$props']['delay']Typing controlled state
A stateful prop does double duty: pass a value without v-model and the part owns the state; add v-model and you own it:
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Switch } from '@shardsui/vue/switch'
const checked = shallowRef(false)
</script>
<template>
<Switch.Root v-model:checked="checked" />
</template>The ref's inferred type is usually enough. shallowRef(false) is already Ref<boolean>. Annotate it when the value is a union, so an invalid value is caught at the binding site instead of at runtime:
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Select } from '@shardsui/vue/select'
const value = shallowRef<'sans' | 'serif' | 'mono'>('sans')
</script>
<template>
<Select.Root v-model:value="value">
<!-- … -->
</Select.Root>
</template>Value types for generic parts
Select.Root and Combobox.Root are generic over the item they hold and over whether selection is single or multiple, but items never infers it: Combobox types it readonly NoInfer<Value>[] and Select's entries are unknown. Annotate the bound value instead. The item type flows from there into update:value, itemToStringLabel, the item slots, and Combobox's itemHighlighted event, with no casts. Without an annotated value, the type has to come from a typed wrapper (see below).
In single mode (the default) the value is the item, or null when nothing is chosen:
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Combobox } from '@shardsui/vue/combobox'
type Fruit = {
id: string
label: string
}
const fruits: Fruit[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'cherry', label: 'Cherry' }
]
const value = shallowRef<Fruit | null>(null)
</script>
<template>
<Combobox.Root :items="fruits" v-model:value="value">
<!-- … -->
</Combobox.Root>
</template>Add multiple and the value type flips to an array of items:
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Combobox } from '@shardsui/vue/combobox'
type Fruit = {
id: string
label: string
}
const fruits: Fruit[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'cherry', label: 'Cherry' }
]
const value = shallowRef<Fruit[] | null>([])
</script>
<template>
<Combobox.Root :items="fruits" multiple v-model:value="value">
<!-- … -->
</Combobox.Root>
</template>Autocomplete.Root is generic over its item too, but only for items, filter and the item slots. Its value is the input's text, so it is always a string.
To wrap a generic part, forward its type parameter with <script setup generic="…"> so the item type keeps flowing from your call site through the wrapper into the part:
<script setup lang="ts" generic="Item">
import { Combobox } from '@shardsui/vue/combobox'
const props = defineProps<InstanceType<typeof Combobox.Root<Item>>['$props']>()
</script>
<template>
<Combobox.Root v-bind="props" />
</template>Form is generic over the values object it collects, defaulting to Record<string, unknown> keyed by each field's name. It can't infer what your fields hold, so annotate the formSubmit handler's parameter and the type flows back into the component — or skip the annotation and parse the values instead (see the Zod example, where the schema is what produces the typed object):
<script setup lang="ts">
import { Form } from '@shardsui/vue/form'
import { Field } from '@shardsui/vue/field'
type SignUpValues = {
email: string
password: string
}
function onFormSubmit({ email, password }: SignUpValues) {
console.log(email, password)
}
</script>
<template>
<Form @form-submit="onFormSubmit">
<Field.Root name="email">
<Field.Control type="email" />
</Field.Root>
<Field.Root name="password">
<Field.Control type="password" />
</Field.Root>
</Form>
</template>The errors prop is typed as FormErrors — Record<string, string | string[]>, keyed by the same field name — exported from @shardsui/vue/form alongside FormValidationMode:
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Form } from '@shardsui/vue/form'
import type { FormErrors } from '@shardsui/vue/form'
const errors = shallowRef<FormErrors>({})
</script>
<template>
<Form :errors="errors">
<!-- … -->
</Form>
</template>Change events and listeners
A change event carries one argument, the new value. Let it infer from the emit, or annotate it when the handler lives away from the markup:
function onCheckedChange(checked: boolean) {
console.log(checked)
}
function onValueChange(value: string) {
console.log(value)
}The component reports the new value; with v-model it is committed for you, so there is nothing to cancel in the listener. To veto a change, bind the prop and the event separately and decline to write the ref (see State).
Native DOM events reach you through ordinary listeners and keep their standard DOM types:
function onSubmit(event: SubmitEvent) {
event.preventDefault()
}Imperative handles
When a trigger and its content can't sit together in the markup, detach them with a handle. Dialog.createHandle() constructs one; the type argument makes the handle's own methods generic over the payload you carry:
<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
const dialog = Dialog.createHandle<{ text: string }>()
</script>
<template>
<Dialog.Trigger :handle="dialog" :payload="{ text: 'From the toolbar' }">Open</Dialog.Trigger>
<Dialog.Root v-slot="{ payload }" :handle="dialog">
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Description v-if="payload">Opened by {{ payload.text }}</Dialog.Description>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</template>The handle drives the dialog from your own code — dialog.open(triggerId), dialog.close(), dialog.openWithPayload(payload), and the readonly dialog.isOpen — with openWithPayload typed against the { text: string } you declared. open takes the id of a registered detached trigger, or null to open with no trigger at all; Dialog, AlertDialog and Drawer accept null and carry openWithPayload, while the Popover, Menu, Tooltip and PreviewCard handles require an id.
The same type argument flows through <Dialog.Trigger>'s payload prop and into the default slot, where payload arrives as { text: string } | undefined. Guard the undefined, which means no trigger has opened the dialog yet. To pass a handle across module boundaries, annotate it with the Dialog.Handle type:
import { Dialog } from '@shardsui/vue/dialog'
let dialog: Dialog.Handle<{ text: string }>Template refs and $el
A template ref on a part resolves to the part's component instance, and $el on it is the element the part rendered, typed HTMLElement: as makes the tag a runtime value, so the type can't narrow to a concrete element. Narrow with an instanceof check where you need a tag-specific API. The ref is null until the element mounts, so a prop that wants a non-null element, like initialFocus, takes a getter instead:
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { Dialog } from '@shardsui/vue/dialog'
const popup = useTemplateRef('popup')
</script>
<template>
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup ref="popup" :initial-focus="() => popup?.$el">
<!-- … -->
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</template>useTemplateRef follows the same rule: typed to the component on a component instance, to the element on a plain element:
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const button = useTemplateRef<HTMLButtonElement>('button')
</script>
<template>
<button ref="button">Click</button>
</template>Slots
When a part hands its default slot its state, destructure it. The payload type comes from the part:
<script setup lang="ts">
import { Switch } from '@shardsui/vue/switch'
</script>
<template>
<Switch.Root>
<Switch.Thumb v-slot="{ checked }">
{{ checked ? 'On' : 'Off' }}
</Switch.Thumb>
</Switch.Root>
</template>To accept a slot in your own component, declare it with defineSlots and type the props object your slot receives:
<script setup lang="ts">
defineSlots<{ default(props: { count: number }): any }>()
</script>
<template>
<slot :count="1" />
</template>A slot that takes nothing is default(): any; one that receives a payload object is default(props: { payload: SomeType }): any.
Other exported types
The toast object your toast content receives carries its id, title, description, priority, transition status and your own data; it is ToastObject<Data>, exported from @shardsui/vue/toast along with ToastManagerAddOptions, ToastManagerUpdateOptions and ToastManagerPromiseOptions for the Toast.Manager queue. See Toast.
Combobox.createFilter returns a ComboboxFilter and takes ComboboxFilterOptions, both exported from @shardsui/vue/combobox. See Combobox.