## プロジェクト準備 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://www.shadcn-vue.com/favicon-16x16.png" /> <span class="link-card-v2-site-name">www.shadcn-vue.com</span> </div> <div class="link-card-v2-title"> Vite </div> <div class="link-card-v2-content"> Install and configure Vite. </div> <img class="link-card-v2-image" src="https://www.shadcn-vue.com/__og-image__/static/docs/installation/vite/og.png" /> <a href="https://www.shadcn-vue.com/docs/installation/vite"></a> </div> ``` ├── @tailwindcss/[email protected] ├── @vitejs/[email protected] ├── @vue/[email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] └── [email protected] ``` [[vite-tsconfig-paths]]を使った方法は `@/` の参照が解決しなかったので [[resolve]] を使った。 ## [[cn]]について [[shadcn-vue]]が[[cn]]という関数を作っている。 `src/lib/utils.ts` ```ts import type { ClassValue } from "clsx" import { clsx } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` これは[[clsx]]と[[tailwind-merge]]を合成した関数である。 ### [[clsx]]とは 条件をいい感じに適応して有効なclassのみを残すために使用。 ```html <script setup lang="ts"> import clsx from "clsx"; const mergedClass = clsx("base"); </script> <template> <div :class="mergedClass"></div> </template> ``` これは以下のようになる。 ```html <div class="base"></civ> ``` `mergedClass` の値を変えて `class` の変化を見ていく。 ```ts const mergedClass = clsx("base", true && "yes"); // "base yes" const mergedClass = clsx("base", false && "yes"); // "base" const mergedClass = clsx("base", { "variant-a": true, "variant-b": false, }); // "base variant-a" const mergedClass = clsx("base", {}); // "base" const mergedClass = clsx("base", ["va", "", false, null, undefined, "vb"]); // "base va vb" const mergedClass = clsx("base", [ "va", ["vb", false], { vc: true, vd: false }, ]); // base va vb vc ``` ### [[tailwind-merge]]とは ```html <script setup lang="ts"> const mergedClass = "p-3 p-4"; </script> <template> <div :class="mergedClass"></div> </template> ``` これは以下のようになる。`p-3` と `p-4` が共存してしまってよろしくない。 ```html <div class="p-3 p-4"></div> ``` `twMerge` を使うとこうなる。 ```html <script setup lang="ts"> import { twMerge } from 'tailwind-merge'; const mergedClass = twMerge("p-3 p-4"); </script> <template> <div :class="mergedClass"></div> </template> ``` ```html <div class="p-4"></div> ``` `mergedClass` の値を変えて変化を見ていく。 ```ts // 基本は後勝ち const mergedClass = twMerge("p-3 p-4"); // "p-4" const mergedClass = twMerge("p-4 p-3"); // "p-3" // 可変長の指定 const mergedClass = twMerge("p-4", "p-3"); // "p-3" const mergedClass = twMerge("p-4 p-5", "p-3 p-6"); // "p-6" ``` ### [[cn]]とは 改めて[[cn]]を見る。 `src/lib/utils.ts` ```ts import type { ClassValue } from "clsx" import { clsx } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` `ClassValue` は[[配列 (JavaScript)|配列]]や[[オブジェクト (JavaScript)|オブジェクト]]を含んだクラス表現はすべて受け付ける。 ```ts export type ClassValue = ClassArray | ClassDictionary | string | number | bigint | null | boolean | undefined; ``` つまり、[[cn]]に可変長で適当に放り込んでおけばよしなにやってくれる。 ```ts const mergedClass = cn( "text-red-200", { "text-blue-300": true }, ["", "bg-green-500", false, {}], {}, "inline-flex", ["flex", ["items-center", ["justify-center"]]], ); // "text-blue-300 bg-green-500 flex items-center justify-center" ``` ## [[cva]]について [[Class Variance Authority]]の関数[[cva]]を使って、独自のButtonコンポーネントを設計してみる。まずは `.ts` ファイルで `buttonVariant` 関数を作成。 `src/components/minerva/button/index.ts` ```ts const buttonVariant = cva(["text-gray-400", "rounded"], { variants: { // variantと呼ばれることも多い intent: { primary: ["bg-blue-600", "text-white"], danger: ["bg-red-600", "text-white"], }, size: { small: ["px-3", "py-1", "text-sm"], medium: ["px-4", "py-2", "text-base"], large: ["px-5", "py-3", "text-lg"], }, // disabled がついているときだけ特定のスタイルを適用 disabled: { true: ["opacity-50", "cursor-not-allowed"], false: [], }, }, defaultVariants: { size: "medium", disabled: false, }, }); export { buttonVariant }; ``` 引数に指定したい `variants` の各要素を指定すると、対象要素のクラス名を複数返してくれる。 ```ts buttonVariant() // size: "medium" // text-gray-400 rounded px-4 py-2 text-base buttonVariant({ size: "large" }) // text-gray-400 rounded px-5 py-3 text-lg buttonVariant({ size: "large", intent: "danger" }) // text-gray-400 rounded bg-red-600 text-white px-5 py-3 text-lg buttonVariant({ size: "large", intent: "danger", disabled: true }) // text-gray-400 rounded bg-red-600 text-white px-5 py-3 text-lg opacity-50 cursor-not-allowed ``` ### 競合の解消 このときは `text-gray-400` と `text-white` のように**競合するもの**も存在する。 ```ts buttonVariant({ size: "large", intent: "danger", disabled: true }) // text-gray-400 rounded bg-red-600 text-white px-5 py-3 text-lg opacity-50 cursor-not-allowed ``` [[cn]]を使って競合を解消する。 ```ts cn(buttonVariant({ size: "large", intent: "danger", disabled: true })) // rounded bg-red-600 text-white px-5 py-3 text-lg opacity-50 cursor-not-allowed ``` [[cva]]では `base` よりも指定した `variants` のvariantに紐づくクラスの方が優先される。クラス名が後ろに追加され、[[cn]]([[tailwind-merge]])では後に指定されたクラスが優先されるため。 これをそのまま `:class` に指定できる。 ```html <script setup lang="ts"> import { buttonVariant, } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; </script> <template> <button :class=" cn(buttonVariant({ size: 'large', intent: 'danger', disabled: true })) " > ボタン </button> </template> ``` ### スマートな[[props (Vue)|props]]指定 `variants` の各variantは、[[Vue]]の[[props (Vue)|props]]で指定できる方がスマートである。[[VariantProps型]]を使って、各variantの型を定義できる。 `src/components/minerva/button/index.ts` ```ts // 中略 export { buttonVariant }; export type ButtonVariantProps = VariantProps<typeof buttonVariant>; // type ButtonVariantProps = { // intent?: "primary" | "danger" | null | undefined; // size?: "small" | "medium" | "large" | null | undefined; // disabled?: boolean | null | undefined; // } ``` vueファイルでは[[インデックス型]]を使う。[[VariantProps型はすべてのプロパティがOptional]]となるが、このように分離することで、[[Nullish (JavaScript)|Nullish]]ではあるがrequiredの型へと昇格できる。 `src/components/minerva/button/MButton.vue` ```html <script setup lang="ts"> import { buttonVariant, type ButtonVariantProps } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; defineProps<{ // required intent: ButtonVariantProps["intent"]; // 以下はoptional size?: ButtonVariantProps["size"]; disabled?: ButtonVariantProps["disabled"]; }>(); </script> ``` ここで、先ほど[[#競合の解消]]の最後で紹介したコードを思い出してみる。 ```html <script setup lang="ts"> import { buttonVariant, } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; </script> <template> <button :class=" cn(buttonVariant({ size: 'large', intent: 'danger', disabled: true })) " > ボタン </button> </template> ``` 合成するとこのようになり `src/components/minerva/button/MButton.vue` ```html <script setup lang="ts"> import { buttonVariant, type ButtonVariantProps, } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; defineProps<{ intent: ButtonVariantProps["intent"]; size: ButtonVariantProps["size"]; disabled?: ButtonVariantProps["disabled"]; }>(); </script> <template> <button :class=" cn(buttonVariant({ intent: intent, size: size, disabled: disabled })) " > ボタン </button> </template> ``` [[拡張オブジェクトリテラル (JavaScript)|拡張オブジェクトリテラル]]でさらに簡略化して、[[スロットアウトレット (Vue)|スロットアウトレット]]を使うとこうなる。 `src/components/minerva/button/MButton.vue` ```html <script setup lang="ts"> import { buttonVariant, type ButtonVariantProps, } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; defineProps<{ intent: ButtonVariantProps["intent"]; size: ButtonVariantProps["size"]; disabled?: ButtonVariantProps["disabled"]; }>(); </script> <template> <button :class="cn(buttonVariant({ intent, size, disabled }))"> <slot></slot> </button> </template> ``` そして `MButton` はコンポーネントとなる。 `src/App.vue` ```html <script setup lang="ts"> import MButton from "@/components/minerva/button/MButton.vue"; </script> <template> <MButton intent="primary" size="medium">クリック me</MButton> </template> ``` ## [[shadcn-vue]]の味付け [[shadcn-vue]]の `Button.vue` を見てみよう。 ```html <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" :as="as" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)" > <slot /> </Primitive> </template> ``` [[Primitive (Reka UI)|Primitive]]は[[Reka UI]]が提供するコンポーネントだが、ここでは実質buttonだと思ってもらって問題ない。問題を単純化するためコードを変更する。 `src/components/ui/button/Button2.vue` ```html <script setup lang="ts"> import { cn } from "@/lib/utils"; import { Primitive } from "reka-ui"; import type { HTMLAttributes } from "vue"; import type { ButtonVariants } from "."; import { buttonVariants } from "."; interface Props { variant?: ButtonVariants["variant"]; size?: ButtonVariants["size"]; class?: HTMLAttributes["class"]; } const props = withDefaults(defineProps<Props>(), {}); </script> <template> <Primitive :class="cn(buttonVariants({ variant, size }), props.class)"> <slot /> </Primitive> </template> ``` [[Reka UI]]の[[Primitive (Reka UI)|Primitive]]、[[PrimitiveProps型 (Reka UI)|PrimitiveProps型]]を削除した。残るは `class` だけになる。 [[HTMLAttributes型 (Vue)|HTMLAttributes型]]の[[インデックス型]] `HTMLAttributes["class"]` は[[class属性 (HTML)|class属性]]が受け付ける型であり`any` 型。これはそのまま[[cn]]に流れ込むので、様々な表現を指定できる。 よって上記のコンポーネント `Button2` は以下の様に利用できる。 ```html <Button2 variant="destructive" size="sm" class="rounded-full font-bold"> ボタン </Button2> ``` ### MButtonへの適応 このようになる。 ```html <script setup lang="ts"> import { buttonVariant, type ButtonVariantProps, } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; import type { HTMLAttributes } from "vue"; const props = defineProps<{ intent: ButtonVariantProps["intent"]; size: ButtonVariantProps["size"]; disabled?: ButtonVariantProps["disabled"]; class?: HTMLAttributes["class"]; }>(); </script> <template> <button :class="cn(buttonVariant({ intent, size, disabled }), props.class)"> <slot></slot> </button> </template> ``` `props` は省略できそうに見えるが、`class` が構文エラーとなってしまうので必須。 ```html <!-- '{' expected. [1005] になる --> <button :class="cn(buttonVariant({ intent, size, disabled }), class)"> <slot></slot> </button> ``` ## より複雑な条件 複数のvariantの状態によって、variantの値が変わる場合は[[Compound Variants]]を使う。 ```ts import { cva, type VariantProps } from "class-variance-authority"; const buttonVariant = cva(["text-gray-400", "rounded"], { variants: { // variantと呼ばれることも多い intent: { primary: ["bg-blue-600 hover:bg-blue-600/50", "text-white"], danger: ["bg-red-600 hover:bg-red-600/50", "text-white"], }, size: { small: ["px-3", "py-1", "text-sm"], medium: ["px-4", "py-2", "text-base"], large: ["px-5", "py-3", "text-lg"], }, // disabled がついているときだけ特定のスタイルを適用 disabled: { true: ["opacity-50", "cursor-not-allowed"], false: [], }, }, // 追加 compoundVariants: [ // intent="danger" size="large" の場合のみ大文字 { intent: "danger", size: "large", class: "uppercase", }, ], defaultVariants: { size: "medium", disabled: false, }, }); export { buttonVariant }; export type ButtonVariantProps = VariantProps<typeof buttonVariant>; ``` ```html <script setup lang="ts"> import MButton from "@/components/minerva/button/MButton.vue"; </script> <template> <!-- これは小文字のボタン --> <MButton size="large" intent="primary" class="rounded-full font-bold"> primary </MButton> <!-- これは大文字のボタン --> <MButton size="large" intent="danger" class="rounded-full font-bold" disabled> danger </MButton> </template> ``` ## disabledとbooleanについて 少し話をシンプルにするため、`intent` と `disabled` 以外を削除する。 `src/components/minerva/button/index.ts` ```ts import { cva, type VariantProps } from "class-variance-authority"; const buttonVariant = cva(["text-gray-400", "rounded", "px-2 py-1"], { variants: { intent: { primary: ["bg-blue-600 hover:bg-blue-600/50", "text-white"], danger: ["bg-red-600 hover:bg-red-600/50", "text-white"], }, disabled: { true: ["opacity-50", "cursor-not-allowed"], false: [], }, }, }); export { buttonVariant }; export type ButtonVariantProps = VariantProps<typeof buttonVariant>; ``` `src/components/minerva/button/MButton.vue` ```html <script setup lang="ts"> import { buttonVariant, type ButtonVariantProps, } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; import type { HTMLAttributes } from "vue"; const props = defineProps<{ intent: ButtonVariantProps["intent"]; disabled?: ButtonVariantProps["disabled"]; class?: HTMLAttributes["class"]; }>(); </script> <template> <div>{{ disabled }}</div> <button :class="cn(buttonVariant({ intent, disabled }), props.class)" @click="console.log('hoge')" > <slot></slot> </button> </template> ``` ### disabledが有効にならない このとき、`disabled` を以下のように指定する。 `src/App.vue` ```html <script setup lang="ts"> import MButton from "@/components/minerva/button/MButton.vue"; </script> <template> <div class="p-8"> <MButton intent="primary" disabled> primary </MButton> </div> </template> ``` しかし、この `disabled` は有効にならない。warningも出る。 ```warning MButton.vue?t=1769248386821:66 [Vue warn]: Missing required prop: "intent" at <MButton disabled="" > at <App> ``` ![[2026-01-24-18-58-28.avif]] `<... disabled />` としただけでは `disabled` は空文字になる。 これを `ButtonVariantProps` の[[インデックス型]]を使わずに、直接同じ推論結果を指定する。 ```diff const props = defineProps<{ intent: ButtonVariantProps["intent"]; - disabled?: ButtonVariantProps["disabled"]; + disabled?: boolean | null | undefined; class?: HTMLAttributes["class"]; }>(); ``` これはちゃんと効く。 ![[2026-01-24-18-57-46.avif]] ### disabledをvariantとして定義しない `ButtonVariantProps` を使わなければ解決する話だが、若干気持ち悪い。これは[[Vue]]の [[defineProps (Vue)|defineProps]]の書き方による仕様の1つなので、そもそもvariantに`disabled`を用意せず、[[Tailwind CSS]]のクラスとして扱ったほうがよいのではないか。 [[shadcn-vue]]の `buttonVariants` 生成ロジックを見てみる。 ```ts 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", ``` このように[[cva]]へ投げ込むクラスの中で `disabled:pointer-events-none disabled:opacity-50` のような記載がある。 同じように実装してみる。 `src/components/minerva/button/index.ts` ```ts import { cva, type VariantProps } from "class-variance-authority"; const buttonVariant = cva( [ "text-gray-400", "rounded", "px-2 py-1", "disabled:opacity-50 disabled:cursor-not-allowed", ], { variants: { intent: { primary: ["bg-blue-600 hover:bg-blue-600/50", "text-white"], danger: ["bg-red-600 hover:bg-red-600/50", "text-white"], }, }, }, ); export { buttonVariant }; export type ButtonVariantProps = VariantProps<typeof buttonVariant>; ``` `src/components/minerva/button/MButton.vue` ```html <script setup lang="ts"> import { buttonVariant, type ButtonVariantProps, } from "@/components/minerva/button"; import { cn } from "@/lib/utils"; import type { HTMLAttributes } from "vue"; const props = defineProps<{ intent: ButtonVariantProps["intent"]; class?: HTMLAttributes["class"]; }>(); </script> <template> <button :class="cn(buttonVariant({ intent }), props.class)" @click="console.log('hoge')" > <slot></slot> </button> </template> ``` これで期待通り動くようになった。 ## 最小構成のテンプレ 最後に `MLabel` という名前の最小構成コンポーネントを雛形として作っておく。最小とは言ったものの、よく使うパターンは入れているつもり。 `src/components/minerva/label/index.ts` ```ts import { cva, type VariantProps } from "class-variance-authority"; const labelVariant = cva( [ "flex items-center justify-center", "text-white rounded px-2 py-1", "cursor-pointer", "hover:opacity-90", ], { variants: { variant: { primary: "bg-amber-500", danger: "bg-red-500", }, size: { sm: "text-xs", md: "text-sm", lg: "text-base uppercase", }, }, // compoundVariants: [], defaultVariants: { variant: "primary", size: "md", }, }, ); export { labelVariant }; export type LabelVariantProps = VariantProps<typeof labelVariant>; ``` `src/components/minerva/label/MLabel.vue` ```html <script setup lang="ts"> import { labelVariant, type LabelVariantProps, } from "@/components/minerva/label"; import { cn } from "@/lib/utils"; import type { HTMLAttributes } from "vue"; const props = defineProps<{ variant?: LabelVariantProps["variant"]; size?: LabelVariantProps["size"]; class?: HTMLAttributes["class"]; }>(); </script> <template> <div :class="cn(labelVariant({ variant, size }), props.class)"> <slot></slot> </div> </template> ``` なお、style(class)がかかる[[HTMLタグ]]が複数に及ぶケースでは、その数だけ `oooVariant` をつくることになる。ここまでの理論を理解できていれば、やることは基本的に同じなので詳細は割愛。