Aller au contenu

shadcn-vue : copier, coller, posséder

Temps 1 · séance 3 · ~35 min

Les slides du chapitre (PDF)PDF

  • Expliquer où vit un composant shadcn-vue et pourquoi il vous appartient.
  • Lire un composant Vue comme un composant Blade : un fichier, des props, un slot.
  • Ajouter un composant à votre projet avec la commande de shadcn-vue.
  • Choisir la variante et la taille d'un bouton.
  • Construire le tableau d'administration des projets avec Table et un bouton « Nouveau projet ».

1. Un composant dans votre dépôt

Ouvrez le dossier resources/js/components/ui/. Le kit y a rangé une vingtaine de dossiers, un par composant d'interface : button, input, label, card, dialog, badge, sonner, checkbox, sidebar et d'autres. Vous les utilisez depuis le chapitre 1.04 sans le savoir : la page de connexion est faite de Input, de Label et de Button.

Ces composants viennent de shadcn-vue. Ce n'est pas une bibliothèque installée dans node_modules, mais une collection de composants que l'on copie dans son propre projet. Ouvrez resources/js/components/ui/button/Button.vue :

<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>

Le fichier tient en trente lignes, et il est dans votre dépôt Git. Le travail difficile est ailleurs : Primitive vient de reka-ui, un paquet installé dans node_modules. reka-ui gère ce qui ne se voit pas, comme le clavier, le focus et les attributs d'accessibilité. shadcn-vue y ajoute l'apparence, en classes Tailwind, dans un fichier que vous possédez.

Deux dossiers du projet. Dans votre dépôt, resources/js/components/ui/button/Button.vue porte l'apparence du bouton, en classes Tailwind : vous le lisez et vous le modifiez. Dans node_modules, le paquet reka-ui porte le clavier, le focus et l'accessibilité : vous ne le modifiez pas. Button.vue importe Primitive de reka-ui

  • Vous lisez tout le code. Un comportement surprenant se comprend en ouvrant le fichier, sans fouiller node_modules.
  • Vous modifiez sans attendre personne. Pour arrondir davantage tous vos boutons, vous changez une classe dans un fichier à vous, et votre site suit. Lequel des deux fichiers du dossier button/ ? La réponse est juste après.
  • Une mise à jour ne casse rien. Aucun npm update ne touche à vos composants, puisqu'ils ne viennent pas d'un paquet.

Un composant shadcn-vue est du code à vous, rangé dans resources/js/components/ui/.

Les composants du kit sont écrits en TypeScript, comme le reste du kit. Vous les importez sans rien changer depuis vos pages en JavaScript, comme les fonctions de Wayfinder.

Deux fichiers, deux rôles : Button.vue et index.ts

Le dossier button/ contient deux fichiers, et chacun a son rôle.

  • Button.vue porte la structure : ce que le composant reçoit et ce qu'il produit. Il déclare les props variant, size et class, pose un <slot /> pour le texte du bouton, et produit un Primitive. Relisez-le : il n'écrit aucune classe Tailwind lui-même. Sa seule ligne :class appelle buttonVariants().
  • index.ts porte l'apparence : toutes les classes. buttonVariants y est construit par cva() à partir de trois listes : d'abord les classes que tout bouton reçoit (inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium …), puis une liste par variante, puis une liste par taille. Vous lirez les variantes à la section 4.

Button.vue porte la structure : defineProps déclare variant, size et class, le slot reçoit le texte, et la ligne :class appelle buttonVariants. index.ts porte l'apparence : cva() reçoit d'abord les classes de tout bouton, dont rounded-md, puis une liste par variante et une liste par taille. Pour changer la forme de tous les boutons, on modifie index.ts

Pour changer la forme de tous les boutons, vous ouvrez donc index.ts, pas Button.vue. rounded-md y apparaît trois fois : dans la première liste, puis dans les tailles sm et lg. Remplacez les trois par rounded-full, enregistrez : chaque bouton du site devient une pilule, « Nouveau projet » comme « Se connecter », sans toucher à Button.vue ni à aucune page. Vous ouvrez Button.vue le jour où c'est la structure qui change, une prop de plus par exemple ; vous n'y toucherez pas dans ce cours. Ce partage est celui des composants à variantes, Button, Badge, Alert. Un composant sans variante, comme Input, écrit ses classes dans son .vue, et son index.ts ne fait que l'exporter : ouvrez les deux fichiers, le bon se reconnaît à ses classes.

Le bouton Nouveau projet du back-office, deux fois : avec rounded-md, ses coins sont légèrement arrondis ; avec rounded-full dans index.ts, il devient une pilule

Un composant Vue, comme un composant Blade

En 5XCOS, vous avez écrit alert.blade.php dans resources/views/components/, puis <x-alert type="danger">Le concert de 22 h est complet.</x-alert> dans une vue. Un composant Vue repose sur les trois mêmes idées : un fichier, des props, un slot. Voici le même composant en Vue, dans resources/js/components/Alert.vue :

<script setup>
defineProps({
    type: { type: String, default: 'info' },
});
</script>

<template>
    <div :class="type === 'danger' ? 'text-red-700' : 'text-neutral-700'">
        <slot />
    </div>
</template>

Et voici une page qui s'en sert deux fois :

<script setup>
import Alert from '@/components/Alert.vue';
</script>

<template>
    <Alert>Les portes ouvrent à 17 h 30.</Alert>
    <Alert type="danger">Le concert de 22 h est complet.</Alert>
</template>

Le navigateur reçoit deux <div>, une par balise <Alert> :

<div class="text-neutral-700">Les portes ouvrent à 17 h 30.</div>
<div class="text-red-700">Le concert de 22 h est complet.</div>

La page écrit la balise Alert avec l'attribut type égal à danger et le texte Le concert de 22 h est complet entre ses balises. Dans Alert.vue, l'attribut arrive dans la prop type, qui choisit la classe text-red-700, et le texte arrive à la place de slot. Le navigateur reçoit une div de classe text-red-700 qui contient le texte. Sans attribut, type vaut info par défaut et la div reçoit text-neutral-700

Le fichier. Un composant Vue est un fichier .vue qui contient un <script setup> et un <template>, comme les pages que vous écrivez depuis le chapitre 1.03. Son nom commence par une majuscule, en un ou plusieurs mots collés : Alert.vue, ProjectCard.vue, RichTextEditor.vue. Vos composants vont dans resources/js/components/, ceux de shadcn-vue dans son sous-dossier ui/. Blade trouvait <x-alert> tout seul d'après le nom du fichier ; Vue demande une ligne import dans chaque page qui l'utilise, et la balise porte le nom importé : <Alert>.

Les props. Une prop est une valeur que la page donne au composant par un attribut de la balise, comme type="danger". Le composant la déclare avec defineProps, qui joue le rôle de @props : le nom, le type attendu et la valeur par défaut. Dans le template, la prop s'utilise par son nom, sans $ : c'est type qui choisit la classe de la <div>. La première <Alert> de la page n'a pas d'attribut : type vaut 'info', la valeur par défaut, et la <div> reçoit text-neutral-700. Les deux-points gardent le sens de Blade : :type="project.type" passe la valeur d'une expression JavaScript, type="danger" passe le texte tel quel.

Le slot. <slot /> marque l'endroit où arrive ce que la page écrit entre les balises du composant, exactement comme {{ $slot }}. Le texte « Le concert de 22 h est complet. » n'est pas une prop : il entre par le slot, et il ressort à l'intérieur de la <div>. Le slot sert à tout ce qui n'est pas une simple valeur : une phrase, une icône, ou d'autres composants, comme le <Link> que Button recevra à la section 5.

Relisez maintenant Button.vue ci-dessus avec ces trois idées : defineProps déclare variant, size et class, et <slot /> reçoit le texte du bouton. <Button variant="destructive">Supprimer</Button> se lit donc comme <x-button type="destructive">Supprimer</x-button> se lisait en Blade : variant est une prop, « Supprimer » est le slot.

Blade, en 5XCOS Vue
Le fichier resources/views/components/alert.blade.php resources/js/components/Alert.vue
Le nom dans la page <x-alert>, trouvé d'après le nom du fichier <Alert>, après import Alert from '@/components/Alert.vue';
Les props @props(['type' => 'info']), puis $type defineProps({ type: { type: String, default: 'info' } }), puis type
Une valeur calculée :type="$concert->type" :type="project.type"
Le slot {{ $slot }} <slot />

Vous n'écrivez aucun composant dans ce chapitre : vous utilisez ceux du kit. Vous écrirez les vôtres aux chapitres 1.15 et 1.18.

2. Ajouter un composant

Le tableau d'administration demande Table, et le formulaire du chapitre 1.12 demande Textarea, une zone de texte sur plusieurs lignes. Le kit n'a ni l'un ni l'autre. Ajoutez-les avec la commande de shadcn-vue, depuis le dossier du projet :

npx shadcn-vue@latest add table textarea

npx télécharge l'outil shadcn-vue et le lance sans l'installer dans le projet. @latest demande la dernière version. La première fois, npx peut vous demander la permission de télécharger le paquet : répondez y. Voici l'essentiel de la sortie :

✔ Checking registry.
- Installing dependencies.
added 1 package, and audited 228 packages in 1s
✔ Installing dependencies.
✔ Created 12 files:
  - resources/js/components/ui/table/Table.vue
  - resources/js/components/ui/table/TableBody.vue
  - resources/js/components/ui/table/TableCaption.vue
  - resources/js/components/ui/table/TableCell.vue
  - resources/js/components/ui/table/TableEmpty.vue
  - resources/js/components/ui/table/TableFooter.vue
  - resources/js/components/ui/table/TableHead.vue
  - resources/js/components/ui/table/TableHeader.vue
  - resources/js/components/ui/table/TableRow.vue
  - resources/js/components/ui/table/index.ts
  - resources/js/components/ui/textarea/Textarea.vue
  - resources/js/components/ui/textarea/index.ts

La commande ne pose aucune question sur le style ou les couleurs. Elle lit les réglages du kit dans le fichier components.json, à la racine du projet.

Le résultat se lit sur la dernière ligne : douze fichiers créés, tous dans resources/js/components/ui/. index.ts regroupe les composants d'un dossier, pour que vous les importiez en une ligne.

La commande npx shadcn-vue@latest add table textarea lit les réglages de components.json, crée douze fichiers dans resources/js/components/ui/, dix dans table et deux dans textarea, et modifie package.json : à relire après la commande

Lire package.json après la commande

« Installing dependencies » signale que la commande a aussi touché à package.json. Ouvrez-le, ou lancez git diff package.json. Deux lignes ont changé dans dependencies :

"@lucide/vue": "^1.61.0",
"cn": "^0.5.0",

Les numéros de version peuvent être plus récents chez vous. La première ligne relève la version des icônes Lucide. La seconde ajoute un petit paquet, cn, que les nouveaux composants importent avec import { cn } from "cn". Les composants du kit, eux, importent leur fonction cn de @/lib/utils. Les deux fusionnent des classes Tailwind, et elles cohabitent sans problème.

Prenez l'habitude de lire ce fichier après chaque commande qui installe quelque chose. Une dépendance ajoutée sans que vous le sachiez part en production avec votre site.

3. Les composants de ce temps

Le portfolio utilise onze composants d'ici la fin du temps 1. Neuf étaient déjà dans le kit.

Composant Import Ce qu'il affiche Vous le verrez
Button @/components/ui/button un bouton, ou un lien qui en a l'apparence dans ce chapitre
Input @/components/ui/input un champ d'une ligne au chapitre 1.12
Textarea @/components/ui/textarea une zone de texte de plusieurs lignes, ajoutée ci-dessus au chapitre 1.12
Label @/components/ui/label l'étiquette d'un champ, reliée par for au chapitre 1.12
Checkbox @/components/ui/checkbox une case à cocher au chapitre 1.12
Spinner @/components/ui/spinner une petite roue qui tourne pendant un envoi au chapitre 1.12
Card @/components/ui/card un cadre avec un en-tête et un contenu, utilisé par les pages de connexion si vous voulez encadrer un formulaire
Table @/components/ui/table un tableau, ajouté ci-dessus dans ce chapitre
Dialog @/components/ui/dialog une boîte de dialogue au-dessus de la page au chapitre 1.13
Badge @/components/ui/badge une petite étiquette arrondie, comme « Brouillon » au chapitre 1.16
Toaster @/components/ui/sonner les notifications, appelées toasts, qui apparaissent dans un coin de l'écran au chapitre 1.12

Un composant se découpe souvent en plusieurs morceaux. Le dossier table/ en exporte neuf : Table, TableHeader, TableBody, TableRow, TableHead, TableCell et trois autres. Chacun produit la balise HTML correspondante, avec ses classes : TableHead produit un <th>, TableCell un <td>.

Le dernier composant est déjà monté pour vous. Le layout du back-office, resources/js/layouts/app/AppSidebarLayout.vue, place un <Toaster /> à la fin de son template :

<template>
    <AppShell variant="sidebar">
        <AppSidebar />
        <AppContent variant="sidebar" class="min-w-0 overflow-x-clip">
            <AppSidebarHeader :breadcrumbs="breadcrumbs" />
            <slot />
        </AppContent>
        <Toaster />
    </AppShell>
</template>

Chaque page du back-office peut donc afficher une notification. Le chapitre 1.12 en fera apparaître une après l'enregistrement d'un projet.

4. Les variantes

Un bouton « Supprimer » ne ressemble pas à un bouton « Annuler ». Plutôt que d'écrire les classes à chaque fois, le composant Button propose des variantes, des apparences prêtes à l'emploi que vous choisissez par une prop. Elles sont définies dans resources/js/components/ui/button/index.ts, avec la fonction cva() du paquet class-variance-authority, qui associe chaque nom de variante à une liste de classes :

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",
},
Variante Apparence Usage dans le back-office
default fond plein, de la couleur principale l'action principale de la page : « Nouveau projet », « Créer le projet »
destructive fond rouge une action qui supprime : « Supprimer »
outline bordure, fond de la page une action secondaire dans une ligne : « Modifier »
secondary fond gris clair une action de retrait : « Annuler »
ghost aucun fond tant que la souris ne passe pas une icône seule
link texte souligné au survol un lien au milieu d'un texte

Les six variantes du composant Button, rendues dans le navigateur : default, fond noir, Nouveau projet ; destructive, fond rouge, Supprimer ; outline, bordure, Modifier ; secondary, fond gris clair, Annuler ; ghost, une icône de crayon sans fond ; link, le texte Voir le projet sans fond

La prop size règle la hauteur et les marges : default, sm, lg, et trois tailles carrées pour une icône seule, icon, icon-sm et icon-lg. Les deux se combinent :

<Button variant="destructive" size="sm">Supprimer</Button>

Sans prop, le bouton prend default pour les deux. Le composant Badge fonctionne de la même façon, avec les variantes default, secondary, destructive et outline.

5. Un bouton qui est un lien : as-child

Le bouton « Nouveau projet » mène à une autre page. Ce n'est pas une action, c'est un lien, et il doit rester une balise <a> pour que le navigateur le traite comme tel. Mais il doit avoir l'apparence d'un bouton. La prop as-child règle ce cas :

<Button as-child>
    <Link :href="create()">Nouveau projet</Link>
</Button>

Avec as-child, le composant Primitive de reka-ui ne produit pas de <button>. Il donne ses classes à son enfant, ici le <Link> d'Inertia, qui produit une balise <a>. Vous obtenez un lien qui ressemble à un bouton, et qui navigue sans recharger la page, comme au chapitre 1.08.

Sans as-child, le composant Button produit une balise button qui porte les classes du bouton. Avec as-child, il ne produit pas de balise : il donne ses classes à son enfant, le Link, qui produit une balise a avec l'adresse /admin/projets/nouveau

create() vient de Wayfinder. Il construit l'adresse de la route admin.projects.create, soit /admin/projets/nouveau. Le kit utilise la même écriture dans sa barre latérale, avec SidebarMenuButton as-child.

6. La documentation comme ressource

Chaque composant a sa page sur le site de shadcn-vue, à l'adresse https://www.shadcn-vue.com/docs/components/ suivie de son nom, par exemple la page dialog. La page contient la commande d'installation, des exemples complets et la liste des morceaux du composant.

La documentation écrit ses commandes avec pnpm dlx, un autre gestionnaire de paquets. Remplacez pnpm dlx par npx, que vous utilisez déjà. Ses exemples sont en TypeScript, avec <script setup lang="ts">. Ils se recopient dans une page en JavaScript tant qu'ils n'ajoutent pas de types : retirez lang="ts" et les annotations comme : string si vous en trouvez.

Lisez un exemple avant de construire un composant à partir de rien. La page Dialog, par exemple, montre une boîte qui contient un formulaire, ce dont vous aurez besoin au chapitre 1.13. Le kit en contient aussi un, dans resources/js/components/DeleteUser.vue.

7. Pratique : le tableau d'administration (15 min)

Remplacez la liste brute du chapitre 1.10 par un tableau. Voici resources/js/pages/admin/projects/Index.vue :

<script setup>
import { Head, Link } from '@inertiajs/vue3';
import { Button } from '@/components/ui/button';
import {
    Table,
    TableBody,
    TableCell,
    TableHead,
    TableHeader,
    TableRow,
} from '@/components/ui/table';
import { create, index } from '@/routes/admin/projects';

defineOptions({
    layout: {
        breadcrumbs: [{ title: 'Projets', href: index() }],
    },
});

defineProps({
    projects: Array,
});
</script>

<template>
    <Head title="Projets" />

    <div class="space-y-4 p-4">
        <div class="flex items-center justify-between">
            <h1 class="text-xl font-semibold">Projets</h1>
            <Button as-child>
                <Link :href="create()">Nouveau projet</Link>
            </Button>
        </div>

        <Table>
            <TableHeader>
                <TableRow>
                    <TableHead>Titre</TableHead>
                    <TableHead>Adresse</TableHead>
                </TableRow>
            </TableHeader>
            <TableBody>
                <TableRow v-for="project in projects" :key="project.id">
                    <TableCell class="font-medium">{{ project.title }}</TableCell>
                    <TableCell>/projets/{{ project.slug }}</TableCell>
                </TableRow>
            </TableBody>
        </Table>
    </div>
</template>

Le back-office du portfolio : barre latérale avec Dashboard et Projets, fil d'Ariane Projets, tableau de trois projets avec leur titre et leur adresse, et un bouton noir Nouveau projet en haut à droite

defineOptions({ layout: { breadcrumbs: [...] } }) passe une prop au layout, comme title dans les pages de connexion. AppLayout attend une prop breadcrumbs, la liste des étapes du fil d'Ariane, chacune avec un titre et une adresse. Le fil d'Ariane affiche maintenant « Projets » en haut de la page.

Chaque composant de la table produit sa balise HTML, avec les classes de shadcn-vue. TableRow répète la ligne avec v-for, et :key reçoit project.id, qui ne change jamais.

Vérifiez dans le navigateur.

  • /admin/projets affiche un tableau de deux colonnes avec vos trois projets, dans l'ordre de position.
  • Le fil d'Ariane affiche « Projets ».
  • Le bouton « Nouveau projet » a l'apparence d'un bouton, et l'outil d'inspection du navigateur montre une balise <a href="/admin/projets/nouveau">. Le clic mène à une page d'erreur, car la méthode create() n'existe pas encore : elle arrive au chapitre 1.12.
  • resources/js/components/ui/ contient les dossiers table et textarea, et package.json contient cn.

Ce que votre portfolio contient maintenant

Un tableau d'administration de vos projets, fait de composants shadcn-vue rangés dans votre dépôt, avec un bouton « Nouveau projet » qui attend sa page. Deux composants de plus, Table et Textarea, dans resources/js/components/ui/.

À retenir

  • shadcn-vue copie le code de chaque composant dans resources/js/components/ui/, et ce code vous appartient : Button.vue porte la structure, index.ts les classes.
  • Un composant Vue, c'est un fichier .vue importé par la page, des props déclarées par defineProps, et un <slot /> qui reçoit ce que la page écrit entre les balises.
  • npx shadcn-vue@latest add table textarea ajoute des composants en lisant les réglages de components.json.
  • Après une commande qui installe, vous lisez package.json pour savoir ce qui a été ajouté.
  • La prop variant choisit l'apparence d'un bouton, et la prop size sa taille.
  • <Button as-child> donne l'apparence d'un bouton à son enfant, par exemple un <Link>, sans produire de <button>.