kodingup.camp

Beranda/Various Library Mastery

Integrasi Library Komponen UI dengan shadcn/ui

Modul 12 dari 135 menit bacaUI Library Integration Techniques

Isi modul

    Kenapa ini penting

    Membuat tombol, input, dialog, dan dropdown yang cantik, konsisten, dan aksesibel (bisa dipakai dengan keyboard dan pembaca layar) dari nol itu sulit. Dialog saja harus menangani fokus yang terkunci di dalamnya, tombol Escape, klik di luar untuk menutup, dan atribut ARIA yang benar.

    shadcn/ui berbeda dari library UI biasa: ia bukan paket yang kamu install sebagai dependency. Ia adalah kumpulan komponen yang kodenya disalin ke proyekmu lewat CLI. Kamu memiliki kodenya, bebas mengubahnya, dan tampilannya memakai Tailwind CSS yang sudah kamu kuasai. Di bawahnya, komponen interaktif memakai primitive yang sudah menangani aksesibilitas. Versi CLI yang dipakai: shadcn 4.22.1, dengan Vite 8 dan Tailwind CSS 4.3.

    Konsep inti

    Analogi: resep vs makanan jadi

    Library UI seperti MUI itu seperti membeli makanan jadi di restoran: praktis, tapi kalau ingin mengurangi garam, kamu harus "meminta" lewat opsi yang disediakan restoran. shadcn/ui itu seperti diberi resep lengkap beserta bahannya di dapurmu sendiri: kamu memasak sekali (CLI menyalin kode), lalu bebas menambah atau mengurangi bumbu sesukamu.

    Kelebihan dan kekurangan

    Aspek shadcn/ui Library terpaket (MUI, Mantine)
    Kepemilikan kode Kode ada di src/components/ui Kode di node_modules
    Kustomisasi Ubah langsung file-nya Lewat props, theme, override
    Styling Tailwind CSS + CSS variables Sistem styling masing-masing
    Update Manual (jalankan CLI lagi / bandingkan) npm update
    Jumlah komponen Cukup banyak, fokus dasar Sangat banyak, termasuk data grid, date picker lengkap

    Setup dengan Vite + Tailwind v4 (JavaScript)

    Dokumentasi resmi shadcn/ui untuk Vite memakai TypeScript, tapi shadcn mendukung JavaScript dengan opsi "tsx": false di components.json, sehingga komponen yang disalin berakhiran .jsx. Langkahnya:

    1. Buat proyek Vite React dan pasang Tailwind v4 (plugin Vite).
    2. Atur alias import @ → src di vite.config.js dan jsconfig.json, karena komponen shadcn mengimpor dengan @/components/... dan @/lib/utils.
    3. Jalankan npx shadcn@latest init. CLI akan menanyakan beberapa pilihan (gaya, warna dasar), lalu membuat components.json, file src/lib/utils.js (mengekspor fungsi cn untuk menggabungkan class Tailwind; di versi CLI ini cn berasal dari paket npm bernama cn), dan menambahkan CSS variables tema, font, serta baris @custom-variant dark ke src/index.css. Di CLI 4.22.1 pertanyaan pertamanya adalah memilih preset (misalnya Nova), dan ada opsi --base untuk memilih library primitive (radix atau base). Materi ini memakai Radix (npx shadcn@latest init -b radix), karena contoh DialogTrigger asChild di bawah adalah API Radix.
    4. Pastikan components.json berisi "tsx": false supaya komponen yang ditambahkan berupa .jsx. Pada uji coba dengan template Vite JavaScript, CLI sudah mendeteksi ini otomatis.
    5. Tambahkan komponen satu per satu: npx shadcn@latest add button input label dialog.

    Komponen yang kita pakai

    Fungsi cn

    cn("px-4", kondisi && "bg-red-500", className) menggabungkan class dan menyelesaikan konflik Tailwind (misalnya px-2 dan px-4, yang terakhir menang). Gunakan saat membuat varian komponenmu sendiri.

    Alternatif: MUI dan Mantine

    Keduanya memakai sistem styling sendiri, jadi menggabungkannya dengan Tailwind butuh penyesuaian. Karena kamu sudah memakai Tailwind, shadcn/ui adalah pilihan paling selaras.

    Contoh kode

    # 1. Proyek baru Vite + React (JavaScript)
    npm create vite@latest dashboard-tugas -- --template react
    cd dashboard-tugas
    npm install
    
    # 2. Tailwind CSS v4 lewat plugin Vite
    npm install tailwindcss @tailwindcss/vite
    
    // vite.config.js
    import { fileURLToPath, URL } from "node:url";
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import tailwindcss from "@tailwindcss/vite";
    
    export default defineConfig({
      plugins: [react(), tailwindcss()],
      resolve: {
        alias: {
          "@": fileURLToPath(new URL("./src", import.meta.url)), // @ → folder src
        },
      },
    });
    
    {
      "compilerOptions": {
        "baseUrl": ".",
        "paths": {
          "@/*": ["./src/*"]
        }
      }
    }
    

    Simpan JSON di atas sebagai jsconfig.json di root proyek (membantu editor dan CLI shadcn mengenali alias). Lalu isi src/index.css dengan:

    /* src/index.css, CLI shadcn akan menambahkan import, variabel tema, dan varian dark di bawahnya */
    @import "tailwindcss";
    
    # 3. Inisialisasi shadcn/ui (Radix) lalu tambahkan komponen
    npx shadcn@latest init -b radix
    npx shadcn@latest add button input label dialog checkbox
    # → ✔ Verifying framework. Found Vite.
    # → ✔ Validating Tailwind CSS. Found v4.
    # → file baru: src/components/ui/button.jsx, input.jsx, label.jsx, dialog.jsx, checkbox.jsx
    #   dan src/lib/utils.js yang mengekspor fungsi cn()
    

    Kalau komponen yang dihasilkan berakhiran .tsx, buka components.json, ubah "tsx": true menjadi "tsx": false, hapus file .tsx tadi, lalu jalankan add lagi.

    // src/features/todos/AddTaskDialog.jsx, Dialog + React Hook Form + Zod
    import { useState } from "react";
    import { Controller, useForm } from "react-hook-form";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { z } from "zod";
    import { Button } from "@/components/ui/button";
    import { Input } from "@/components/ui/input";
    import { Label } from "@/components/ui/label";
    import { Checkbox } from "@/components/ui/checkbox";
    import {
      Dialog,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
      DialogTrigger,
    } from "@/components/ui/dialog";
    
    const schema = z.object({
      title: z.string().trim().min(3, "Judul minimal 3 karakter"),
      penting: z.boolean(),
    });
    
    export function AddTaskDialog({ onCreate }) {
      const [open, setOpen] = useState(false);
      const {
        register,
        handleSubmit,
        control,
        reset,
        formState: { errors, isSubmitting },
      } = useForm({ resolver: zodResolver(schema), defaultValues: { title: "", penting: false } });
    
      async function onValid(data) {
        await onCreate(data); // misalnya mutateAsync dari TanStack Query
        reset();
        setOpen(false); // tutup dialog setelah sukses
      }
    
      return (
        <Dialog
          open={open}
          onOpenChange={(o) => {
            setOpen(o);
            if (!o) reset(); // bersihkan form saat dialog ditutup
          }}
        >
          <DialogTrigger asChild>
            <Button>+ Tugas baru</Button>
          </DialogTrigger>
          <DialogContent>
            <form onSubmit={handleSubmit(onValid)} noValidate className="space-y-4">
              <DialogHeader>
                <DialogTitle>Tambah tugas</DialogTitle>
                <DialogDescription>Tulis tugas yang ingin kamu selesaikan.</DialogDescription>
              </DialogHeader>
    
              <div className="space-y-2">
                <Label htmlFor="title">Judul</Label>
                {/* Input shadcn meneruskan ref & props ke <input>, jadi register langsung bisa */}
                <Input id="title" aria-invalid={!!errors.title} {...register("title")} />
                {errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
              </div>
    
              <div className="flex items-center gap-2">
                {/* Checkbox shadcn adalah komponen controlled → pakai Controller */}
                <Controller
                  name="penting"
                  control={control}
                  render={({ field }) => (
                    <Checkbox
                      id="penting"
                      checked={field.value}
                      onCheckedChange={(v) => field.onChange(v === true)}
                    />
                  )}
                />
                <Label htmlFor="penting">Tandai penting</Label>
              </div>
    
              <DialogFooter>
                <Button type="button" variant="outline" onClick={() => setOpen(false)}>
                  Batal
                </Button>
                <Button type="submit" disabled={isSubmitting}>
                  {isSubmitting ? "Menyimpan..." : "Simpan"}
                </Button>
              </DialogFooter>
            </form>
          </DialogContent>
        </Dialog>
      );
    }
    // → Klik "+ Tugas baru": dialog terbuka, fokus otomatis masuk ke dalam dialog.
    // → Tekan Escape atau klik di luar: dialog tertutup dan form dibersihkan.
    // → Submit valid: onCreate({ title: "Rapat tim", penting: true }) dipanggil lalu dialog tertutup.
    

    Kesalahan umum

    Latihan

    1. Mudah, Tambahkan komponen card dengan npx shadcn@latest add card, lalu tampilkan satu tugas di dalam Card dengan CardHeader dan CardTitle.
    2. Sedang, Buat ConfirmDeleteDialog yang menerima onConfirm. Tombol pemicunya memakai variant="destructive", dan dialog menampilkan pertanyaan "Hapus tugas ini?".
    3. Menantang, Tambahkan varian tombol baru success (hijau) tanpa mengubah file button.jsx, dengan membuat komponen pembungkus yang memakai fungsi cn.
    Lihat solusi
    // 1.
    import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
    
    export function TaskCard({ task }) {
      return (
        <Card>
          <CardHeader>
            <CardTitle>{task.title}</CardTitle>
          </CardHeader>
          <CardContent>{task.completed ? "Selesai" : "Belum selesai"}</CardContent>
        </Card>
      );
    }
    
    // 2.
    import { useState } from "react";
    import { Button } from "@/components/ui/button";
    import {
      Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
    } from "@/components/ui/dialog";
    
    export function ConfirmDeleteDialog({ onConfirm }) {
      const [open, setOpen] = useState(false);
      return (
        <Dialog open={open} onOpenChange={setOpen}>
          <DialogTrigger asChild>
            <Button variant="destructive" size="sm">Hapus</Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Hapus tugas ini?</DialogTitle>
              <DialogDescription>Tindakan ini tidak bisa dibatalkan.</DialogDescription>
            </DialogHeader>
            <DialogFooter>
              <Button variant="outline" onClick={() => setOpen(false)}>Batal</Button>
              <Button
                variant="destructive"
                onClick={() => {
                  onConfirm();
                  setOpen(false);
                }}
              >
                Ya, hapus
              </Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      );
    }
    
    // 3. Pembungkus tanpa mengubah button.jsx
    import { Button } from "@/components/ui/button";
    import { cn } from "@/lib/utils";
    
    export function SuccessButton({ className, ...props }) {
      return (
        <Button
          className={cn("bg-emerald-600 text-white hover:bg-emerald-700", className)}
          {...props}
        />
      );
    }
    // <SuccessButton onClick={...}>Tandai selesai</SuccessButton>
    

    Ringkasan

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.