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:
103
.claude/skills/vue/SKILL.md
Normal file
103
.claude/skills/vue/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: vue
|
||||
description: Use when editing .vue files, creating Vue 3 components, writing composables, or testing Vue code - provides Composition API patterns, props/emits best practices, VueUse integration, and reactive destructuring guidance
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Vue 3 Development
|
||||
|
||||
Reference for Vue 3 Composition API patterns, component architecture, and testing practices.
|
||||
|
||||
**Current stable:** Vue 3.5+ with enhanced reactivity performance (-56% memory, 10x faster array tracking), new SSR features, and improved developer experience.
|
||||
|
||||
## Overview
|
||||
|
||||
Progressive reference system for Vue 3 projects. Load only files relevant to current task to minimize context usage (~250 tokens base, 500-1500 per sub-file).
|
||||
|
||||
## When to Use
|
||||
|
||||
**Use this skill when:**
|
||||
|
||||
- Writing `.vue` components
|
||||
- Creating composables (`use*` functions)
|
||||
- Building client-side utilities
|
||||
- Testing Vue components/composables
|
||||
|
||||
**Use `nuxt` skill instead for:**
|
||||
|
||||
- Server routes, API endpoints
|
||||
- File-based routing, middleware
|
||||
- Nuxt-specific patterns
|
||||
|
||||
**For styled UI components:** use `nuxt-ui` skill
|
||||
**For headless accessible components:** use `reka-ui` skill
|
||||
**For VueUse composables:** use `vueuse` skill
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Working on... | Load file |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| `.vue` in `components/` | references/components.md |
|
||||
| File in `composables/` | references/composables.md |
|
||||
| File in `utils/` | references/utils-client.md |
|
||||
| `.spec.ts` or `.test.ts` | references/testing.md |
|
||||
| TypeScript patterns | references/typescript.md |
|
||||
| Vue Router typing | references/router.md |
|
||||
| Reactivity (ref, watch) | references/reactivity.md |
|
||||
| Custom directives | references/directives.md |
|
||||
| Provide/inject | references/provide-inject.md |
|
||||
| Edge cases, vue-tsc | references/gotchas.md |
|
||||
|
||||
## Loading Files
|
||||
|
||||
**Consider loading these reference files based on your task:**
|
||||
|
||||
- [ ] [references/components.md](references/components.md) - if working in `components/` or writing `.vue` files
|
||||
- [ ] [references/composables.md](references/composables.md) - if creating composables (`use*` functions)
|
||||
- [ ] [references/utils-client.md](references/utils-client.md) - if working in `utils/` or writing client utilities
|
||||
- [ ] [references/testing.md](references/testing.md) - if writing `.spec.ts` or `.test.ts` files
|
||||
- [ ] [references/typescript.md](references/typescript.md) - if working with Vue TypeScript patterns or generics
|
||||
- [ ] [references/router.md](references/router.md) - if working with Vue Router or route typing
|
||||
- [ ] [references/reactivity.md](references/reactivity.md) - if using ref, reactive, computed, watch, or watchEffect
|
||||
- [ ] [references/directives.md](references/directives.md) - if creating or using custom directives
|
||||
- [ ] [references/provide-inject.md](references/provide-inject.md) - if using provide/inject patterns
|
||||
- [ ] [references/gotchas.md](references/gotchas.md) - if debugging edge cases or hydration issues
|
||||
|
||||
**DO NOT load all files at once.** Load only what's relevant to your current task.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { count = 0 } = defineProps<{ count?: number }>()
|
||||
const emit = defineEmits<{ update: [value: number] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="emit('update', count + 1)">
|
||||
Count: {{ count }}
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Available Guidance
|
||||
|
||||
**[references/components.md](references/components.md)** - Props with reactive destructuring, emits patterns, defineModel for v-model, slots shorthand
|
||||
|
||||
**[references/composables.md](references/composables.md)** - Composition API structure, VueUse integration, lifecycle hooks, async patterns, reactivity gotchas
|
||||
|
||||
**[references/utils-client.md](references/utils-client.md)** - Pure functions, formatters, validators, transformers, when NOT to use utils
|
||||
|
||||
**[references/testing.md](references/testing.md)** - Vitest + @vue/test-utils, component testing, composable testing, router mocking
|
||||
|
||||
**[references/typescript.md](references/typescript.md)** - InjectionKey for provide/inject, vue-tsc strict templates, tsconfig settings, generic components
|
||||
|
||||
**[references/router.md](references/router.md)** - Route meta types, typed params with unplugin-vue-router, scroll behavior, navigation guards
|
||||
|
||||
**[references/reactivity.md](references/reactivity.md)** - ref, reactive, computed, watch, watchEffect, reactivity fundamentals
|
||||
|
||||
**[references/directives.md](references/directives.md)** - Custom directive hooks, v-focus, v-click-outside, v-tooltip patterns
|
||||
|
||||
**[references/provide-inject.md](references/provide-inject.md)** - InjectionKey typing, app-level provide, readonly patterns
|
||||
|
||||
**[references/gotchas.md](references/gotchas.md)** - Common gotchas, vue-tsc edge cases, hydration issues, race conditions (from vuejs-ai/skills)
|
||||
323
.claude/skills/vue/references/components.md
Normal file
323
.claude/skills/vue/references/components.md
Normal 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
|
||||
358
.claude/skills/vue/references/composables.md
Normal file
358
.claude/skills/vue/references/composables.md
Normal 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))
|
||||
```
|
||||
225
.claude/skills/vue/references/directives.md
Normal file
225
.claude/skills/vue/references/directives.md
Normal 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
|
||||
-->
|
||||
438
.claude/skills/vue/references/gotchas.md
Normal file
438
.claude/skills/vue/references/gotchas.md
Normal 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/)
|
||||
174
.claude/skills/vue/references/provide-inject.md
Normal file
174
.claude/skills/vue/references/provide-inject.md
Normal 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
|
||||
-->
|
||||
289
.claude/skills/vue/references/reactivity.md
Normal file
289
.claude/skills/vue/references/reactivity.md
Normal 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
|
||||
-->
|
||||
181
.claude/skills/vue/references/router.md
Normal file
181
.claude/skills/vue/references/router.md
Normal 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
|
||||
```
|
||||
294
.claude/skills/vue/references/testing.md
Normal file
294
.claude/skills/vue/references/testing.md
Normal 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/)
|
||||
172
.claude/skills/vue/references/typescript.md
Normal file
172
.claude/skills/vue/references/typescript.md
Normal 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
|
||||
```
|
||||
156
.claude/skills/vue/references/utils-client.md
Normal file
156
.claude/skills/vue/references/utils-client.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user