62 changed files with 2388 additions and 37 deletions
+77
View File
@@ -0,0 +1,77 @@
---
name: shadcn
description: Manages shadcn-vue components and projects in Vue 3 apps — initializing, adding, updating, styling, and composing UI in components.json-based repositories.
user-invocable: false
---
# shadcn-vue
Use this skill only for Vue 3 repositories that manage UI with `shadcn-vue`.
> Run commands with the project's package runner: `npx shadcn-vue@latest`, `pnpm dlx shadcn-vue@latest`, or `bunx --bun shadcn-vue@latest`.
## Use This Skill When
- The repository is a Vue 3 app.
- The user wants to initialize, add, update, or fix `shadcn-vue` components.
- The repository contains `components.json` or a `src/components/ui` directory.
## Current Project Context
```text
!`npx shadcn-vue@latest info 2>/dev/null || echo 'No shadcn-vue project found. Run shadcn-vue init first.'`
```
## Workflow
1. Run `npx shadcn-vue@latest info`.
2. Check `components.json` and the existing `src/components/ui` directory before adding anything.
3. Add components with `npx shadcn-vue@latest add <component>...`.
4. Review generated files under `src/components/ui/<name>/`.
5. Before overwriting an existing component, run `npx shadcn-vue@latest diff <component>`.
6. Only overwrite after explicit approval.
7. Verify with `pnpm build` or `vue-tsc --build`.
## Configuration Checks
- `components.json` should use `$schema: "https://shadcn-vue.com/schema.json"`.
- Keep `tailwind.css` pointing at the project's real global stylesheet.
- Alias maps should match the repository. Typical keys are `components`, `ui`, `utils`, `lib`, and `composables`.
- Generated UI components should be Vue component directories such as `src/components/ui/button/Button.vue` plus `src/components/ui/button/index.ts`.
- If `components.json` contains unsupported keys such as `rsc`, `tsx`, `rtl`, `menuColor`, or `menuAccent`, fix the configuration before adding components.
## Working Rules
- Use existing components before writing custom markup.
- Use alias imports such as `@/components/ui/button`.
- Prefer component variants and semantic tokens over raw colors.
- Use `class` for layout and spacing, not to restyle component internals.
- Use `gap-*` instead of `space-x-*` or `space-y-*`.
- Use `size-*` when width and height are equal.
- Use `truncate` instead of long overflow utility combinations.
- Use `cn()` for conditional class merging.
- `Avatar` should include `AvatarFallback`.
- `Dialog` should include `DialogTitle`.
- `SelectItem` should be placed inside `SelectGroup`.
- Use full `Card` composition when the subparts exist: `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`.
## Common Checks After `add`
- The generated files are `.vue` files with `index.ts` re-exports.
- Imports use project aliases instead of hard-coded relative paths.
- Required dependencies are present in `package.json`.
- The global CSS file still matches `components.json`.
- The build still passes.
## Quick Reference
```bash
npx shadcn-vue@latest info
npx shadcn-vue@latest init --force
npx shadcn-vue@latest add button card dialog input
npx shadcn-vue@latest diff button
```
## References
- [cli.md](./cli.md) — `shadcn-vue` command reference
+5
View File
@@ -0,0 +1,5 @@
interface:
display_name: "shadcn-vue"
short_description: "Manages shadcn-vue components in Vue 3 apps."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+96
View File
@@ -0,0 +1,96 @@
# shadcn-vue CLI Reference
Configuration is read from `components.json`.
> Run commands with the project's package runner: `npx shadcn-vue@latest`, `pnpm dlx shadcn-vue@latest`, or `bunx --bun shadcn-vue@latest`.
Only use flags that are confirmed by `shadcn-vue --help`.
## `info`
```bash
npx shadcn-vue@latest info
```
Use first. It reports framework detection, Tailwind CSS file, alias prefix, and the resolved `components.json` paths.
Supported flags:
- `--cwd <cwd>`
## `init`
```bash
npx shadcn-vue@latest init [components...] [options]
```
Use to initialize an existing Vue project for `shadcn-vue`.
Supported flags:
- `--base-color <base-color>`
- `--yes`
- `--defaults`
- `--force`
- `--cwd <cwd>`
- `--silent`
- `--css-variables`
- `--no-css-variables`
- `--no-base-style`
## `add`
```bash
npx shadcn-vue@latest add [components...] [options]
```
Use to add components by explicit name, URL, or local path.
Supported flags:
- `--yes`
- `--overwrite`
- `--cwd <cwd>`
- `--all`
- `--path <path>`
- `--silent`
- `--css-variables`
- `--no-css-variables`
Notes:
- Check `src/components/ui` before adding a component that may already exist.
- Review the generated files after `add`.
- Confirm before using `--overwrite`.
## `diff`
```bash
npx shadcn-vue@latest diff [component]
```
Use before overwriting an existing component.
Supported flags:
- `--yes`
- `--cwd <cwd>`
## Other Commands
`shadcn-vue` also exposes `migrate`, `build`, and `mcp`. Inspect their local help output before using them:
```bash
npx shadcn-vue@latest migrate --help
npx shadcn-vue@latest build --help
npx shadcn-vue@latest mcp --help
```
## Quick Reference
```bash
npx shadcn-vue@latest info
npx shadcn-vue@latest init --force
npx shadcn-vue@latest add button card dialog input
npx shadcn-vue@latest diff button
```
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/shadcn
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/shadcn
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"tailwind": {
"config": "",
"css": "src/styles/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"typescript": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
}
}
+8
View File
@@ -16,9 +16,17 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/vite": "^4.2.1",
"@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.4",
"reka-ui": "^2.9.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"vue": "^3.5.29",
"vue-router": "^5.0.3"
},
+555 -21
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"shadcn": {
"source": "shadcn/ui",
"sourceType": "github",
"computedHash": "1ec42c9b918b315d8e5c6c856b78c3c4725fc93dea30b9b7e9975c69848fcce6"
}
}
}
+1
View File
@@ -0,0 +1 @@
../.agents/skills/shadcn
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router'
import App from '@/App.vue'
import { createAppRouter } from '@/router'
import ShadcnComponentsSpike from '@/spikes/shadcn-components/ShadcnComponentsSpike.vue'
describe('shadcn component spike page', () => {
it('renders the requested component surface', () => {
const wrapper = mount(ShadcnComponentsSpike)
expect(wrapper.get('[data-testid="shadcn-spike-title"]').text()).toContain(
'shadcn-vue 基础组件验证',
)
expect(wrapper.get('[data-testid="summary-text"]').text()).toContain('Forge Operator')
expect(wrapper.text()).toContain('Button / Avatar / Select / Badge / Card / Checkbox / Dialog / Input')
})
it('updates the summary when the input changes', async () => {
const wrapper = mount(ShadcnComponentsSpike)
await wrapper.get('[data-testid="profile-name-input"]').setValue('Dialog Pilot')
expect(wrapper.get('[data-testid="summary-text"]').text()).toContain('Dialog Pilot')
})
})
describe('shadcn spike route', () => {
it('renders the spike page at /__spike/shadcn-components', async () => {
const router = createAppRouter(createMemoryHistory())
const wrapper = mount(App, {
global: {
plugins: [router],
},
})
await router.push('/__spike/shadcn-components')
await flushPromises()
expect(router.currentRoute.value.name).toBe('spike-shadcn-components')
expect(wrapper.text()).toContain('shadcn-vue 基础组件验证')
})
})
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template>
+3
View File
@@ -0,0 +1,3 @@
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+38
View File
@@ -0,0 +1,38 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CheckIcon } from '@radix-icons/vue'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<CheckIcon class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue"
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Cross2Icon } from '@radix-icons/vue'
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<Cross2Icon />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { DialogClose } from "reka-ui"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
showCloseButton?: boolean
}>(), {
showCloseButton: false,
})
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="outline">
Close
</Button>
</DialogClose>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Cross2Icon } from '@radix-icons/vue'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<Cross2Icon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>
+10
View File
@@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from "./Input.vue"
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectGroupProps } from "reka-ui"
import { SelectGroup } from "reka-ui"
const props = defineProps<SelectGroupProps>()
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="props"
>
<slot />
</SelectGroup>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CheckIcon } from '@radix-icons/vue'
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<CheckIcon class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDownIcon } from '@radix-icons/vue'
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronDownIcon class="size-4" />
</slot>
</SelectScrollDownButton>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronUpIcon } from '@radix-icons/vue'
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronUpIcon class="size-4" />
</slot>
</SelectScrollUpButton>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDownIcon } from '@radix-icons/vue'
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDownIcon class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectValueProps } from "reka-ui"
import { SelectValue } from "reka-ui"
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="props"
>
<slot />
</SelectValue>
</template>
+11
View File
@@ -0,0 +1,11 @@
export { default as Select } from "./Select.vue"
export { default as SelectContent } from "./SelectContent.vue"
export { default as SelectGroup } from "./SelectGroup.vue"
export { default as SelectItem } from "./SelectItem.vue"
export { default as SelectItemText } from "./SelectItemText.vue"
export { default as SelectLabel } from "./SelectLabel.vue"
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
export { default as SelectSeparator } from "./SelectSeparator.vue"
export { default as SelectTrigger } from "./SelectTrigger.vue"
export { default as SelectValue } from "./SelectValue.vue"
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -37,6 +37,16 @@ export const spikeRoutes: RouteRecordRaw[] = [
createdAt: '2026-03-16',
},
},
{
path: `${SPIKE_PREFIX}/shadcn-components`,
name: 'spike-shadcn-components',
component: () => import('@/spikes/shadcn-components/ShadcnComponentsSpike.vue'),
meta: {
type: 'spike',
description: 'shadcn-vue 基础组件验证',
createdAt: '2026-03-16',
},
},
// 在此添加更多 spike 路由...
// {
// path: `${SPIKE_PREFIX}/router-layout`,
@@ -0,0 +1,469 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
type ButtonVariant = 'default' | 'secondary' | 'outline' | 'destructive'
type ButtonSize = 'sm' | 'default' | 'lg'
type Role = 'builder' | 'reviewer' | 'operator'
const profileName = ref('Forge Operator')
const selectedRole = ref<Role>('builder')
const notificationsEnabled = ref(true)
const controlsDisabled = ref(false)
const buttonVariant = ref<ButtonVariant>('default')
const buttonSize = ref<ButtonSize>('default')
const avatarEnabled = ref(true)
const dialogOpen = ref(false)
const buttonVariants: readonly ButtonVariant[] = ['default', 'secondary', 'outline', 'destructive']
const buttonSizes: readonly ButtonSize[] = ['sm', 'default', 'lg']
const roleLabels: Record<Role, string> = {
builder: 'Builder',
reviewer: 'Reviewer',
operator: 'Operator',
}
const profileInitials = computed(() => {
const source = profileName.value.trim() || 'Forge Operator'
return source
.split(/\s+/)
.slice(0, 2)
.map(part => part[0]?.toUpperCase() ?? '')
.join('')
})
const profileAvatarSrc = computed(() => {
const initials = profileInitials.value || 'FO'
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<defs>
<linearGradient id="panel" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#d9b26a" />
<stop offset="100%" stop-color="#85a8ff" />
</linearGradient>
</defs>
<rect width="96" height="96" rx="24" fill="#111827" />
<circle cx="48" cy="48" r="38" fill="url(#panel)" opacity="0.92" />
<text
x="50%"
y="54%"
text-anchor="middle"
dominant-baseline="middle"
font-family="Arial, sans-serif"
font-size="28"
font-weight="700"
fill="#111827"
>${initials}</text>
</svg>
`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
})
const summaryText = computed(() => {
return [
profileName.value || 'Forge Operator',
roleLabels[selectedRole.value],
notificationsEnabled.value ? 'Notifications on' : 'Notifications muted',
controlsDisabled.value ? 'Controls locked' : 'Controls live',
].join(' · ')
})
</script>
<template>
<div
data-testid="shadcn-spike-page"
class="relative min-h-screen overflow-hidden bg-background text-foreground"
>
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-24 left-0 size-72 rounded-full bg-signal/18 blur-3xl" />
<div class="absolute top-40 right-0 size-80 rounded-full bg-clay/18 blur-3xl" />
<div class="absolute bottom-0 left-1/3 h-56 w-80 rounded-full bg-mist/12 blur-3xl" />
</div>
<div class="relative mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-8 px-6 py-8 lg:px-10">
<header class="rounded-[2rem] border border-border/60 bg-card/75 p-6 shadow-sm backdrop-blur">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">Spike Route</Badge>
<Badge variant="outline">/__spike/shadcn-components</Badge>
<Badge :variant="controlsDisabled ? 'destructive' : 'default'">
{{ controlsDisabled ? 'Frozen State' : 'Interactive State' }}
</Badge>
</div>
<div class="space-y-2">
<h1
data-testid="shadcn-spike-title"
class="font-display text-3xl tracking-tight text-clay sm:text-4xl"
>
shadcn-vue 基础组件验证
</h1>
<p class="max-w-3xl text-sm leading-6 text-muted-foreground sm:text-base">
这个临时页面用于集中验证 ButtonAvatarSelectBadgeCardCheckboxDialogInput
的基础可用性组合方式和交互状态
</p>
</div>
</div>
<Card class="w-full max-w-sm border-border/70 bg-background/70 py-4">
<CardHeader class="px-4 pb-0">
<CardTitle class="text-sm">Live Summary</CardTitle>
<CardDescription>页面状态由左侧控制面板驱动</CardDescription>
</CardHeader>
<CardContent class="px-4 pt-4">
<p
data-testid="summary-text"
class="text-sm leading-6 text-foreground"
>
{{ summaryText }}
</p>
</CardContent>
</Card>
</div>
</header>
<main class="grid flex-1 gap-6 lg:grid-cols-[22rem_minmax(0,1fr)]">
<Card class="h-fit border-border/70 bg-card/80 shadow-sm backdrop-blur">
<CardHeader>
<CardTitle>State Cockpit</CardTitle>
<CardDescription>
用本地状态切换各个 shadcn-vue 组件的展示结果
</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-5">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Input · Profile name</label>
<Input
data-testid="profile-name-input"
v-model="profileName"
placeholder="Type a profile name"
:disabled="controlsDisabled"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Select · Working role</label>
<Select v-model:model-value="selectedRole" :disabled="controlsDisabled">
<SelectTrigger class="w-full">
<SelectValue placeholder="Choose a role" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Team role</SelectLabel>
<SelectItem value="builder">Builder</SelectItem>
<SelectItem value="reviewer">Reviewer</SelectItem>
<SelectItem value="operator">Operator</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="rounded-xl border border-border/70 bg-background/60 p-4">
<div class="flex items-start gap-3">
<Checkbox
id="notifications"
v-model:model-value="notificationsEnabled"
:disabled="controlsDisabled"
class="mt-0.5"
/>
<div class="space-y-1">
<label class="text-sm font-medium" for="notifications">
Checkbox · Notifications
</label>
<p class="text-xs leading-5 text-muted-foreground">
验证勾选态禁用态和摘要联动
</p>
</div>
</div>
</div>
<div class="flex flex-col gap-3">
<p class="text-sm font-medium">Button · Variant</p>
<div class="flex flex-wrap gap-2">
<Button
v-for="variant in buttonVariants"
:key="variant"
type="button"
size="sm"
:variant="buttonVariant === variant ? 'default' : 'outline'"
@click="buttonVariant = variant"
>
{{ variant }}
</Button>
</div>
</div>
<div class="flex flex-col gap-3">
<p class="text-sm font-medium">Button · Size</p>
<div class="flex flex-wrap gap-2">
<Button
v-for="size in buttonSizes"
:key="size"
type="button"
size="sm"
:variant="buttonSize === size ? 'secondary' : 'outline'"
@click="buttonSize = size"
>
{{ size }}
</Button>
</div>
</div>
<div class="rounded-xl border border-border/70 bg-background/60 p-4">
<div class="flex items-start gap-3">
<Checkbox
id="avatar-preview"
v-model:model-value="avatarEnabled"
:disabled="controlsDisabled"
class="mt-0.5"
/>
<div class="space-y-1">
<label class="text-sm font-medium" for="avatar-preview">
Avatar image preview
</label>
<p class="text-xs leading-5 text-muted-foreground">
关闭后可以直接观察 AvatarFallback
</p>
</div>
</div>
</div>
<Button
type="button"
variant="outline"
class="w-full"
@click="controlsDisabled = !controlsDisabled"
>
{{ controlsDisabled ? 'Unlock interactive controls' : 'Lock interactive controls' }}
</Button>
</CardContent>
</Card>
<div class="grid gap-6 xl:grid-cols-2">
<Card class="border-border/70 bg-card/80 shadow-sm backdrop-blur xl:col-span-2">
<CardHeader>
<CardTitle>Button + Badge</CardTitle>
<CardDescription>
验证主要动作按钮状态徽章和组件变体切换
</CardDescription>
<CardAction>
<Badge variant="outline">{{ buttonVariant }} / {{ buttonSize }}</Badge>
</CardAction>
</CardHeader>
<CardContent class="flex flex-col gap-6">
<div class="flex flex-wrap items-center gap-3">
<Button
type="button"
:variant="buttonVariant"
:size="buttonSize"
:disabled="controlsDisabled"
>
Primary Action
</Button>
<Button type="button" variant="secondary" size="sm" :disabled="controlsDisabled">
Secondary
</Button>
<Button type="button" variant="outline" size="sm" :disabled="controlsDisabled">
Outline
</Button>
<Button type="button" variant="destructive" size="sm" :disabled="controlsDisabled">
Destructive
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge>default</Badge>
<Badge variant="secondary">secondary</Badge>
<Badge variant="outline">outline</Badge>
<Badge variant="destructive">destructive</Badge>
</div>
</CardContent>
<CardFooter class="px-6 pt-0">
<p class="text-xs leading-5 text-muted-foreground">
观察 hoverfocusactive disabled 状态时不需要依赖真实后端
</p>
</CardFooter>
</Card>
<Card class="border-border/70 bg-card/80 shadow-sm backdrop-blur">
<CardHeader>
<CardTitle>Avatar + Identity</CardTitle>
<CardDescription>
验证 AvatarImage / AvatarFallback 和摘要联动
</CardDescription>
</CardHeader>
<CardContent class="flex items-start gap-4">
<Avatar class="size-18 border border-border/70 shadow-sm">
<AvatarImage
v-if="avatarEnabled"
:src="profileAvatarSrc"
:alt="profileName"
/>
<AvatarFallback class="bg-secondary text-secondary-foreground">
{{ profileInitials }}
</AvatarFallback>
</Avatar>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<p class="truncate text-lg font-semibold">{{ profileName }}</p>
<p class="text-sm text-muted-foreground">{{ roleLabels[selectedRole] }}</p>
<div class="flex flex-wrap gap-2">
<Badge :variant="notificationsEnabled ? 'default' : 'outline'">
{{ notificationsEnabled ? 'Notifications on' : 'Notifications muted' }}
</Badge>
<Badge variant="secondary">
{{ avatarEnabled ? 'Image enabled' : 'Fallback only' }}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card class="border-border/70 bg-card/80 shadow-sm backdrop-blur">
<CardHeader>
<CardTitle>Input + Card Composition</CardTitle>
<CardDescription>
用完整 Card 结构展示表单输入结果和静态说明
</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Mirrored input</label>
<Input
:model-value="profileName"
placeholder="Mirrored field"
disabled
/>
</div>
<div class="rounded-xl border border-dashed border-border/80 bg-background/60 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Route contract
</p>
<p class="mt-2 font-mono text-sm text-foreground">
/__spike/shadcn-components
</p>
</div>
</CardContent>
<CardFooter class="px-6 pt-0">
<p class="text-xs leading-5 text-muted-foreground">
这个页面是临时验证面不应成为默认入口
</p>
</CardFooter>
</Card>
<Card class="border-border/70 bg-card/80 shadow-sm backdrop-blur xl:col-span-2">
<CardHeader>
<CardTitle>Dialog Contract</CardTitle>
<CardDescription>
通过 DialogTriggerDialogTitle DialogFooter 验证复合组件组合
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap items-center justify-between gap-4">
<div class="space-y-2">
<p class="text-sm leading-6 text-muted-foreground">
当前人物卡会被带入弹窗内容便于确认输入徽章和按钮在 overlay 里也能正常工作
</p>
<p class="font-mono text-xs text-muted-foreground">
Components: Button / Avatar / Select / Badge / Card / Checkbox / Dialog / Input
</p>
</div>
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button type="button" :disabled="controlsDisabled">
Open validation dialog
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Review component state</DialogTitle>
<DialogDescription>
这个弹窗只服务于 spike 验证不连接真实数据
</DialogDescription>
</DialogHeader>
<div class="grid gap-5 py-2 sm:grid-cols-[auto_minmax(0,1fr)]">
<Avatar class="size-16 border border-border/70">
<AvatarImage
v-if="avatarEnabled"
:src="profileAvatarSrc"
:alt="profileName"
/>
<AvatarFallback class="bg-secondary text-secondary-foreground">
{{ profileInitials }}
</AvatarFallback>
</Avatar>
<div class="flex min-w-0 flex-col gap-3">
<Input :model-value="profileName" disabled />
<div class="flex flex-wrap gap-2">
<Badge>{{ roleLabels[selectedRole] }}</Badge>
<Badge :variant="notificationsEnabled ? 'secondary' : 'outline'">
{{ notificationsEnabled ? 'Subscribed' : 'Paused' }}
</Badge>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="dialogOpen = false">
Close
</Button>
<Button type="button" @click="dialogOpen = false">
Confirm layout
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</div>
</main>
</div>
</div>
</template>
+120 -1
View File
@@ -1,4 +1,8 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@fontsource-variable/geist';
@custom-variant dark (&:is(.dark *));
@theme {
--color-ink: oklch(0.19 0.02 255);
@@ -19,10 +23,11 @@
* {
box-sizing: border-box;
@apply border-border outline-ring/50;
}
body {
@apply m-0 min-h-screen bg-ink font-body text-paper antialiased;
@apply m-0 min-h-screen font-body antialiased bg-background text-foreground;
background-image:
radial-gradient(
circle at top left,
@@ -52,8 +57,122 @@
background: color-mix(in srgb, var(--color-signal) 55%, white 12%);
color: var(--color-ink);
}
html {
@apply font-sans;
}
}
#app {
min-height: 100vh;
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
+7 -15
View File
@@ -1,27 +1,19 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"src/**/__tests__/*"
],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"types": [
"vite/client"
],
"baseUrl": ".",
"types": ["vite/client"],
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
}
+6
View File
@@ -1,4 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{