Aller au contenu

Tailwind CSS : exercices

Bloc Laravel · séances 6 et 7 · ~3 h

Au chapitre 13, vous avez vu les classes utilitaires, @vite, @theme et les composants Blade à variantes. Dans ce chapitre, vous mettez en forme un site entier, celui de la ludothèque des Tilleuls. Le site fonctionne déjà : il affiche un catalogue de jeux de société, la fiche de chaque jeu, la liste des prêts et un formulaire d'ajout. Ses vues ne contiennent pourtant aucune classe, et son layout ne charge aucun CSS.

À la fin de ce chapitre, vous serez capables de :

  1. brancher Tailwind dans un layout, et poser un conteneur commun à toutes les pages,
  2. construire des composants Blade à variantes avec @class et $attributes->class(),
  3. adapter une grille, un tableau et un menu à la largeur de l'écran,
  4. placer le JavaScript d'une page dans resources/js/app.js, et décrire l'état d'un menu avec aria-expanded,
  5. reproduire un écran d'après une capture, et vérifier le résultat avec une liste de critères.

1. Le site de la ludothèque

Installer le dépôt (10 minutes, une seule fois)

Sur github.com/opmvpc/tailwind-26, créez votre copie avec le bouton Fork. Ouvrez ensuite un terminal dans le dossier de Herd ou de Laragon, et préparez le projet :

git clone https://github.com/VOTRE-COMPTE/tailwind-26.git
cd tailwind-26
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
npm install

Les cinq premières commandes sont celles du dépôt eloquent-26, au chapitre 10. Avec --seed, migrate remplit aussi la base : douze jeux, huit membres et quinze prêts. Les dates des prêts partent du jour où vous lancez la commande, et vos dates diffèrent donc de celles des captures. npm install recrée node_modules/, le dossier des paquets JavaScript, dont Vite et Tailwind.

Lancez ensuite cette commande dans un terminal, et laissez-le ouvert pendant tout le travail :

composer run dev

Comme au chapitre 13, composer run dev lance npm run dev : chaque nouvelle classe reçoit sa règle CSS dès que vous enregistrez la vue. Avec Herd, ouvrez http://tailwind-26.test. Avec Laragon, rechargez-le d'abord depuis son menu.

Les pages

Adresse Vue Ce qu'elle affiche
/jeux games/index.blade.php le catalogue, avec la couverture de chaque jeu
/jeux/{id} games/show.blade.php la fiche d'un jeu et l'historique de ses prêts
/jeux/nouveau games/create.blade.php le formulaire d'ajout d'un jeu
/prets loans/index.blade.php tous les prêts, dans un tableau
/composants styleguide.blade.php les boutons, les statuts et la carte du site

La page /composants rassemble chaque élément de l'interface, dans toutes ses variantes. Une page de ce genre s'appelle un guide de style, styleguide en anglais. Vous y vérifiez un composant sans chercher une page qui l'affiche dans le bon état.

Les routes, les contrôleurs et les modèles ne changent pas. Vous ne modifiez que des fichiers de resources/ : les vues, css/app.css et js/app.js. Le projet utilise la teinte zinc, un gris très légèrement bleuté, à la place du gray du chapitre 13. Ses nuances vont aussi de 50 à 950.

Lire un exercice

Les exercices 14.1 et 14.2 donnent tout le code, et expliquent chaque classe. Les exercices 14.3 à 14.5 donnent une capture de l'écran fini, une banque de classes et des critères à cocher. Chaque banque contient les classes du corrigé dans le désordre, et trois intrus qui ne servent pas dans l'exercice. Les exercices 14.6 à 14.8 ne donnent que la capture et les critères.

Chaque critère se vérifie à l'écran :

  • une largeur : ouvrez les outils de développement avec F12, puis le mode d'affichage mobile avec Ctrl+Maj+M, et tapez la largeur voulue,
  • le clavier : cliquez dans la barre d'adresse, puis avancez d'un lien ou d'un bouton au suivant avec la touche Tab,
  • un attribut : dans les outils de développement, l'onglet Éléments affiche le HTML de la page tel qu'il est en ce moment, attributs compris.

Le corrigé de chaque exercice se cache sous un bouton. Ouvrez-le après votre essai, et comparez avec votre code.

2. Pas à pas : le cadre et le bouton

14.1 Brancher Tailwind et poser le cadre (25 minutes)

Charger le CSS. Ouvrez resources/views/components/layouts/app.blade.php. Ajoutez deux lignes à la fin du <head>, sous <title> :

    <title>{{ $title }} · Ludothèque des Tilleuls</title>
    @fonts
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

Rechargez /jeux. Avant toute classe, la page a déjà changé. Le titre « Catalogue » a la taille du texte courant, les listes n'ont plus de puces, et les liens ne sont plus ni bleus ni soulignés. @import 'tailwindcss' apporte en effet la remise à zéro des styles du navigateur, vue au chapitre 13. Chaque élément ne prend plus que le style de ses classes.

La couleur de marque. La ludothèque a choisi un bleu canard. Ajoutez onze variables de thème dans le bloc @theme de resources/css/app.css, sous --font-sans :

@theme {
    --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
        'Segoe UI Symbol', 'Noto Color Emoji';

    /* La couleur de la ludothèque : un bleu canard, de 50 (très clair) à 950 (très foncé). */
    --color-brand-50: oklch(0.98 0.016 195);
    --color-brand-100: oklch(0.95 0.04 195);
    --color-brand-200: oklch(0.9 0.07 195);
    --color-brand-300: oklch(0.83 0.1 195);
    --color-brand-400: oklch(0.74 0.12 195);
    --color-brand-500: oklch(0.64 0.12 196);
    --color-brand-600: oklch(0.54 0.105 198);
    --color-brand-700: oklch(0.46 0.09 200);
    --color-brand-800: oklch(0.4 0.075 201);
    --color-brand-900: oklch(0.35 0.06 202);
    --color-brand-950: oklch(0.25 0.045 204);
}

Comme au chapitre 13, chaque variable --color-brand-… crée toutes les classes de sa nuance : bg-brand-600, text-brand-700, outline-brand-600, et les autres. La fonction CSS oklch() reçoit trois nombres : la clarté, de 0 à 1, l'intensité de la couleur, et la teinte, un angle sur le cercle des couleurs. D'une nuance à l'autre, la teinte reste proche de 195 et la clarté baisse. Les couleurs de Tailwind sont écrites de la même façon.

Le corps de la page. Dans le layout, remplacez la balise <body>, la balise <main> et le pied de page :

<body class="flex min-h-screen flex-col bg-zinc-50 font-sans text-zinc-900 antialiased">
    …
    <main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
        {{ $slot }}
    </main>

    <footer class="border-t border-zinc-200">
        <div class="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-6 text-sm text-zinc-500 sm:flex-row sm:justify-between sm:px-6 lg:px-8">
            <p>Ludothèque des Tilleuls, rue des Tilleuls 12, 1300 Wavre. Ouverte le mercredi et le samedi, de 10 h à 17 h.</p>
            <p><a href="{{ route('styleguide') }}" class="underline underline-offset-4 hover:text-zinc-900">Les composants du site</a></p>
        </div>
    </footer>
</body>
  • flex min-h-screen flex-col sur <body> range l'en-tête, <main> et le pied de page en colonne, sur au moins la hauteur de la fenêtre. flex-1 donne à <main> toute la hauteur restante. Le pied de page reste donc en bas de la fenêtre, même sur une page courte.
  • mx-auto max-w-6xl centre un bloc de 72 rem de large au plus. Dans une colonne Flex, un enfant aux marges auto ne prend que la largeur de son contenu : w-full lui rend toute la largeur, que max-w-6xl limite ensuite.
  • px-4 sm:px-6 lg:px-8 élargit la marge intérieure avec l'écran : 16 pixels sur un téléphone, 24 à partir de 640 pixels, 32 à partir de 1 024.
  • flex-col sm:flex-row sm:justify-between place les deux paragraphes du pied de page l'un sous l'autre, puis côte à côte à partir de 640 pixels.
  • font-sans applique la police Instrument Sans de @theme, et antialiased affine le dessin des lettres sur macOS.

L'en-tête. Remplacez la balise <header> et le lien du logo. Gardez la balise <nav> telle quelle :

<header class="border-b border-zinc-200 bg-white">
    <div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-4 py-3 sm:px-6 lg:px-8">
        <a href="{{ route('games.index') }}" class="flex items-center gap-2 rounded-md text-lg font-semibold tracking-tight focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-600">
            <span class="grid size-8 place-items-center rounded-lg bg-brand-600 text-sm font-semibold text-white" aria-hidden="true">LT</span>
            Ludothèque des Tilleuls
        </a>
        <nav aria-label="Navigation principale">
            …
        </nav>
    </div>
</header>

Le <div> de l'en-tête reprend mx-auto max-w-6xl et les mêmes marges que <main> et le pied de page : le logo, le titre et l'adresse commencent à la même distance du bord. grid size-8 place-items-center dessine un carré de 32 pixels, dont place-items-center centre le contenu. aria-hidden="true" cache « LT » aux lecteurs d'écran, qui ne lisent que le nom de la ludothèque.

Le titre de chaque page. Dans games/index.blade.php, entourez le titre et sa phrase d'un <div> :

<div class="mb-8">
    <h1 class="text-3xl font-semibold tracking-tight">Catalogue</h1>
    <p class="mt-2 text-zinc-600">{{ $games->count() }} jeux à emprunter pour trois semaines.</p>
</div>

Faites de même dans loans/index.blade.php, games/create.blade.php et styleguide.blade.php. Donnez aussi text-zinc-600 aux paragraphes « Aucun jeu dans le catalogue. » et « Aucun prêt pour l'instant. ». Les titres sont en font-semibold, car le projet télécharge Instrument Sans en 400, 500 et 600, et non en 700.

Le catalogue après l'exercice 14.1 : en-tête blanc avec le logo LT, titre « Catalogue » et sous-titre gris sur fond gris clair, couvertures encore empilées

Le menu reste une liste verticale à droite du logo, et les jeux restent empilés. Ils prennent leur forme aux exercices 14.5 et 14.3.

Critères

  • Le fond de la page est gris très clair, et l'en-tête blanc est séparé du contenu par un trait.
  • À 1 280 pixels, le logo, le titre de la page et l'adresse du pied de page commencent à la même distance du bord gauche.
  • Sur /composants, une page courte, le pied de page touche le bas de la fenêtre.
  • À 375 pixels, les deux paragraphes du pied de page sont l'un sous l'autre, et côte à côte à 640 pixels.
  • Au clavier, le logo reçoit un contour bleu canard, écarté du texte.

14.2 Le bouton et ses variantes (20 minutes)

La page /composants affiche quatre boutons sans style : enregistrer, annuler, supprimer, et un bouton désactivé. Créez le composant :

php artisan make:component button --view
   INFO  View [resources/views/components/button.blade.php] created successfully.

La base. Remplacez tout le contenu du fichier créé :

@props(['variant' => 'primary'])

<button {{ $attributes->merge(['type' => 'button'])->class([
    'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors',
]) }}>
    {{ $slot }}
</button>
  • $attributes->merge(['type' => 'button']) donne au bouton le type button par défaut. Sans attribut type, un <button> placé dans un formulaire l'envoie. Un type écrit dans la balise <x-button> remplace cette valeur.
  • ->class([...]) reçoit un tableau comme @class, et ajoute les classes écrites dans la balise <x-button>. Le chapitre 13 écrivait class() avant merge() : l'ordre ne change pas le résultat.
  • inline-flex items-center justify-center gap-2 centre le texte du bouton, et l'écarte d'une icône s'il en reçoit une.
  • transition-colors fait passer les couleurs d'un état à l'autre en 150 millisecondes, au lieu d'un changement brusque.

Les variantes et les états. Complétez le tableau :

@props(['variant' => 'primary'])

<button {{ $attributes->merge(['type' => 'button'])->class([
    'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors',
    'focus-visible:outline-2 focus-visible:outline-offset-2',
    'disabled:cursor-not-allowed disabled:opacity-50',
    'bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600 disabled:hover:bg-brand-600' => $variant === 'primary',
    'bg-white text-zinc-900 ring-1 ring-zinc-300 ring-inset hover:bg-zinc-50 focus-visible:outline-zinc-500 disabled:hover:bg-white' => $variant === 'secondary',
    'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600 disabled:hover:bg-red-600' => $variant === 'danger',
]) }}>
    {{ $slot }}
</button>
Ligne Ce qu'elle fait
focus-visible:outline-2 focus-visible:outline-offset-2 au focus du clavier, un contour de 2 pixels, écarté du bouton de 2 pixels
disabled:cursor-not-allowed disabled:opacity-50 un bouton désactivé devient à moitié transparent, et le curseur montre un sens interdit
focus-visible:outline-brand-600, -zinc-500, -red-600 la couleur du contour, une par variante
ring-1 ring-zinc-300 ring-inset le bord du bouton secondaire : un contour de 1 pixel, dessiné à l'intérieur du bouton
disabled:hover:bg-brand-600 au survol d'un bouton désactivé, le fond reste celui du repos

Les préfixes s'additionnent, comme md:hover: au chapitre 13. Sans disabled:hover:bg-brand-600, un bouton désactivé foncerait au survol, comme s'il répondait au clic.

Le guide de style. Dans styleguide.blade.php, sous le <div> du titre, regroupez les trois sections dans un <div>, et remplacez les quatre boutons :

<div class="space-y-10">
    <section>
        <h2 class="text-xl font-semibold tracking-tight">Boutons</h2>
        <p class="mt-4 flex flex-wrap gap-3">
            <x-button>Enregistrer</x-button>
            <x-button variant="secondary">Annuler</x-button>
            <x-button variant="danger">Supprimer</x-button>
            <x-button disabled>Indisponible</x-button>
        </p>
    </section>

    …
</div>

Donnez les classes du titre « Boutons » aux deux autres titres <h2>. Affichez ensuite le code source de la page avec Ctrl+U :

            <button class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600 disabled:hover:bg-brand-600" type="button">
    Enregistrer
</button>
…
            <button class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600 disabled:hover:bg-brand-600" type="button" disabled="disabled">
    Indisponible
</button>

@class a gardé les trois premières chaînes et celle de la variante choisie. Le quatrième bouton n'a pas d'attribut variant : il prend primary, la valeur par défaut de @props.

Le formulaire. Dans games/create.blade.php, remplacez le bouton d'envoi :

<x-button type="submit">Ajouter le jeu</x-button>

Le type="submit" écrit dans la balise remplace le type="button" de merge(), et le bouton envoie toujours le formulaire.

Les quatre boutons de /composants : Enregistrer en bleu canard avec son contour de focus, Annuler blanc bordé de gris, Supprimer rouge, Indisponible pâle

Critères, sur /composants :

  • Les quatre boutons sont bleu canard, blanc bordé de gris, rouge, et bleu canard pâle.
  • Au survol, les trois premiers foncent progressivement. Le quatrième ne change pas, et le curseur montre un sens interdit.
  • Au clavier, chaque bouton actif reçoit un contour de sa couleur, écarté du bouton. Le bouton désactivé ne reçoit pas le focus.
  • Dans le code source, les quatre boutons portent type="button", et celui de /jeux/nouveau porte type="submit".
  • Le formulaire de /jeux/nouveau s'envoie toujours : un clic sur « Ajouter le jeu », avec les champs remplis, affiche la fiche du nouveau jeu.

3. Avec une banque de classes : la carte, le tableau, le menu

14.3 La carte et la grille du catalogue (25 minutes)

Chaque jeu du catalogue s'affiche dans une carte : la couverture en haut, puis la catégorie, le titre et une ligne d'informations. La carte du guide de style a le même cadre, sans image. Créez le composant card avec php artisan make:component card --view, et partez de cette structure :

<article {{ $attributes->class([]) }}>
    @isset($image)
        {{ $image }}
    @endisset

    <div>
        {{ $slot }}
    </div>
</article>

L'image arrive par un slot nommé, image, comme <x-slot:note> au chapitre Views. @isset ne l'affiche que si la page en donne une. Dans games/index.blade.php, chaque <article> devient :

<x-card>
    <x-slot:image>
        <img src="{{ asset($game->coverPath()) }}" alt="" width="400" height="300">
    </x-slot:image>

    <p>{{ $game->category }}</p>
    <h2><a href="{{ route('games.show', $game->id) }}">{{ $game->title }}</a></h2>
    <p>{{ $game->playersLabel() }} · {{ $game->duration }} min</p>
</x-card>

Dans styleguide.blade.php, remplacez de même l'<article> de la section « Carte » par <x-card>, sans slot image. Ajoutez ensuite les classes de la banque : celles du cadre dans le composant, celles de la grille sur la liste <ul>, et celles du contenu dans les deux vues.

Le catalogue à 1 280 pixels : quatre cartes par ligne, chacune avec sa couverture, sa catégorie en capitales bleu canard, son titre et une ligne grise

Le catalogue à 375 pixels : une seule carte par ligne, sur toute la largeur

Banque de classes, trois intrus compris :

Classe Effet
object-cover recadre l'image pour remplir sa boîte, sans la déformer
sm:grid-cols-2 deux colonnes à partir de 640 pixels
tracking-wide lettres un peu plus espacées
h-full toute la hauteur du parent
whitespace-nowrap interdit le passage à la ligne
rounded-xl coins arrondis de 12 pixels
xl:grid-cols-4 quatre colonnes à partir de 1 280 pixels
text-zinc-600 texte gris moyen
p-5 marge intérieure de 20 pixels
uppercase tout en majuscules
hover:shadow-md ombre plus marquée au survol
aspect-4/3 proportions de 4 de large pour 3 de haut
max-w-sm largeur maximale de 24 rem
grid range les enfants dans une grille
tabular-nums chiffres de même largeur
border border-zinc-200 bordure de 1 pixel, gris clair
transition-shadow l'ombre change progressivement
text-brand-700 texte bleu canard foncé
lg:grid-cols-3 trois colonnes à partir de 1 024 pixels
overflow-hidden coupe ce qui dépasse du cadre, comme les coins de l'image
hover:underline hover:underline-offset-4 souligne au survol, 4 pixels sous le texte
gap-6 24 pixels entre les lignes et entre les colonnes
shadow-xs ombre très légère
divide-y trait horizontal entre les enfants
w-full toute la largeur du parent
rounded-sm coins arrondis de 4 pixels, ceux du contour de focus compris
bg-white fond blanc
text-xs, text-sm, text-lg texte très petit, petit, grand
font-semibold lettres épaisses
mt-1, mt-2, mt-4 marge au-dessus de 4, 8 ou 16 pixels
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 le contour de focus des boutons principaux, vu en 14.2

Critères

  • Le catalogue affiche quatre cartes par ligne à 1 280 pixels, trois à 1 024, deux à 640 et une seule à 375.
  • Dans une même ligne, les cartes ont toutes la même hauteur, même quand un titre passe sur deux lignes.
  • La couverture remplit le haut de la carte sans être déformée, et ses coins suivent l'arrondi de la carte.
  • Au survol d'une carte, son ombre se renforce progressivement. Au survol du titre, il se souligne.
  • Au clavier, le titre de chaque jeu reçoit un contour bleu canard.
  • La catégorie s'écrit en petites majuscules bleu canard, au-dessus du titre.
  • Sur /composants, la carte a le même cadre, sur 24 rem de large au plus.
  • Les classes du cadre n'apparaissent que dans components/card.blade.php.

components/card.blade.php :

<article {{ $attributes->class([
    'overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs',
]) }}>
    @isset($image)
        {{ $image }}
    @endisset

    <div class="p-5">
        {{ $slot }}
    </div>
</article>

games/index.blade.php, sous le titre de la page :

<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
    @foreach ($games as $game)
        <li>
            <x-card class="h-full transition-shadow hover:shadow-md">
                <x-slot:image>
                    <img src="{{ asset($game->coverPath()) }}" alt="" width="400" height="300" class="aspect-4/3 w-full object-cover">
                </x-slot:image>

                <p class="text-xs font-semibold tracking-wide text-brand-700 uppercase">{{ $game->category }}</p>
                <h2 class="mt-1 text-lg font-semibold">
                    <a href="{{ route('games.show', $game->id) }}" class="rounded-sm hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600">{{ $game->title }}</a>
                </h2>
                <p class="mt-2 text-sm text-zinc-600">{{ $game->playersLabel() }} · {{ $game->duration }} min</p>
            </x-card>
        </li>
    @endforeach
</ul>

styleguide.blade.php, section « Carte » :

<x-card class="mt-4 max-w-sm">
    <h3 class="font-semibold">Titre de la carte</h3>
    <p class="mt-2 text-sm text-zinc-600">Le contenu de la carte : un texte court, une liste ou une image.</p>
</x-card>

La grille étire chaque <li> à la hauteur de sa ligne. h-full étire ensuite la carte à la hauteur de son <li> : sans lui, une carte au titre court s'arrête plus haut que ses voisines. overflow-hidden coupe les coins de l'image, qui sinon dépasseraient de l'arrondi de la carte.

Les trois intrus : whitespace-nowrap, tabular-nums et divide-y. Ils servent à l'exercice suivant.

14.4 Le tableau des prêts et le badge de statut (25 minutes)

Chaque prêt est en cours, en retard ou rendu. Le modèle Loan calcule ce statut à partir des dates :

Méthode Ce qu'elle renvoie
$loan->status() 'ongoing', 'overdue' ou 'returned'
$loan->statusLabel() 'En cours', 'En retard' ou 'Rendu'

Le badge. Créez le composant badge, avec une prop status. Écrivez ses classes avec @class, sur le modèle du badge du chapitre 13 : une chaîne toujours écrite, puis une chaîne par statut. Le texte du badge arrive par le slot :

<x-badge :status="$loan->status()">{{ $loan->statusLabel() }}</x-badge>

Au chapitre 13, le badge choisissait lui-même son texte avec match. Ici, le modèle fournit déjà ce texte avec statusLabel(). Dans styleguide.blade.php, remplacez les trois <span> par <x-badge status="ongoing">En cours</x-badge> et ses deux voisins.

Le tableau. Un tableau de six colonnes ne tient pas sur un téléphone de 375 pixels. Dans loans/index.blade.php, entourez <table> d'un <div> : c'est ce <div> qui défilera de côté, et non toute la page. Donnez ensuite leurs classes au <div>, à <table>, à <thead>, à <tbody>, aux lignes et aux cellules. Remplacez enfin {{ $loan->statusLabel() }} par le badge.

La page /prets à 1 280 pixels : un tableau encadré et arrondi, en-têtes en gras sur fond gris très clair, noms des jeux en bleu canard, badges bleus, rouges et gris

La page /prets à 375 pixels : le tableau est coupé à droite de la colonne Membre, dans son cadre, et la page ne déborde pas

Banque de classes, trois intrus compris :

Classe Effet
whitespace-nowrap interdit le passage à la ligne
bg-red-50 text-red-700 ring-red-600/20 fond rouge très pâle, texte rouge, contour rouge à 20 % d'opacité
min-w-full au moins toute la largeur du parent
px-4 py-3 marge intérieure de 16 pixels sur les côtés, 12 en haut et en bas
inline-flex items-center aligne le contenu au milieu, sans passer à la ligne avant ni après
tabular-nums chiffres de même largeur, alignés d'une ligne à l'autre
aspect-4/3 proportions de 4 de large pour 3 de haut
overflow-x-auto ajoute une barre de défilement horizontale quand le contenu est trop large
ring-1 ring-inset contour de 1 pixel, dessiné à l'intérieur
hover:bg-zinc-50 fond gris très clair au survol
divide-y divide-zinc-200 trait gris clair entre les enfants
text-left texte aligné à gauche
bg-zinc-100 text-zinc-600 ring-zinc-500/20 fond gris pâle, texte gris, contour gris à 20 % d'opacité
rounded-full coins entièrement arrondis, en forme de pilule
font-medium lettres moyennement épaisses
object-cover recadre l'image pour remplir sa boîte
bg-zinc-50 fond gris très clair
px-2.5 py-0.5 marge intérieure de 10 pixels sur les côtés, 2 en haut et en bas
text-zinc-500, text-zinc-400 texte gris, gris plus clair
bg-sky-50 text-sky-700 ring-sky-600/20 fond bleu ciel très pâle, texte bleu, contour bleu à 20 % d'opacité
xl:grid-cols-4 quatre colonnes à partir de 1 280 pixels
text-xs, text-sm texte très petit, petit
font-semibold lettres épaisses
mt-4 flex flex-wrap gap-3 la rangée de boutons du guide de style, vue en 14.2
rounded-xl border border-zinc-200 bg-white shadow-xs le cadre de la carte, vu en 14.3
text-brand-700, avec les classes du lien de la carte le lien du titre, vu en 14.3, en bleu canard

Critères

  • À 375 pixels, la page ne défile pas de côté. Seul le tableau défile, à l'intérieur de son cadre arrondi.
  • Le nom et l'adresse du membre restent sur deux lignes, et aucune autre cellule ne passe à la ligne.
  • Les en-têtes sont en gras sur un fond gris très clair, et un trait fin sépare les lignes.
  • Au survol, toute la ligne prend un fond gris très clair.
  • Dans les colonnes de dates, les chiffres sont alignés d'une ligne à l'autre.
  • Le nom du jeu est un lien bleu canard, souligné au survol, avec un contour au clavier.
  • Un prêt en cours a un badge bleu, un retard un badge rouge, un jeu rendu un badge gris, ici comme sur /composants.
  • loans/index.blade.php ne contient aucune condition sur le statut : seul badge.blade.php choisit les couleurs.

components/badge.blade.php :

@props(['status'])

<span @class([
    'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ring-1 ring-inset',
    'bg-sky-50 text-sky-700 ring-sky-600/20' => $status === 'ongoing',
    'bg-red-50 text-red-700 ring-red-600/20' => $status === 'overdue',
    'bg-zinc-100 text-zinc-600 ring-zinc-500/20' => $status === 'returned',
])>
    {{ $slot }}
</span>

loans/index.blade.php, sous le titre de la page :

<div class="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-xs">
    <table class="min-w-full divide-y divide-zinc-200 text-left text-sm">
        <thead class="bg-zinc-50">
            <tr>
                <th scope="col" class="px-4 py-3 font-semibold whitespace-nowrap">Jeu</th>
                …
                <th scope="col" class="px-4 py-3 font-semibold whitespace-nowrap">Statut</th>
            </tr>
        </thead>
        <tbody class="divide-y divide-zinc-200">
            @foreach ($loans as $loan)
                <tr class="hover:bg-zinc-50">
                    <td class="px-4 py-3 font-medium whitespace-nowrap">
                        <a href="{{ route('games.show', $loan->game->id) }}" class="rounded-sm text-brand-700 hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600">{{ $loan->game->title }}</a>
                    </td>
                    <td class="px-4 py-3 whitespace-nowrap">
                        {{ $loan->member->name }}
                        <br>
                        <span class="text-zinc-500">{{ $loan->member->email }}</span>
                    </td>
                    <td class="px-4 py-3 whitespace-nowrap tabular-nums">{{ $loan->borrowed_at->format('d/m/Y') }}</td>
                    <td class="px-4 py-3 whitespace-nowrap tabular-nums">{{ $loan->due_at->format('d/m/Y') }}</td>
                    <td class="px-4 py-3 whitespace-nowrap tabular-nums">
                        @if ($loan->returned_at !== null)
                            {{ $loan->returned_at->format('d/m/Y') }}
                        @else
                            <span class="text-zinc-400">—</span>
                        @endif
                    </td>
                    <td class="px-4 py-3">
                        <x-badge :status="$loan->status()">{{ $loan->statusLabel() }}</x-badge>
                    </td>
                </tr>
            @endforeach
        </tbody>
    </table>
</div>

Les six <th> portent les mêmes classes. styleguide.blade.php, section « Statuts d'un prêt » :

<p class="mt-4 flex flex-wrap gap-3">
    <x-badge status="ongoing">En cours</x-badge>
    <x-badge status="overdue">En retard</x-badge>
    <x-badge status="returned">Rendu</x-badge>
</p>

min-w-full donne au tableau au moins la largeur du cadre sur un grand écran. Sur un téléphone, whitespace-nowrap garde chaque cellule sur une ligne : le tableau devient plus large que le cadre, et overflow-x-auto le fait défiler. Les intrus : aspect-4/3, object-cover et xl:grid-cols-4.

14.5 Le menu responsive (30 minutes)

Sur un téléphone, le logo et les trois liens du menu ne tiennent pas sur une ligne. Le menu se cache alors derrière un bouton, et s'affiche en ligne à partir de 768 pixels. Le lien de la page ouverte se distingue des deux autres.

Le lien de menu. Créez le composant nav-link, avec deux props : href, l'adresse, et active, qui vaut false par défaut. Partez de cette structure, et ajoutez ses classes avec @class : une chaîne toujours écrite, une pour le lien actif, une pour les autres. ! $active vaut true quand le lien n'est pas actif.

@props(['href', 'active' => false])

<a
    href="{{ $href }}"
    @if ($active) aria-current="page" @endif
>
    {{ $slot }}
</a>

L'attribut aria-current="page" indique aux lecteurs d'écran que ce lien mène à la page ouverte. Dans le layout, les trois liens deviennent :

<li><x-nav-link :href="route('games.index')" :active="request()->routeIs('games.index', 'games.show')">Catalogue</x-nav-link></li>
<li><x-nav-link :href="route('loans.index')" :active="request()->routeIs('loans.*')">Prêts</x-nav-link></li>
<li><x-nav-link :href="route('games.create')" :active="request()->routeIs('games.create')">Ajouter un jeu</x-nav-link></li>

request()->routeIs() renvoie true quand le nom de la route de la page ouverte figure dans sa liste. 'loans.*' désigne toute route dont le nom commence par loans.. Le lien « Catalogue » reste actif sur la fiche d'un jeu, car elle appartient au catalogue.

Le bouton. Placez ce bouton entre le logo et <nav>, et donnez à <nav> l'attribut id="main-menu" :

<button type="button" id="menu-button" aria-controls="main-menu" aria-expanded="false">
    <span class="sr-only">Menu</span>
    <svg class="size-6 group-aria-expanded:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
        <path d="M4 6h16M4 12h16M4 18h16" />
    </svg>
    <svg class="hidden size-6 group-aria-expanded:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
        <path d="M6 6l12 12M18 6L6 18" />
    </svg>
</button>
  • Les deux <svg> dessinent trois traits, l'icône appelée burger, et une croix. stroke="currentColor" leur donne la couleur du texte du bouton.
  • sr-only cache le mot « Menu » à l'écran, mais les lecteurs d'écran le lisent : le bouton a un nom.
  • group-aria-expanded:hidden cache les trois traits quand le parent marqué de la classe group porte aria-expanded="true". group-aria-expanded:block affiche alors la croix. La classe group, dans la banque, va sur le bouton.

L'icône suit donc l'attribut aria-expanded, sans autre ligne de JavaScript.

Le script. Le code d'une page Laravel va dans resources/js/app.js, la seconde source de @vite. Le layout ne contient aucune balise <script> : Vite sert app.js pendant npm run dev, et l'inclut dans public/build/ au build. Écrivez-y le script du chapitre 13, section 6, avec deux changements :

  • le script lit l'état du menu dans aria-expanded, avec getAttribute(), puis écrit l'état contraire,
  • il ne s'exécute que si querySelector() a trouvé le bouton et le menu, car querySelector() renvoie null pour un élément absent.

L'en-tête à 375 pixels, menu ouvert : la croix à droite du logo, puis les trois liens l'un sous l'autre, « Prêts » sur fond bleu canard très clair

L'en-tête à 1 280 pixels : le logo à gauche, les trois liens alignés à droite, « Prêts » sur fond bleu canard très clair

Banque de classes, trois intrus compris :

Classe Effet
md:hidden cache l'élément à partir de 768 pixels
group marque le parent que lisent les préfixes group-…: de ses enfants
border-t border-zinc-200 trait gris clair au-dessus
transition-colors les couleurs changent progressivement
md:flex-row enfants en ligne à partir de 768 pixels
bg-brand-50 text-brand-700 fond bleu canard très clair, texte bleu canard foncé
tabular-nums chiffres de même largeur
hidden cache l'élément
md:w-auto largeur du contenu à partir de 768 pixels
p-2 marge intérieure de 8 pixels
md:block affiche l'élément à partir de 768 pixels
flex flex-col enfants en colonne
hover:bg-zinc-100 hover:text-zinc-900 fond gris pâle et texte presque noir au survol
md:border-0 plus de bordure à partir de 768 pixels
block le lien occupe toute la largeur de sa ligne, et toute sa surface est cliquable
ring-inset contour dessiné à l'intérieur
gap-1 4 pixels entre les enfants
md:items-center enfants centrés en hauteur à partir de 768 pixels
w-full toute la largeur du parent
px-3 py-2 marge intérieure de 12 pixels sur les côtés, 8 en haut et en bas
py-2, md:py-0 marge intérieure de 8 pixels en haut et en bas, aucune à partir de 768 pixels
aspect-4/3 proportions de 4 de large pour 3 de haut
rounded-md coins arrondis de 6 pixels
text-zinc-600 texte gris moyen
text-sm font-medium texte petit, lettres moyennement épaisses
focus-visible:outline-2 focus-visible:outline-brand-600 contour de focus bleu canard, collé à l'élément

Critères

  • À 375 pixels, l'en-tête n'affiche que le logo et l'icône à trois traits.
  • Un clic sur l'icône affiche les trois liens l'un sous l'autre, sous un trait, et l'icône devient une croix. Un second clic referme le menu.
  • Dans l'onglet Éléments, le bouton passe de aria-expanded="false" à aria-expanded="true" quand le menu s'ouvre.
  • À partir de 768 pixels, le bouton disparaît, et les trois liens s'alignent à droite du logo, menu fermé ou non.
  • Le lien de la page ouverte a un fond bleu canard très clair et porte aria-current="page". Sur la fiche d'un jeu, c'est « Catalogue ».
  • Au clavier, le bouton et chaque lien reçoivent un contour bleu canard.
  • Le layout ne contient aucune balise <script>.

components/nav-link.blade.php :

@props(['href', 'active' => false])

<a
    href="{{ $href }}"
    @if ($active) aria-current="page" @endif
    @class([
        'block rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-brand-600',
        'bg-brand-50 text-brand-700' => $active,
        'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900' => ! $active,
    ])
>
    {{ $slot }}
</a>

Le layout, entre le logo et la fin du <div> de l'en-tête :

<button
    type="button"
    id="menu-button"
    class="group rounded-md p-2 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-brand-600 md:hidden"
    aria-controls="main-menu"
    aria-expanded="false"
>
    …
</button>

<nav id="main-menu" class="hidden w-full md:block md:w-auto" aria-label="Navigation principale">
    <ul class="flex flex-col gap-1 border-t border-zinc-200 py-2 md:flex-row md:items-center md:border-0 md:py-0">
        …
    </ul>
</nav>

resources/js/app.js :

// Le menu mobile : le bouton burger affiche ou cache la liste des liens.
const menuButton = document.querySelector('#menu-button');
const menu = document.querySelector('#main-menu');

if (menuButton && menu) {
    menuButton.addEventListener('click', () => {
        const isOpen = menuButton.getAttribute('aria-expanded') === 'true';

        menuButton.setAttribute('aria-expanded', String(!isOpen));
        menu.classList.toggle('hidden');
    });
}

w-full place le menu ouvert sur sa propre ligne, sous le logo : le <div> de l'en-tête a flex-wrap. À partir de 768 pixels, md:block l'emporte sur hidden, que le script l'ait retiré ou non. Les intrus : tabular-nums, ring-inset et aspect-4/3.

4. D'après la capture : la fiche, le formulaire, le mode sombre

14.6 La fiche d'un jeu (20 minutes)

Mettez en forme games/show.blade.php. L'historique des prêts réutilise <x-badge>.

La fiche du jeu Code Corail à 1 280 pixels : la couverture à gauche, à droite la catégorie, le titre, la description et trois chiffres, puis l'historique des prêts dans un cadre, avec un badge par prêt

Critères

  • Le lien « ← Retour au catalogue » est petit, bleu canard, souligné au survol, avec un contour au clavier.
  • À partir de 768 pixels, la couverture et le texte sont côte à côte, en deux colonnes égales, alignés en haut. En dessous, la couverture est au-dessus du texte.
  • La couverture a des coins arrondis et une ombre légère, sans être déformée.
  • La catégorie et le titre reprennent le style du catalogue. Le titre a la taille des autres titres de page.
  • La description a des lignes plus espacées que le texte courant.
  • Joueurs, durée et nombre de prêts sont sur trois colonnes, sous un trait. Chaque libellé gris est au-dessus de sa valeur en gras.
  • L'historique est une liste encadrée, avec un trait entre deux prêts et le badge à droite. Si la place manque, le badge passe sous la date.

md:grid-cols-2, md:items-start, grid-cols-3 et leading-relaxed. Les autres viennent des exercices 14.1 à 14.4.

<x-layouts.app>
    <x-slot:title>{{ $game->title }}</x-slot:title>

    <p class="mb-6">
        <a href="{{ route('games.index') }}" class="rounded-sm text-sm font-medium text-brand-700 hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600">← Retour au catalogue</a>
    </p>

    <article class="grid gap-8 md:grid-cols-2 md:items-start">
        <img src="{{ asset($game->coverPath()) }}" alt="" width="400" height="300" class="aspect-4/3 w-full rounded-xl object-cover shadow-xs">

        <div>
            <p class="text-sm font-semibold tracking-wide text-brand-700 uppercase">{{ $game->category }}</p>
            <h1 class="mt-1 text-3xl font-semibold tracking-tight">{{ $game->title }}</h1>

            @if ($game->description !== null)
                <p class="mt-4 leading-relaxed text-zinc-700">{{ $game->description }}</p>
            @endif

            <dl class="mt-6 grid grid-cols-3 gap-4 border-t border-zinc-200 pt-6">
                <div>
                    <dt class="text-sm text-zinc-500">Joueurs</dt>
                    <dd class="mt-1 font-semibold">{{ $game->playersLabel() }}</dd>
                </div>
                …
            </dl>
        </div>
    </article>

    <section class="mt-12">
        <h2 class="text-xl font-semibold tracking-tight">Historique des prêts</h2>

        @if ($loans->isEmpty())
            <p class="mt-4 text-zinc-600">Ce jeu n'a encore jamais été emprunté.</p>
        @else
            <ul class="mt-4 divide-y divide-zinc-200 rounded-xl border border-zinc-200 bg-white">
                @foreach ($loans as $loan)
                    <li class="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 px-5 py-4">
                        <div>
                            <p class="font-medium">{{ $loan->member->name }}</p>
                            <p class="text-sm text-zinc-500">Emprunté le {{ $loan->borrowed_at->format('d/m/Y') }}, à rendre le {{ $loan->due_at->format('d/m/Y') }}</p>
                        </div>
                        <x-badge :status="$loan->status()">{{ $loan->statusLabel() }}</x-badge>
                    </li>
                @endforeach
            </ul>
        @endif
    </section>
</x-layouts.app>

Les deux autres blocs de <dl> portent les mêmes classes. md:items-start aligne les deux colonnes en haut : sans lui, la grille étire l'image à la hauteur du texte.

14.7 Le formulaire d'ajout (25 minutes)

Mettez en forme games/create.blade.php. Le message sous le champ « Durée » est écrit en dur, car le formulaire ne vérifie encore rien : il sert à donner son style à un champ en erreur. Les attributs du champ sont déjà en place. aria-invalid="true" annonce aux lecteurs d'écran que la valeur est refusée, et aria-describedby="duration-error" relie le champ à son message.

Le formulaire « Ajouter un jeu » à 1 280 pixels : un cadre blanc centré, le champ Titre entouré d'un halo bleu canard au focus, le champ Durée bordé de rouge avec son message rouge, les boutons Annuler et Ajouter le jeu en bas à droite

Critères

  • Le titre et le formulaire sont centrés, sur 42 rem de large au plus. Le formulaire a le cadre d'une carte, et ses blocs sont espacés régulièrement.
  • Chaque libellé est au-dessus de son champ. Un clic sur le libellé place le curseur dans le champ.
  • Les champs occupent toute la largeur du cadre, et « Minimum » et « Maximum » se partagent une ligne.
  • Au focus, la bordure du champ devient bleu canard, et un halo bleu canard pâle l'entoure, au clic comme au clavier.
  • Le champ « Durée » a une bordure rouge et son message est rouge. Au focus, son halo est rouge.
  • L'aide « Facultatif. Deux ou trois phrases suffisent. » est grise et plus petite que le libellé.
  • À 375 pixels, le bouton « Ajouter le jeu » occupe toute la largeur, au-dessus du lien « Annuler ». À partir de 640 pixels, les deux sont alignés à droite, sur une ligne.
  • Le lien « Annuler » reçoit un contour bleu canard au clavier.

Un champ de saisie reçoit le focus au clic comme au clavier, et la personne doit voir où elle tape dans les deux cas : écrivez focus:, et non focus-visible:. focus:ring-3 focus:ring-brand-600/20 dessine le halo. focus:outline-hidden retire le contour du navigateur, que la bordure et le halo remplacent : la règle du chapitre 13 est respectée. En mode de contraste élevé de Windows, outline-hidden laisse le contour du navigateur, et le champ reste repérable.

Autres classes nouvelles : max-w-2xl, space-y-6, placeholder:text-zinc-400, flex-col-reverse et sm:flex-row.

<x-layouts.app title="Ajouter un jeu">
    <div class="mx-auto max-w-2xl">
        <div class="mb-8">
            <h1 class="text-3xl font-semibold tracking-tight">Ajouter un jeu</h1>
            <p class="mt-2 text-zinc-600">Le jeu rejoint le catalogue dès l'enregistrement.</p>
        </div>

        <form method="POST" action="{{ route('games.store') }}" class="space-y-6 rounded-xl border border-zinc-200 bg-white p-6 shadow-xs sm:p-8">
            @csrf

            <div>
                <label for="title" class="block text-sm font-medium">Titre</label>
                <input type="text" id="title" name="title" required class="mt-2 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 shadow-xs placeholder:text-zinc-400 focus:border-brand-600 focus:ring-3 focus:ring-brand-600/20 focus:outline-hidden">
            </div>

            …

            <fieldset>
                <legend class="text-sm font-medium">Nombre de joueurs</legend>

                <div class="mt-2 grid grid-cols-2 gap-4">
                    <div>
                        <label for="min_players" class="block text-sm text-zinc-600">Minimum</label>
                        <input type="number" id="min_players" name="min_players" min="1" max="20" value="2" required class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 shadow-xs focus:border-brand-600 focus:ring-3 focus:ring-brand-600/20 focus:outline-hidden">
                    </div>
                    …
                </div>
            </fieldset>

            <div>
                <label for="duration" class="block text-sm font-medium">Durée d'une partie, en minutes</label>
                <input type="number" id="duration" name="duration" min="5" required aria-invalid="true" aria-describedby="duration-error" class="mt-2 block w-full rounded-lg border border-red-500 bg-white px-3 py-2 text-red-900 shadow-xs focus:border-red-600 focus:ring-3 focus:ring-red-600/20 focus:outline-hidden">
                <p id="duration-error" class="mt-2 text-sm text-red-600">Indiquez une durée en minutes, par exemple 45.</p>
            </div>

            <div>
                <label for="description" class="block text-sm font-medium">Description</label>
                <textarea id="description" name="description" rows="4" aria-describedby="description-hint" class="mt-2 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 shadow-xs focus:border-brand-600 focus:ring-3 focus:ring-brand-600/20 focus:outline-hidden"></textarea>
                <p id="description-hint" class="mt-2 text-sm text-zinc-500">Facultatif. Deux ou trois phrases suffisent.</p>
            </div>

            <div class="flex flex-col-reverse gap-3 border-t border-zinc-200 pt-6 sm:flex-row sm:items-center sm:justify-end">
                <a href="{{ route('games.index') }}" class="rounded-md px-3 py-2 text-center text-sm font-semibold text-zinc-700 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-brand-600">Annuler</a>
                <x-button type="submit">Ajouter le jeu</x-button>
            </div>
        </form>
    </div>
</x-layouts.app>

Le <select> de la catégorie porte les classes du champ « Titre », sans placeholder:text-zinc-400. Le champ « Maximum » porte celles de « Minimum ». flex-col-reverse inverse l'ordre de la colonne : sur un téléphone, le bouton passe au-dessus du lien, et la colonne l'étire sur toute la largeur.

Le code qui sent mauvais

En binôme, cinq minutes. Dans le corrigé de 14.7, les six champs répètent presque la même liste de quatorze classes. La ludothèque veut maintenant des champs aux coins moins arrondis, rounded-md au lieu de rounded-lg. Combien de lignes modifiez-vous, et que faudrait-il créer pour n'en modifier qu'une ?

14.8 Bonus : le mode sombre (30 minutes)

Le préfixe dark: du chapitre 13 applique une classe quand le système est réglé en mode sombre. Donnez une version sombre à tout le site : le layout, les quatre composants et les cinq vues.

Pour tester sans changer le réglage de votre système, ouvrez les outils de développement de Chrome ou d'Edge avec F12. Appuyez ensuite sur Ctrl+Maj+P, tapez dark, et choisissez « Emulate CSS prefers-color-scheme: dark ».

Le catalogue en mode sombre : fond presque noir, en-tête et cartes gris très foncé, catégories en bleu canard clair, textes clairs

Critères

  • Sur les cinq pages, le fond est presque noir, et les surfaces sont un peu plus claires : en-tête, cartes, tableau, formulaire.
  • Tout texte reste lisible. Les gris passent à une nuance plus claire, et le bleu canard à brand-300.
  • Les bordures et les traits de séparation restent visibles, sans briller.
  • Les trois badges, le bouton secondaire et le lien actif du menu ont leur version sombre. Les boutons principal et danger ne changent pas.
  • Les champs du formulaire ont un fond sombre, et le texte tapé est clair.
  • De retour en mode clair, rien n'a changé.
Clair Sombre
bg-zinc-50, le fond de la page dark:bg-zinc-950
bg-white, une surface dark:bg-zinc-900
border-zinc-200, divide-zinc-200 dark:border-zinc-800, dark:divide-zinc-800
text-zinc-900, text-zinc-600 dark:text-zinc-100, dark:text-zinc-400
text-brand-700 dark:text-brand-300
bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-400/10 dark:text-sky-300 dark:ring-sky-400/30

Le <body> et l'en-tête du layout :

<body class="flex min-h-screen flex-col bg-zinc-50 font-sans text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100">
    <header class="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">

La carte et le bouton secondaire :

<article {{ $attributes->class([
    'overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs',
    'dark:border-zinc-800 dark:bg-zinc-900',
]) }}>
'bg-white text-zinc-900 ring-1 ring-zinc-300 ring-inset hover:bg-zinc-50 focus-visible:outline-zinc-500 disabled:hover:bg-white dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700' => $variant === 'secondary',

Le badge et le lien de menu :

'bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-400/10 dark:text-sky-300 dark:ring-sky-400/30' => $status === 'ongoing',
'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-400/10 dark:text-red-300 dark:ring-red-400/30' => $status === 'overdue',
'bg-zinc-100 text-zinc-600 ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-300 dark:ring-zinc-400/30' => $status === 'returned',
'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-200' => $active,
'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white' => ! $active,

Les champs du formulaire ajoutent dark:border-zinc-700 dark:bg-zinc-950, et l'en-tête du tableau dark:bg-zinc-800/50. Grâce aux composants, le mode sombre du bouton, de la carte et du badge s'écrit une fois pour tout le site.

À retenir

  • @vite dans le <head> du layout charge le CSS et le JavaScript de toutes les pages, et composer run dev doit tourner pour que chaque nouvelle classe ait sa règle.
  • Un conteneur mx-auto max-w-6xl répété avec les mêmes marges aligne l'en-tête, le contenu et le pied de page.
  • Un composant Blade écrit une liste de classes une fois, choisit ses variantes avec @class ou $attributes->class(), et laisse la page ajouter une marge.
  • Une classe sans préfixe vise le téléphone, et sm:, md:, lg: ou xl: la remplacent à partir d'une largeur.
  • Le JavaScript d'une page va dans resources/js/app.js, et il tient l'attribut aria-expanded à jour pour les lecteurs d'écran.

Six cartes

Pourquoi w-full sur un <main> qui a déjà mx-auto max-w-6xl ?

Dans une colonne Flex, un enfant aux marges auto ne prend que la largeur de son contenu. w-full lui rend toute la largeur, et max-w-6xl la limite.

Pourquoi h-full sur la carte du catalogue ?

La grille étire chaque <li> à la hauteur de sa ligne. h-full étire la carte à la hauteur de son <li>, et toutes les cartes d'une ligne ont la même hauteur.

Pourquoi entourer le tableau d'un <div class="overflow-x-auto"> ?

Pour que seul ce <div> défile de côté sur un téléphone, et non toute la page.

Que fait group-aria-expanded:hidden ?

Il cache l'élément quand son parent marqué de la classe group porte aria-expanded="true".

focus: ou focus-visible: ?

focus-visible: pour un bouton ou un lien, dont le contour n'apparaît qu'au clavier. focus: pour un champ de saisie, qui doit montrer son focus au clic comme au clavier.

Où va le script du menu ?

Dans resources/js/app.js, que @vite charge sur chaque page. Le layout ne contient aucune balise <script>.