Add nuxt-skills and update auto quotes to use new policy API structure

- Add nuxt-skills (vue, nuxt, nuxt-ui) to .claude/skills/
- Create useCustomerSelection() composable for managing insured/buyer selection
- Create usePolicyApi() composable for policy API operations
- Update auto quote components to use insured/buyer instead of client
- Update vehicle fields: remove valorVehiculo, add market_value, requested_value, rc_limits
- Make chassis_number and engine_number optional
- Update auto quote types and composables to match new API structure
- Update auto quote page to submit to policy API with new structure
This commit is contained in:
2026-04-27 14:56:53 -05:00
parent 67482f6629
commit a2eb1f3789
154 changed files with 10346 additions and 51 deletions

View File

@@ -0,0 +1,323 @@
# Vue Components
Patterns for Vue 3 components using Composition API with `<script setup>`.
## Quick Reference
| Pattern | Syntax |
| --------------------- | --------------------------------------------------------------- |
| Props (destructured) | `const { name = 'default' } = defineProps<{ name?: string }>()` |
| Props (template-only) | `defineProps<{ name: string }>()` |
| Emits | `const emit = defineEmits<{ click: [id: number] }>()` |
| Two-way binding | `const model = defineModel<string>()` |
| Slots shorthand | `<template #header>` not `<template v-slot:header>` |
## Naming
**Files:** PascalCase (`UserProfile.vue`) OR kebab-case (`user-profile.vue`) - be consistent
**Component names in code:** Always PascalCase
**Composition:** General → Specific: `SearchButtonClear.vue` not `ClearSearchButton.vue`
## Props
**Destructure with defaults (Vue 3.5+)** when used in script or need defaults:
```ts
const { count = 0, message = 'Hello' } = defineProps<{
count?: number
message?: string
required: boolean
}>()
// Use directly - maintains reactivity
console.log(count + 1)
// ⚠️ When passing to watchers/functions, wrap in getter:
watch(() => count, (newVal) => { ... }) // ✅ Correct
watch(count, (newVal) => { ... }) // ❌ Won't work
```
**Non-destructured** only if props ONLY used in template:
```ts
defineProps<{ count: number }>()
// Template: {{ count }}
```
**Same-name shorthand (Vue 3.4+):** `:count` instead of `:count="count"`
```vue
<MyComponent :count :user :items />
<!-- Same as: :count="count" :user="user" :items="items" -->
```
[Reactive destructuring docs](https://vuejs.org/guide/components/props#reactive-props-destructure)
## Emits
Type-safe event definitions:
```ts
const emit = defineEmits<{
update: [id: number, value: string] // multiple args
close: [] // no args
}>()
// Usage
emit('update', 123, 'new value')
emit('close')
```
**Template syntax:** kebab-case (`@update-item`) vs camelCase in script (`updateItem`)
## Slots
**Always use shorthand:** `<template #header>` not `<template v-slot:header>`
**Always explicit `<template>` tags** for all slots
```vue
<template>
<Card>
<template #header>
<h2>Title</h2>
</template>
<template #default>
Content
</template>
</Card>
</template>
```
## defineModel() - Two-Way Binding
Replaces manual `modelValue` prop + `update:modelValue` emit.
### Basic
```vue
<script setup lang="ts">
const title = defineModel<string>()
</script>
<template>
<input v-model="title">
</template>
```
### With Options
```vue
<script setup lang="ts">
const [title, modifiers] = defineModel<string>({
default: 'default value',
required: true,
get: (value) => value.trim(),
set: (value) => {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
},
})
</script>
```
**⚠️ Warning:** When using `default` without parent providing a value, parent and child can de-sync (parent `undefined`, child has default). Always provide matching defaults in parent or make prop required.
**Prevent double-emit with `required: true`:**
```ts
// ❌ Without required - emits twice (undefined then value)
const model = defineModel<Item>()
// ✅ With required - single emit
const model = defineModel<Item>({ required: true })
```
Use `required: true` when the model should always have a value to avoid the double-emit issue during initialization.
### Multiple Models
Default assumes `modelValue` prop. For multiple bindings, use explicit names:
```vue
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const age = defineModel<number>('age')
</script>
<!-- Usage -->
<UserForm v-model:first-name="user.firstName" v-model:age="user.age" />
```
[v-model modifiers docs](https://vuejs.org/guide/components/v-model#handling-v-model-modifiers)
## Reusable Templates
For typed, scoped template snippets within a component:
```vue
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
const [DefineItem, UseItem] = createReusableTemplate<{
item: SearchItem
icon: string
color?: 'red' | 'green' | 'blue'
}>()
</script>
<template>
<DefineItem v-slot="{ item, icon, color }">
<div :class="color">
<Icon :name="icon" />
{{ item.name }}
</div>
</DefineItem>
<!-- Reuse multiple times -->
<UseItem v-for="item in items" :key="item.id" :item :icon="getIcon(item)" />
</template>
```
## Template Refs (Vue 3.5+)
Use `useTemplateRef()` for type-safe template references with IDE support:
```vue
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
const input = useTemplateRef<HTMLInputElement>('my-input')
onMounted(() => {
input.value?.focus()
})
</script>
<template>
<input ref="my-input">
</template>
```
**Benefits over `ref()`:**
- Type-safe with generics
- Better IDE autocomplete and refactoring
- Explicit ref name as string literal
**Dynamic refs:**
```vue
<script setup lang="ts">
const items = ref(['a', 'b', 'c'])
const itemRefs = useTemplateRef<HTMLElement>('item')
// Access refs after mount
onMounted(() => {
console.log(itemRefs.value) // Array of elements
})
</script>
<template>
<div v-for="item in items" :key="item" ref="item">
{{ item }}
</div>
</template>
```
**Component refs with generics:**
For generic components, use `ComponentExposed` from `vue-component-type-helpers`:
```ts
import type { ComponentExposed } from 'vue-component-type-helpers'
import MyGenericComponent from './MyGenericComponent.vue'
// Get exposed methods/properties with correct generic types
const compRef = useTemplateRef<ComponentExposed<typeof MyGenericComponent>>('comp')
onMounted(() => {
compRef.value?.someExposedMethod() // Typed!
})
```
Install: `pnpm add -D vue-component-type-helpers`
## SSR Hydration (Vue 3.5+)
**Suppress hydration mismatches** for values that differ between server/client:
```vue
<template>
<!-- Client-side only values -->
<span data-allow-mismatch>{{ new Date().toLocaleString() }}</span>
<!-- Specific mismatch types -->
<span data-allow-mismatch="text">{{ timestamp }}</span>
<span data-allow-mismatch="children">
<ClientOnly>...</ClientOnly>
</span>
<span data-allow-mismatch="style">...</span>
<span data-allow-mismatch="class">...</span>
<span data-allow-mismatch="attribute">...</span>
</template>
```
**Generate SSR-stable IDs:**
```vue
<script setup lang="ts">
import { useId } from 'vue'
const id = useId() // Stable across server/client renders
</script>
<template>
<label :for="id">Name</label>
<input :id="id">
</template>
```
## Deferred Teleport (Vue 3.5+)
Teleport to elements rendered later in the same cycle:
```vue
<template>
<!-- This renders first -->
<Teleport defer to="#late-div">
<span>Deferred content</span>
</Teleport>
<!-- This renders after, but Teleport waits -->
<div id="late-div"></div>
</template>
```
Without `defer`, teleport to `#late-div` would fail since it doesn't exist yet.
## Common Mistakes
**Using `const props =` with destructured values:**
```ts
// ❌ Wrong
const props = defineProps<{ count: number }>()
const { count } = props // Loses reactivity
```
**Forgetting TypeScript types:**
```ts
// ❌ Wrong
const emit = defineEmits(['update'])
// ✅ Correct
const emit = defineEmits<{ update: [id: number] }>()
```
**Components >300 lines:** Split into smaller components or extract logic to composables

View File

@@ -0,0 +1,358 @@
# Vue Composables
Reusable functions encapsulating stateful logic using Composition API.
## Core Rules
1. **VueUse first** - check [vueuse.org](https://vueuse.org) before writing custom
2. **No async composables** - lose lifecycle context when awaited in other composables
3. **Top-level only** - never call in event handlers, conditionals, or loops
4. **readonly() exports** - protect internal state from external mutation
5. **useState() for SSR** - use Nuxt's `useState()` not global refs
## Quick Reference
| Pattern | Example |
| --------- | ------------------------------------------------ |
| Naming | `useAuth`, `useCounter`, `useDebounce` |
| State | `const count = ref(0)` |
| Computed | `const double = computed(() => count.value * 2)` |
| Lifecycle | `onMounted(() => ...)`, `onUnmounted(() => ...)` |
| Return | `return { count, increment }` |
## Structure
```ts
// composables/useCounter.ts
import { readonly, ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return {
count: readonly(count), // readonly if shouldn't be mutated
increment,
decrement,
reset,
}
}
```
## Naming
**Always prefix with `use`:** `useAuth`, `useLocalStorage`, `useDebounce`
**File = function:** `useAuth.ts` exports `useAuth`
## Best Practices
**Do:**
- Return object with named properties (destructuring-friendly)
- Accept options object for configuration
- Use `readonly()` for state that shouldn't mutate
- Handle cleanup (`onUnmounted`, `onScopeDispose`)
- Add JSDoc for complex functions
## Lifecycle
Hooks execute in component context:
```ts
export function useEventListener(target: EventTarget, event: string, handler: Function) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}
```
**Watcher cleanup (Vue 3.5+):**
```ts
import { watch, onWatcherCleanup } from 'vue'
export function usePolling(url: Ref<string>) {
watch(url, (newUrl) => {
const interval = setInterval(() => {
fetch(newUrl).then(/* ... */)
}, 1000)
// Cleanup when watcher re-runs or stops
onWatcherCleanup(() => {
clearInterval(interval)
})
})
}
```
**Benefits of `onWatcherCleanup()`:**
- Cleaner than returning cleanup functions
- Works with async watchers
- Can be called multiple times in same watcher
## Async Pattern
```ts
export function useAsyncData<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
}
catch (e) {
error.value = e as Error
}
finally {
loading.value = false
}
}
execute()
return { data, error, loading, refetch: execute }
}
```
**Data fetching:** Prefer Pinia Colada queries over custom composables.
## VueUse
> For VueUse composable reference, use the `vueuse` skill.
Check VueUse before writing custom composables - most patterns already implemented.
> **For Nuxt-specific composables** (useFetch, useRequestURL): see `nuxt` skill nuxt-composables.md
## Advanced Patterns
### Singleton Composable
Share state across all components using the same composable:
```ts
import { createSharedComposable } from '@vueuse/core'
function useMapControlsBase() {
const mapInstance = ref<Map | null>(null)
const flyTo = (coords: [number, number]) => mapInstance.value?.flyTo(coords)
return { mapInstance, flyTo }
}
export const useMapControls = createSharedComposable(useMapControlsBase)
```
### Cancellable Fetch with AbortController
```ts
export function useSearch() {
let abortController: AbortController | null = null
watch(query, async (newQuery) => {
abortController?.abort()
abortController = new AbortController()
try {
const data = await $fetch('/api/search', {
query: { q: newQuery },
signal: abortController.signal,
})
}
catch (e) {
if (e.name !== 'AbortError')
throw e
}
})
}
```
### Step-Based State Machine
```ts
export function useSendFlow() {
const step = ref<'input' | 'confirm' | 'success'>('input')
const amount = ref('')
const next = () => {
if (step.value === 'input')
step.value = 'confirm'
else if (step.value === 'confirm')
step.value = 'success'
}
return { step, amount, next }
}
```
### Client-Only Guards
```ts
export function useUserLocation() {
const location = ref<GeolocationPosition | null>(null)
if (import.meta.client) {
navigator.geolocation.getCurrentPosition(pos => location.value = pos)
}
return { location }
}
```
### Custom Element Composables (Vue 3.5+)
For custom element components, use built-in helpers:
```ts
import { useHost, useShadowRoot } from 'vue'
export function useCustomElement() {
const host = useHost() // Host element reference
const shadowRoot = useShadowRoot() // Shadow DOM root
onMounted(() => {
console.log('Host:', host)
console.log('Shadow:', shadowRoot)
})
return { host, shadowRoot }
}
```
**Available in:**
- Components using `<script setup>` in custom elements
- Access via `this.$host` in Options API
### Auto-Save with Debounce
```ts
export function useAutoSave(content: Ref<string>) {
const hasChanges = ref(false)
const save = useDebounceFn(async () => {
if (!hasChanges.value)
return
await $fetch('/api/save', { method: 'POST', body: { content: content.value } })
hasChanges.value = false
}, 1000)
watch(content, () => {
hasChanges.value = true
save()
})
return { hasChanges }
}
```
### Tagged Logger
```ts
import { consola } from 'consola'
export function useSearch() {
const logger = consola.withTag('search')
watch(query, (q) => {
logger.info('Query changed:', q)
})
}
```
## Reactivity Gotchas
### Ref Unwrapping in Reactive
Refs auto-unwrap in `reactive()` objects but **NOT** in arrays, Maps, or Sets:
```ts
// ✅ Object - auto unwraps
const state = reactive({ count: ref(0) })
state.count++ // No .value needed
// ❌ Array - NO unwrapping
const arr = reactive([ref(1)])
arr[0].value // Need .value!
// ❌ Map/Set - NO unwrapping
const map = reactive(new Map([['key', ref(1)]]))
map.get('key').value // Need .value!
```
### watchEffect Conditional Tracking
Dependencies inside conditional branches are **not tracked** when condition is false:
```ts
// ❌ Wrong - dep not tracked when condition false
watchEffect(() => {
if (condition.value) {
console.log(dep.value) // Only tracked when condition=true
}
})
// ✅ Correct - use explicit watch for conditional deps
watch([condition, dep], ([cond, d]) => {
if (cond) console.log(d)
})
```
### Cleanup Patterns
**For keep-alive components** - use `onDeactivated`:
```ts
export function usePolling() {
let interval: NodeJS.Timeout
onMounted(() => { interval = setInterval(poll, 5000) })
onUnmounted(() => clearInterval(interval))
onDeactivated(() => clearInterval(interval)) // Pause when deactivated
onActivated(() => { interval = setInterval(poll, 5000) }) // Resume
}
```
**For scope-aware cleanup** - use `tryOnScopeDispose` from VueUse:
```ts
import { tryOnScopeDispose } from '@vueuse/core'
export function useEventSource(url: string) {
const source = new EventSource(url)
// Cleans up when effect scope disposes (component unmount, watcher stop)
tryOnScopeDispose(() => source.close())
return { source }
}
```
## Common Mistakes
**Not using `readonly()` for internal state:**
```ts
// ❌ Wrong - exposes mutable ref
return { count }
// ✅ Correct - prevents external mutation
return { count: readonly(count) }
```
**Missing cleanup:**
```ts
// ❌ Wrong - listener never removed
onMounted(() => target.addEventListener('click', handler))
// ✅ Correct - cleanup on unmount
onMounted(() => target.addEventListener('click', handler))
onUnmounted(() => target.removeEventListener('click', handler))
```

View File

@@ -0,0 +1,225 @@
---
name: Custom Directives
description: Create reusable directives for low-level DOM manipulation
---
# Custom Directives
Custom directives provide low-level DOM access for reusable behavior.
## When to Use
Use custom directives when:
- You need direct DOM manipulation
- The behavior can't be achieved with components or composables
- You need to apply behavior to native elements
## Basic Example
```vue
<script setup lang="ts">
// v-focus directive
const vFocus = {
mounted: (el: HTMLElement) => el.focus()
}
</script>
<template>
<input v-focus />
</template>
```
## Directive Hooks
```ts
const myDirective = {
// Before element attributes/listeners are applied
created(el, binding, vnode) {},
// Before element is inserted into DOM
beforeMount(el, binding, vnode) {},
// After element and children are mounted
mounted(el, binding, vnode) {},
// Before parent component updates
beforeUpdate(el, binding, vnode, prevVnode) {},
// After parent component updates
updated(el, binding, vnode, prevVnode) {},
// Before parent component unmounts
beforeUnmount(el, binding, vnode) {},
// After parent component unmounts
unmounted(el, binding, vnode) {}
}
```
## Hook Arguments
```ts
interface DirectiveBinding<T = any> {
value: T // v-my-dir="value"
oldValue: T // Previous value (beforeUpdate/updated only)
arg?: string // v-my-dir:arg
modifiers: Record<string, boolean> // v-my-dir.foo.bar → { foo: true, bar: true }
instance: ComponentPublicInstance // Component using the directive
dir: ObjectDirective // Directive definition object
}
```
Example usage:
```vue-html
<div v-example:foo.bar="baz">
```
```ts
// binding object:
{
arg: 'foo',
modifiers: { bar: true },
value: /* value of baz */,
oldValue: /* previous value */
}
```
## Function Shorthand
When you only need `mounted` and `updated` with same behavior:
```ts
// Full form
const vColor = {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
el.style.color = binding.value
}
}
// Shorthand (same behavior)
const vColor = (el: HTMLElement, binding: DirectiveBinding<string>) => {
el.style.color = binding.value
}
```
## Global Registration
```ts
// main.ts
const app = createApp(App)
app.directive('focus', {
mounted: (el) => el.focus()
})
// Shorthand
app.directive('color', (el, binding) => {
el.style.color = binding.value
})
```
## Object Literals
Pass multiple values:
```vue-html
<div v-demo="{ color: 'white', text: 'hello' }">
```
```ts
const vDemo = (el: HTMLElement, binding: DirectiveBinding<{ color: string; text: string }>) => {
console.log(binding.value.color) // 'white'
console.log(binding.value.text) // 'hello'
}
```
## Dynamic Arguments
```vue-html
<div v-my-directive:[dynamicArg]="value">
```
## Practical Examples
### v-click-outside
```ts
const vClickOutside = {
mounted(el: HTMLElement, binding: DirectiveBinding<() => void>) {
el._clickOutside = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value()
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el: HTMLElement) {
document.removeEventListener('click', el._clickOutside)
}
}
```
### v-tooltip
```ts
const vTooltip = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
el.setAttribute('title', binding.value)
},
updated(el: HTMLElement, binding: DirectiveBinding<string>) {
el.setAttribute('title', binding.value)
}
}
```
### v-permission
```ts
const vPermission = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
if (!hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
}
}
```
## TypeScript: Global Directives
```ts
// directives/highlight.ts
import type { Directive } from 'vue'
export type HighlightDirective = Directive<HTMLElement, string>
declare module 'vue' {
export interface ComponentCustomProperties {
vHighlight: HighlightDirective
}
}
export default {
mounted: (el, binding) => {
el.style.backgroundColor = binding.value
}
} satisfies HighlightDirective
```
## Usage on Components
⚠️ **Not recommended** - directives apply to root element, which can be unpredictable with multi-root components.
```vue-html
<!-- Applies to MyComponent's root element -->
<MyComponent v-my-directive />
```
<!--
Source references:
- https://vuejs.org/guide/reusability/custom-directives.html
-->

View File

@@ -0,0 +1,438 @@
# Vue Common Gotchas & Edge Cases
Critical Vue 3 gotchas that cause silent failures or hard-to-debug issues.
> Based on [vuejs-ai/skills](https://github.com/vuejs-ai/skills) vue-best-practices. For comprehensive coverage (200+ rules), see the upstream repo.
## Reactivity
### Always Use `.value` When Accessing ref() in Scripts
**Impact: HIGH** - Forgetting `.value` causes silent failures.
```ts
const count = ref(0)
// WRONG
count++ // Tries to increment the ref object
count = 5 // Reassigns variable, loses reactivity
items.push(4) // Error: push is not a function
// CORRECT
count.value++
count.value = 5
items.value.push(4)
// In templates - NO .value needed (Vue unwraps automatically)
// {{ count }} works, not {{ count.value }}
```
### Never Destructure reactive() Objects Directly
**Impact: HIGH** - Destructuring breaks reactive connection.
```ts
const state = reactive({ count: 0, name: 'Vue' })
// WRONG - destructured variables lose reactivity
const { count, name } = state
state.count++
console.log(count) // Still 0!
// CORRECT - use toRefs()
const { count, name } = toRefs(state)
state.count++
console.log(count.value) // 1
// BEST - just use ref() instead of reactive()
const count = ref(0)
const name = ref('Vue')
```
### Proxy Identity Hazard with reactive()
```ts
const raw = {}
const proxy = reactive(raw)
// WRONG - comparing different objects
console.log(proxy === raw) // false
// WRONG - creating multiple proxies
const a = reactive({})
const b = reactive(a) // Returns same proxy
console.log(a === b) // true (same object)
// GOTCHA - nested objects get proxied too
const nested = reactive({ obj: {} })
console.log(nested.obj === nested.obj) // true (same proxy)
```
## Computed Properties
### No Side Effects in Computed Getters
**Impact: HIGH** - Side effects break reactivity model.
```ts
// WRONG - mutates state
const doubled = computed(() => {
count.value++ // Side effect!
return count.value * 2
})
// WRONG - async operation
const data = computed(async () => {
return await fetch('/api') // Side effect!
})
// CORRECT - pure computation only
const doubled = computed(() => count.value * 2)
// For side effects, use watch:
watch(count, (newVal) => {
document.title = `Count: ${newVal}`
})
```
### Computed Returns Are Read-Only
```ts
const fullName = computed(() => `${first.value} ${last.value}`)
// WRONG - computed values are read-only
fullName.value = 'John Doe' // Error!
// CORRECT - use writable computed
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (val) => {
const [f, l] = val.split(' ')
first.value = f
last.value = l
}
})
```
## Watchers
### Clean Up Async Operations to Prevent Race Conditions
**Impact: HIGH** - Stale requests can overwrite newer data.
```ts
const query = ref('')
const results = ref([])
// WRONG - race condition
watch(query, async (q) => {
const res = await fetch(`/api?q=${q}`)
results.value = await res.json() // May overwrite newer results!
})
// CORRECT - use onWatcherCleanup (Vue 3.5+)
watch(query, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
try {
const res = await fetch(`/api?q=${q}`, { signal: controller.signal })
results.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})
// Or use onCleanup parameter
watch(query, async (q, oldQ, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
// ... same as above
})
```
### Deep Watch Returns Same Object Reference
```ts
const obj = reactive({ nested: { count: 0 } })
// GOTCHA - oldValue === newValue for deep watches
watch(obj, (newVal, oldVal) => {
console.log(newVal === oldVal) // true! Same object
}, { deep: true })
// If you need old value, clone first:
watch(
() => structuredClone(obj),
(newVal, oldVal) => { /* now different */ }
)
```
## Props
### Props Are Read-Only - Never Mutate
**Impact: HIGH** - Breaks one-way data flow.
```ts
const props = defineProps<{ count: number; user: User }>()
// WRONG - direct mutation
props.count++ // Vue warning
props.user.name = 'New' // No warning but still wrong!
// CORRECT - emit to parent
const emit = defineEmits(['update:count', 'update-user'])
emit('update:count', props.count + 1)
emit('update-user', { ...props.user, name: 'New' })
// Or create local copy
const localUser = ref({ ...props.user })
```
### Destructured Props Don't Update Watchers (pre-3.5)
```ts
// WRONG (Vue < 3.5)
const { count } = defineProps<{ count: number }>()
watch(count, () => {}) // Won't trigger!
// CORRECT - use getter
const props = defineProps<{ count: number }>()
watch(() => props.count, () => {})
// Vue 3.5+ - destructuring works with reactive props
const { count } = defineProps<{ count: number }>()
watch(() => count, () => {}) // Works in 3.5+
```
## Lifecycle Hooks
### Register Hooks Synchronously During Setup
**Impact: HIGH** - Async hooks silently fail.
```ts
// WRONG - hook registered after await
async setup() {
const data = await fetchData()
onMounted(() => {}) // Will NEVER run!
}
// WRONG - hook in setTimeout
setup() {
setTimeout(() => {
onMounted(() => {}) // Will NEVER run!
}, 100)
}
// CORRECT - register synchronously, async inside
setup() {
onMounted(async () => {
const data = await fetchData()
})
}
```
## Templates
### Never Use v-if with v-for on Same Element
**Impact: HIGH** - Vue 2/3 precedence differs.
```vue
<!-- WRONG - ambiguous precedence -->
<li v-for="user in users" v-if="user.active" :key="user.id">
<!-- Vue 3: v-if runs FIRST, 'user' undefined! -->
<!-- CORRECT - computed filter -->
<li v-for="user in activeUsers" :key="user.id">
<script setup>
const activeUsers = computed(() => users.filter(u => u.active))
</script>
<!-- CORRECT - template wrapper -->
<template v-for="user in users" :key="user.id">
<li v-if="user.active">{{ user.name }}</li>
</template>
```
### Template Refs Are Null with v-if
```ts
const inputRef = ref<HTMLInputElement | null>(null)
// GOTCHA - ref is null when element hidden
<input v-if="show" ref="inputRef" />
// WRONG - may be null
inputRef.value.focus() // Error if !show
// CORRECT - null check
inputRef.value?.focus()
// Or use watchEffect with flush: 'post'
watchEffect(() => {
inputRef.value?.focus()
}, { flush: 'post' })
```
## defineModel
### Object Mutations Don't Emit
```ts
const model = defineModel<{ name: string }>()
// WRONG - mutation doesn't notify parent
model.value.name = 'New' // Parent won't know!
// CORRECT - replace entire object
model.value = { ...model.value, name: 'New' }
```
### Updated Value Needs nextTick
```ts
const model = defineModel<string>()
// WRONG - value not updated yet
model.value = 'new'
console.log(model.value) // Still old value!
// CORRECT - wait for nextTick
model.value = 'new'
await nextTick()
console.log(model.value) // Now 'new'
```
## Component Events
### Undeclared Emits Can Fire Twice
```ts
// WRONG - missing emit declaration causes double firing
const emit = defineEmits([]) // 'click' not declared
<button @click="emit('click')"> // Fires twice!
// CORRECT - declare all custom events
const emit = defineEmits(['click'])
```
### Events Don't Bubble Through Components
```vue
<!-- Parent can't listen to grandchild events directly -->
<Grandparent>
<Parent>
<Child @custom="handler" /> <!-- Only Parent can listen -->
</Parent>
</Grandparent>
<!-- Solution: re-emit or use provide/inject -->
```
## Provide/Inject
### Reactivity Not Automatic
```ts
// Provider
const count = ref(0)
provide('count', count) // Pass the ref, not .value
// Consumer
const count = inject('count') // Receives the ref
console.log(count.value) // Reactive!
// WRONG - loses reactivity
provide('count', count.value) // Just passes number
```
### Must Call Provide Synchronously
```ts
// WRONG - provide after async
async setup() {
await fetchData()
provide('key', value) // Silently fails!
}
// CORRECT
setup() {
provide('key', value) // Synchronous
onMounted(async () => {
await fetchData()
})
}
```
## SSR
### Lifecycle Hooks Don't Run on Server
```ts
// onMounted, onUpdated, onUnmounted - client only
onMounted(() => {
// Only runs in browser
window.addEventListener('resize', handler)
})
// For SSR, use onServerPrefetch for data
onServerPrefetch(async () => {
data.value = await fetchData()
})
```
### Hydration Mismatch Causes
Common causes:
- Browser-only APIs (`window`, `localStorage`)
- Different timestamps
- Random values
- User-agent specific rendering
```ts
// WRONG
const width = ref(window.innerWidth) // undefined on server
// CORRECT
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})
```
## Performance
### Use shallowRef for Large Non-Reactive Data
```ts
// WRONG - deep reactivity overhead
const hugeList = ref(thousandsOfItems)
// CORRECT - only track .value assignment
const hugeList = shallowRef(thousandsOfItems)
// Trigger update by replacing entire array
hugeList.value = [...hugeList.value, newItem]
```
### markRaw for Non-Reactive Objects
```ts
// WRONG - Chart.js instance becomes reactive (breaks it)
const chart = ref(new Chart(ctx, config))
// CORRECT - mark as non-reactive
const chart = ref(markRaw(new Chart(ctx, config)))
```
## References
- [vuejs-ai/skills vue-best-practices](https://github.com/vuejs-ai/skills/tree/main/skills/vue-best-practices) - Full 200+ rules
- [Vue Style Guide](https://vuejs.org/style-guide/)
- [Vue 3 Migration Guide](https://v3-migration.vuejs.org/)

View File

@@ -0,0 +1,174 @@
---
name: Provide / Inject
description: Pass data through component tree without prop drilling
---
# Provide / Inject
Provide data from ancestor components to any descendant, avoiding prop drilling.
## Basic Usage
```vue
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const message = ref('hello')
provide('message', message)
</script>
```
```vue
<!-- DeepChild.vue (any level deep) -->
<script setup lang="ts">
import { inject } from 'vue'
const message = inject('message')
</script>
```
## Typing with InjectionKey
Use `InjectionKey` for type safety between provider and injector:
```ts
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const messageKey = Symbol() as InjectionKey<Ref<string>>
export const countKey = Symbol() as InjectionKey<number>
```
```vue
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { messageKey } from './keys'
const message = ref('hello')
provide(messageKey, message)
</script>
```
```vue
<!-- Injector.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { messageKey } from './keys'
const message = inject(messageKey) // Ref<string> | undefined
</script>
```
## Default Values
```ts
// Simple default
const value = inject('message', 'default value')
// Factory function (for expensive defaults)
const value = inject('key', () => new ExpensiveClass(), true)
// ^ treat as factory
```
## App-Level Provide
Available to all components:
```ts
// main.ts
import { createApp } from 'vue'
const app = createApp(App)
app.provide('globalConfig', { theme: 'dark' })
```
## Reactive Provide/Inject
Provide reactive values for automatic updates:
```vue
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const count = ref(0)
provide('count', count)
</script>
```
The injected value maintains reactivity connection.
## Mutations Best Practice
Keep mutations in the provider, expose update functions:
```vue
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref, readonly } from 'vue'
const location = ref('North Pole')
function updateLocation(newLocation: string) {
location.value = newLocation
}
provide('location', {
location: readonly(location), // Prevent direct mutation
updateLocation
})
</script>
```
```vue
<!-- Injector.vue -->
<script setup lang="ts">
import { inject } from 'vue'
const { location, updateLocation } = inject('location')!
</script>
<template>
<button @click="updateLocation('South Pole')">
{{ location }}
</button>
</template>
```
## Using Symbol Keys
Recommended for libraries and large apps to avoid collisions:
```ts
// keys.ts
export const myKey = Symbol('myKey')
// provider
provide(myKey, value)
// injector
inject(myKey)
```
## Type Helpers
```ts
// String key with explicit type
const foo = inject<string>('foo')
// ^? string | undefined
// With default (removes undefined)
const foo = inject<string>('foo', 'default')
// ^? string
// Force non-undefined (use when certain it's provided)
const foo = inject('foo') as string
```
<!--
Source references:
- https://vuejs.org/guide/components/provide-inject.html
- https://vuejs.org/guide/typescript/composition-api.html#typing-provide-inject
-->

View File

@@ -0,0 +1,289 @@
---
name: Vue Reactivity System
description: Core reactivity primitives - ref, reactive, computed, and watchers
---
# Vue Reactivity System
Vue's reactivity system enables automatic tracking of state changes and DOM updates.
## ref()
Create reactive primitive values with `ref()`. Access/modify via `.value` in JavaScript, auto-unwrapped in templates.
```ts
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++
```
```vue
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
```
### Typing refs
```ts
import { ref } from 'vue'
import type { Ref } from 'vue'
// Type inference
const year = ref(2020) // Ref<number>
// Explicit generic
const name = ref<string | null>(null)
// Ref type annotation
const id: Ref<string | number> = ref('abc')
```
## reactive()
Create reactive objects. No `.value` needed, but cannot reassign the entire object.
```ts
import { reactive } from 'vue'
interface State {
count: number
name: string
}
const state: State = reactive({
count: 0,
name: 'Vue'
})
state.count++ // reactive
```
### Limitations of reactive()
1. **Only works with objects** - not primitives
2. **Cannot replace entire object** - loses reactivity
3. **Destructuring loses reactivity** - use `toRefs()` instead
```ts
const state = reactive({ count: 0 })
// ❌ Loses reactivity
let { count } = state
// ✅ Use toRefs
import { toRefs } from 'vue'
const { count } = toRefs(state)
```
## Recommendation
Use `ref()` as the primary API for declaring reactive state - it works with any value type and has consistent behavior.
## Deep Reactivity
Both `ref()` and `reactive()` are deeply reactive by default:
```ts
const obj = ref({
nested: { count: 0 },
arr: ['foo', 'bar']
})
// These trigger updates
obj.value.nested.count++
obj.value.arr.push('baz')
```
Use `shallowRef()` or `shallowReactive()` to opt out of deep reactivity for performance.
## DOM Update Timing
DOM updates are batched and asynchronous. Use `nextTick()` to wait for updates:
```ts
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
await nextTick()
// DOM is now updated
}
```
## Ref Unwrapping Rules
- **In templates**: Top-level refs auto-unwrap
- **In reactive objects**: Refs auto-unwrap when accessed as properties
- **In arrays/collections**: Refs do NOT auto-unwrap
```ts
const count = ref(0)
const state = reactive({ count })
console.log(state.count) // 0 (unwrapped)
const books = reactive([ref('Vue Guide')])
console.log(books[0].value) // Need .value
```
## computed()
Derive values from reactive state with automatic caching. Only re-evaluates when dependencies change.
```ts
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Readonly computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// Writable computed
const fullNameWritable = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue: string) {
[firstName.value, lastName.value] = newValue.split(' ')
}
})
```
### Computed Best Practices
- **Getters should be pure** - no side effects, no mutating other state
- **Don't mutate computed values** - mutate the source instead
- **Use computed over methods** for derived data (caching benefit)
```ts
// ✅ Cached - only recalculates when items changes
const activeItems = computed(() => items.value.filter(x => x.active))
// ❌ Not cached - runs on every render
function getActiveItems() {
return items.value.filter(x => x.active)
}
```
## watch()
Explicitly watch reactive sources and run side effects when they change. Lazy by default.
```ts
import { ref, watch } from 'vue'
const id = ref(1)
watch(id, async (newId, oldId) => {
const data = await fetchData(newId)
// handle data...
})
```
### Watch Source Types
```ts
const x = ref(0)
const obj = reactive({ count: 0 })
// Single ref
watch(x, (newX) => console.log(newX))
// Getter function
watch(() => obj.count, (count) => console.log(count))
// Multiple sources
watch([x, () => obj.count], ([newX, newCount]) => {
console.log(newX, newCount)
})
```
### Watch Options
```ts
watch(source, callback, {
immediate: true, // Run immediately on creation
deep: true, // Watch nested properties
once: true, // Trigger only once (3.4+)
flush: 'post' // Run after DOM update
})
```
## watchEffect()
Automatically tracks dependencies and runs immediately. Re-runs when any tracked dependency changes.
```ts
import { ref, watchEffect } from 'vue'
const todoId = ref(1)
const data = ref(null)
watchEffect(async () => {
const response = await fetch(`/api/todos/${todoId.value}`)
data.value = await response.json()
})
```
### watch vs watchEffect
| Feature | watch | watchEffect |
| ------------------- | ---------------- | --------------------- |
| Dependency tracking | Explicit | Automatic |
| Lazy | Yes | No (immediate) |
| Access old value | Yes | No |
| Best for | Specific sources | Multiple dependencies |
## Watcher Cleanup (3.5+)
Cancel stale async operations:
```ts
import { watch, onWatcherCleanup } from 'vue'
watch(id, async (newId) => {
const controller = new AbortController()
fetch(`/api/${newId}`, { signal: controller.signal })
onWatcherCleanup(() => controller.abort())
})
```
## Stopping Watchers
```ts
const stop = watch(source, callback)
const stop2 = watchEffect(() => { /* ... */ })
// Stop manually
stop()
stop2()
// Pause/Resume (3.5+)
const { stop, pause, resume } = watchEffect(() => { /* ... */ })
```
<!--
Source references:
- https://vuejs.org/guide/essentials/reactivity-fundamentals.html
- https://vuejs.org/guide/essentials/computed.html
- https://vuejs.org/guide/essentials/watchers.html
- https://vuejs.org/api/reactivity-core.html
-->

View File

@@ -0,0 +1,181 @@
# Vue Router Typing
Type-safe routing patterns for Vue Router.
> **For Nuxt:** Use file-based routing instead. See `nuxt` skill for Nuxt routing patterns.
## Route Meta Types
Extend `RouteMeta` for typed route.meta:
```ts
// router/types.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
title?: string
roles?: ('admin' | 'user')[]
}
}
```
**Usage:**
```ts
const route = useRoute()
route.meta.requiresAuth // boolean | undefined (typed!)
route.meta.title // string | undefined
```
## Typed Route Params with unplugin-vue-router
Use `unplugin-vue-router` for fully typed routes:
```bash
pnpm add -D unplugin-vue-router
```
```ts
// vite.config.ts
import VueRouter from 'unplugin-vue-router/vite'
export default defineConfig({
plugins: [VueRouter(), Vue()], // VueRouter BEFORE Vue
})
```
**Typed useRoute:**
```ts
// Auto-generated route types from file structure
const route = useRoute('/users/[id]')
route.params.id // string (typed!)
const route = useRoute('/posts/[...slug]')
route.params.slug // string[] (typed!)
```
**Typed router.push:**
```ts
const router = useRouter()
// ✅ Type-checked
router.push({ name: '/users/[id]', params: { id: '123' } })
// ❌ TypeScript error - wrong param
router.push({ name: '/users/[id]', params: { userId: '123' } })
```
## Scroll Behavior Types
Type scroll behavior function:
```ts
import type { RouterScrollBehavior } from 'vue-router'
const scrollBehavior: RouterScrollBehavior = (to, from, savedPosition) => {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
}
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior,
})
```
## Dynamic Route Params
Handle union types for dynamic segments:
```ts
// routes/[type].vue where type can be 'posts' | 'users' | 'comments'
const route = useRoute()
// Narrow params type
type ContentType = 'posts' | 'users' | 'comments'
const type = route.params.type as ContentType
// Or use route guards
if (route.params.type === 'posts') {
// TypeScript knows type is 'posts'
}
```
## Navigation Guards Types
Type navigation guards:
```ts
import type { NavigationGuardWithThis, RouteLocationNormalized } from 'vue-router'
const authGuard: NavigationGuardWithThis<undefined> = (to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
router.beforeEach(authGuard)
```
**Per-route guards:**
```ts
const routes = [
{
path: '/admin',
component: AdminPage,
beforeEnter: (to: RouteLocationNormalized) => {
if (!hasAdminRole()) return { name: 'forbidden' }
},
},
]
```
## RouteLocation Types
Common route types:
```ts
import type {
RouteLocationNormalized, // Resolved route (after navigation)
RouteLocationNormalizedLoaded, // Current route (from useRoute)
RouteLocationRaw, // Input to router.push()
RouteRecordRaw, // Route config definition
} from 'vue-router'
```
## Common Mistakes
**Not extending RouteMeta module:**
```ts
// ❌ Loses type info
route.meta.customField // any
// ✅ Extend the interface
declare module 'vue-router' {
interface RouteMeta { customField: string }
}
```
**Assuming params are always strings:**
```ts
// Catch-all routes have string[] params
const route = useRoute()
// ❌ May be string[]
const id = route.params.id
// ✅ Handle both cases
const id = Array.isArray(route.params.id)
? route.params.id[0]
: route.params.id
```

View File

@@ -0,0 +1,294 @@
# Vue Testing
Test patterns for Vue 3 components, composables, and utilities.
## Quick Reference
| Test Type | Pattern |
| ---------------- | ------------------------------------ |
| Component | `mount(Component, { props, slots })` |
| User interaction | `await wrapper.trigger('click')` |
| Emitted events | `wrapper.emitted('update')` |
| Composable | Call directly, test return values |
| Utils | Pure function testing (easiest) |
## Stack
- **Vitest** - test runner
- **@vue/test-utils** - component mounting, interaction
- **@testing-library/vue** - user-centric alternative
- **happy-dom / jsdom** - DOM environment
## File Location
Colocate tests with code:
```
Button.vue → Button.spec.ts
useAuth.ts → useAuth.spec.ts
formatters.ts → formatters.spec.ts
```
## Component Tests
### Basic
```ts
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
it('renders slot', () => {
const wrapper = mount(Button, {
slots: { default: 'Click me' }
})
expect(wrapper.text()).toBe('Click me')
})
it('emits on click', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
```
### Props
```ts
it('applies variant class', () => {
const wrapper = mount(Button, {
props: { variant: 'primary' }
})
expect(wrapper.classes()).toContain('btn-primary')
})
```
### Emits
```ts
it('emits update with payload', async () => {
const wrapper = mount(Input)
await wrapper.find('input').setValue('new value')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value'])
})
```
### Slots
```ts
it('renders named slots', () => {
const wrapper = mount(Card, {
slots: {
header: '<h1>Title</h1>',
default: '<p>Content</p>'
}
})
expect(wrapper.html()).toContain('<h1>Title</h1>')
})
```
## Composable Tests
Call directly, no mounting needed:
```ts
import { useCounter } from './useCounter'
it('increments count', () => {
const { count, increment } = useCounter(0)
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
it('resets to initial', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
expect(count.value).toBe(7)
reset()
expect(count.value).toBe(5)
})
```
## Utils Tests
Easiest - pure functions:
```ts
import { formatCurrency, slugify } from './formatters'
describe('formatCurrency', () => {
it('formats USD', () => {
expect(formatCurrency(10.5)).toBe('$10.50')
})
})
describe('slugify', () => {
it('converts to lowercase', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('removes special chars', () => {
expect(slugify('Hello! World?')).toBe('hello-world')
})
})
```
## Mocking
**Composables:**
```ts
import { vi } from 'vitest'
vi.mock('./useAuth', () => ({
useAuth: vi.fn(() => ({
user: { id: 1, name: 'Test' },
isAuthenticated: true
}))
}))
```
**API calls:**
```ts
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: [] })
})
)
```
## Router Mocking
Mock `useRoute` and `useRouter` for component tests:
```ts
import { vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: { id: '123' },
query: { filter: 'active' },
path: '/users/123',
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
})),
}))
it('uses route params', () => {
const wrapper = mount(UserPage)
expect(wrapper.text()).toContain('123')
})
```
**Dynamic route mocking per test:**
```ts
import { useRoute } from 'vue-router'
it('handles different routes', () => {
vi.mocked(useRoute).mockReturnValue({
params: { id: '456' },
} as any)
const wrapper = mount(UserPage)
expect(wrapper.text()).toContain('456')
})
```
## Suspense and Teleport
**Testing async components with Suspense:**
```ts
import { flushPromises, mount } from '@vue/test-utils'
it('renders async content', async () => {
const wrapper = mount(AsyncComponent, {
global: {
stubs: { Suspense: false }, // Don't stub Suspense
},
})
// Wait for async setup to complete
await flushPromises()
expect(wrapper.text()).toContain('Loaded content')
})
```
**Testing Teleport:**
```ts
it('teleports modal content', () => {
const wrapper = mount(Modal, {
global: {
stubs: {
teleport: true, // Stub teleport to render inline
},
},
})
expect(wrapper.text()).toContain('Modal content')
})
```
**Access teleported content:**
```ts
it('finds teleported content', () => {
document.body.innerHTML = '<div id="modal-target"></div>'
mount(Modal, { props: { open: true } })
// Content teleports to #modal-target
expect(document.body.innerHTML).toContain('Modal content')
})
```
## Best Practices
**Do:**
- Test behavior (what user sees/does), not implementation
- Arrange-Act-Assert structure
- One assertion per test
- Descriptive test names
- Mock external dependencies
**Don't:**
- Test Vue internals (reactivity)
- Test third-party libraries
- Test trivial getters/setters
- Test implementation details
## What to Test
**Test:**
- User interactions (clicks, inputs)
- Conditional rendering
- Props validation, emitted events
- Computed values, business logic
**Skip:**
- Vue internals, third-party libs
- Trivial getters/setters
- Implementation details
## Running
```bash
pnpm test # all
pnpm exec vitest Button.spec.ts # specific
pnpm exec vitest --watch # watch
pnpm test --coverage # coverage
```
**Docs:** [vitest.dev](https://vitest.dev/) · [test-utils.vuejs.org](https://test-utils.vuejs.org/)

View File

@@ -0,0 +1,172 @@
# Vue TypeScript Patterns
TypeScript-specific patterns for Vue 3 development.
## Provide/Inject Types
Use `InjectionKey` for type-safe dependency injection:
```ts
import type { InjectionKey } from 'vue'
import type { User } from './types'
// Define typed key
export const UserKey: InjectionKey<User> = Symbol('user')
// Provider component
const user = ref<User>({ id: 1, name: 'John' })
provide(UserKey, user)
// Consumer component
const user = inject(UserKey) // Ref<User> | undefined
const user = inject(UserKey)! // Ref<User> (assert non-null)
```
**With default value:**
```ts
const user = inject(UserKey, ref({ id: 0, name: 'Guest' }))
// Type: Ref<User> (no undefined)
```
## vue-tsc Strict Templates
Enable stricter template type checking:
```bash
# Check templates with strict mode
vue-tsc --noEmit --strict-templates
```
Catches template errors like:
- Accessing non-existent properties
- Wrong prop types
- Missing required props
## tsconfig Settings
**Required for Vue 3:**
```json
{
"compilerOptions": {
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"strict": true,
"jsx": "preserve"
}
}
```
**`moduleResolution: "bundler"`** - Matches Vite/webpack resolution. Avoids `.js` extension issues.
**`verbatimModuleSyntax: true`** - Enforces explicit `type` imports:
```ts
// ❌ May cause issues with bundlers
import { User } from './types'
// ✅ Explicit type import
import type { User } from './types'
```
## Component Type Helpers
**Extract props type from component:**
```ts
import type { ComponentProps, ComponentSlots, ComponentEmits } from 'vue-component-type-helpers'
import MyComponent from './MyComponent.vue'
type Props = ComponentProps<typeof MyComponent>
type Slots = ComponentSlots<typeof MyComponent>
type Emits = ComponentEmits<typeof MyComponent>
```
**Extract exposed methods:**
```ts
import type { ComponentExposed } from 'vue-component-type-helpers'
type Exposed = ComponentExposed<typeof MyComponent>
```
## Generic Components
Define generic components with typed slots:
```vue
<script setup lang="ts" generic="T extends { id: string }">
defineProps<{
items: T[]
}>()
defineSlots<{
default: (props: { item: T }) => any
}>()
</script>
<template>
<div v-for="item in items" :key="item.id">
<slot :item="item" />
</div>
</template>
```
## Ref Type Narrowing
Handle ref type narrowing correctly:
```ts
const maybeUser = ref<User | null>(null)
// ❌ TypeScript still sees User | null
if (maybeUser.value) {
maybeUser.value.name // Error: possibly null
}
// ✅ Use computed or extract value
const userName = computed(() => maybeUser.value?.name ?? 'Guest')
// ✅ Or guard in same expression
maybeUser.value && maybeUser.value.name
```
## Event Handler Types
Type event handlers correctly:
```ts
// DOM events
const onClick = (e: MouseEvent) => { ... }
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement
console.log(target.value)
}
// Component emits
const onUpdate = (value: string) => { ... }
```
## Common Mistakes
**Forgetting to import types explicitly:**
```ts
// ❌ Runtime import of type-only
import { User } from './types'
// ✅ Type-only import
import type { User } from './types'
```
**Not using `as const` for literal types:**
```ts
// ❌ Type is string[]
const variants = ['primary', 'secondary']
// ✅ Type is readonly ['primary', 'secondary']
const variants = ['primary', 'secondary'] as const
```

View File

@@ -0,0 +1,156 @@
# Client Utilities
Pure functions for formatting, validation, transformation, and parsing.
## Quick Reference
| Category | Examples |
| ------------ | --------------------------------------------- |
| Formatters | `formatCurrency`, `formatDate`, `formatBytes` |
| Validators | `isValidEmail`, `isValidUrl`, `isValidPhone` |
| Transformers | `slugify`, `truncate`, `capitalize` |
| Parsers | `parseQuery`, `parseJSON`, `parseDate` |
## Rules
**Pure functions:**
- Same input → same output
- No side effects
- No external state mutation
- No API calls, no refs, no reactive
**When NOT to use utils:**
- Stateful logic → use composables
- Vue-specific → use composables
- Component logic → keep in component
- API calls → use queries
## Structure
```ts
// utils/formatters.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount)
}
export function formatRelativeTime(date: Date): string {
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
const diff = date.getTime() - Date.now()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
return rtf.format(days, 'day')
}
```
**Naming:** Descriptive verbs (`formatCurrency`, `validateEmail`, `parseQuery`)
**Organization:** Group by category (`formatters.ts`, `validators.ts`)
**Exports:** Named exports only
## Examples by Category
**Formatters:**
```ts
// utils/formatters.ts
export function formatBytes(bytes: number): string { ... }
export function formatPhone(phone: string): string { ... }
```
**Validators:**
```ts
// utils/validators.ts
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email)
}
export function isValidUrl(url: string): boolean {
try { new URL(url); return true }
catch { return false }
}
```
**Transformers:**
```ts
// utils/transformers.ts
export function slugify(text: string): string {
return text.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
}
export function truncate(text: string, length: number): string {
return text.length > length ? `${text.slice(0, length)}...` : text
}
```
**Parsers:**
```ts
// utils/parsers.ts
export function parseQuery(search: string): Record<string, string> {
return Object.fromEntries(new URLSearchParams(search))
}
export function parseJSON<T>(json: string, fallback: T): T {
try { return JSON.parse(json) }
catch { return fallback }
}
```
## Common Mistakes
**Side effects (not pure):**
```ts
// ❌ Wrong - mutates external state
let count = 0
export function increment() {
count++
return count
}
// ✅ Correct - pure
export function add(a: number, b: number): number {
return a + b
}
```
**Using utils for stateful logic:**
```ts
// ❌ Wrong - should be composable
export function useCounter() { ... }
// ✅ Correct - pure transformation
export function formatCount(count: number): string { ... }
```
## Organization
**Flat for small projects:**
```
utils/
├── formatters.ts
├── validators.ts
└── transformers.ts
```
**Nested for large projects:**
```
utils/
├── formatters/
│ ├── date.ts
│ ├── currency.ts
│ └── index.ts
└── validators/
├── email.ts
└── index.ts
```