Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c6b4f709 | ||
|
|
fee24dc1c2 | ||
|
|
b4ce7e29e1 | ||
|
|
d40bed60b5 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
```
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/shadcn
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/shadcn
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+555
-21
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"shadcn": {
|
||||
"source": "shadcn/ui",
|
||||
"sourceType": "github",
|
||||
"computedHash": "1ec42c9b918b315d8e5c6c856b78c3c4725fc93dea30b9b7e9975c69848fcce6"
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -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 基础组件验证')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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">
|
||||
这个临时页面用于集中验证 Button、Avatar、Select、Badge、Card、Checkbox、Dialog、Input
|
||||
的基础可用性、组合方式和交互状态。
|
||||
</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">
|
||||
观察 hover、focus、active 和 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>
|
||||
通过 DialogTrigger、DialogTitle 和 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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user