Tailwind memberi kita bata — utility satu layanan per class. Tapi halaman nyata butuh bata yang tersusun jadi dinding: tombol yang selalu tampak seperti tombol, kartu yang konsisten di semua halaman, badge yang warnanya bermakna. Pada chapter ini kita menyusun bata-bata itu menjadi UI kit kecil berbasis component Blade — empat component dengan kontrak properti yang jelas dan dokumentasi ala library. Dasar teorinya (props, slot, $attributes) sudah kita kuasai di chapter Components dan Slots dan Attributes ; fokus sekarang pada perancangan kit yang enak dipakai bertahun-tahun.

Struktur Folder dan Konvensi Nama #

Semua component kit hidup di resources/views/components/ui/:

text
resources/views/components/ui/
├── button.blade.php   → <x-ui.button>
├── card.blade.php     → <x-ui.card>
├── badge.blade.php    → <x-ui.badge>
└── modal.blade.php    → <x-ui.modal>

Subfolder menjadi bagian nama: ui/button.blade.php dipanggil <x-ui.button>. Prefix ui.* ini konvensi lazim untuk kit internal — nanti kita bisa menambah x-form.input atau x-table.* tanpa bentrok nama dengan component domain seperti <x-produk-kartu>. Gaya penulisan kitnya sendiri tetap sama seperti yang kita pakai di chapter UI yang Reusable : @props untuk kontrak, $attributes->merge() untuk atribut HTML, $slot untuk isi. Bedanya, kit kali ini kita tulis dengan Tailwind langsung di dalam component.

x-ui.button: Variant sebagai Kontrak Utama #

Tombol adalah component yang paling sering dipakai ulang, sekaligus contoh terbaik pola variant — prop string yang dipetakan menjadi kelas Tailwind:

blade
{{-- resources/views/components/ui/button.blade.php --}}
@props([
    'variant' => 'primary',
    'type' => 'button',
])

@php
    $variantClass = [
        'primary' => 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500',
        'secondary' => 'bg-white text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 focus:ring-slate-400',
        'danger' => 'bg-rose-600 text-white hover:bg-rose-700 focus:ring-rose-500',
    ][$variant] ?? 'bg-slate-200 text-slate-700';
@endphp

<button
    type="{{ $type }}"
    {{ $attributes->merge(['class' => 'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 '.$variantClass]) }}
>
    {{ $slot }}
</button>

Array di blok @php adalah satu-satunya tempat definisi variant hidup. Halaman hanya menulis variant="danger" dan tidak pernah menyentuh class Tailwind mentah — mengganti warna brand berarti mengubah satu berkas ini saja. Operator ?? menjaga component tetap hidup saat caller salah menulis nama variant. Berikut hasil render nyata <x-ui.button>Simpan</x-ui.button> di sandbox:

text
<button
    type="button"
    class="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500"
>
    Simpan
</button>

Class dasar dan class variant menyatu lewat $attributes->merge(), dan type bawaan button mencegah kecelakaan submit yang terjadi kalau tombol lupa menulis type di dalam <form>.

x-ui.badge dan x-ui.card: Dua Pola Berbeda #

Badge memakai pola yang sama persis dengan tombol, hanya saja prop-nya bernama tone dan hasilnya elemen inline:

blade
{{-- resources/views/components/ui/badge.blade.php --}}
@props(['tone' => 'netral'])

@php
    $toneClass = [
        'netral' => 'bg-slate-100 text-slate-700',
        'sukses' => 'bg-emerald-100 text-emerald-800',
        'peringatan' => 'bg-amber-100 text-amber-800',
        'bahaya' => 'bg-rose-100 text-rose-800',
    ][$tone] ?? 'bg-slate-100 text-slate-700';
@endphp

<span {{ $attributes->merge(['class' => 'inline-block rounded-full px-2.5 py-0.5 text-xs font-medium '.$toneClass]) }}>
    {{ $slot }}
</span>

Kartu memilih pola kedua: composition murni. Component tidak tahu apa isi card-body-nya — kerangka saja yang diberikan:

blade
{{-- resources/views/components/ui/card.blade.php --}}
@props(['heading' => null])

<div {{ $attributes->merge(['class' => 'rounded-xl bg-white shadow-sm ring-1 ring-slate-200']) }}>
    @isset($heading)
        <div class="border-b border-slate-100 px-5 py-3 font-semibold text-slate-900">{{ $heading }}</div>
    @endisset
    <div class="px-5 py-4">
        {{ $slot }}
    </div>
</div>

x-ui.modal: Pratinjau Interaktivitas Alpine #

Modal adalah component pertama kit kita yang bergerak. Pergerakannya kita percayakan pada Alpine.js — cukup beberapa directive, tanpa build step tambahan:

blade
{{-- resources/views/components/ui/modal.blade.php --}}
@props([
    'judul' => null,
    'pemicu' => 'Buka Modal',
])

<div x-data="{ terbuka: false }" {{ $attributes }}>
    <x-ui.button x-on:click="terbuka = true">{{ $pemicu }}</x-ui.button>

    <div
        x-show="terbuka"
        class="fixed inset-0 z-50 flex items-center justify-center p-4"
        style="display: none"
    >
        <div class="fixed inset-0 bg-slate-900/50" x-on:click="terbuka = false"></div>

        <div class="relative w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
            @isset($judul)
                <h3 class="text-lg font-semibold text-slate-900">{{ $judul }}</h3>
            @endisset

            <div class="mt-2 text-sm text-slate-600">{{ $slot }}</div>

            <div class="mt-5 flex justify-end gap-3">
                <x-ui.button variant="secondary" x-on:click="terbuka = false">Tutup</x-ui.button>
            </div>
        </div>
    </div>
</div>

State terbuka hidup di x-data, tombol pemicu membaliknya lewat x-on:click, dan x-show menampilkan overlay saat nilainya true. Atribut style="display: none" menjaga modal tersembunyi pada HTML awal — sebelum JavaScript sempat jalan. Render awalnya di sandbox:

text
<div x-data="{ terbuka: false }" >
    <button
    type="button"
    class="inline-flex items-center rounded-lg ... bg-indigo-600 text-white ..." x-on:click="terbuka = true"
>
    Buka Modal
</button>

Cara kerja directive Alpine ini kita bedah tuntas di chapter Alpine.js Integration . Untuk sekarang, catat saja hasilnya: modal ter-render server-side dalam kondisi tertutup, lalu terbuka di browser tanpa request tambahan.

Dokumentasi API Kit #

Kit yang baik punya kontrak yang bisa dibaca sekali pandang — persis seperti dokumentasi library:

ComponentPropTipeDefaultKeterangan
x-ui.buttonvariantstringprimaryprimary, secondary, danger
x-ui.buttontypestringbuttonAtribut type HTML
x-ui.badgetonestringnetralnetral, sukses, peringatan, bahaya
x-ui.cardheadingstringnullJudul di header kartu
x-ui.modaljudulstringnullJudul modal
x-ui.modalpemicustringBuka ModalTeks tombol pembuka

Semua component juga mewarisi atribut HTML lewat $attributes — <x-ui.button form="tugas-baru"> atau <x-ui.card id="panel-utama"> bekerja tanpa kode tambahan.

Halaman Rakitan #

Ujian kit adalah halaman nyata. resources/views/kit-ui.blade.php merangkai keempatnya — kartu menampung daftar tugas berbadge, tiga variant tombol berdampingan, modal di ekor halaman:

blade
<main class="mx-auto max-w-3xl space-y-6">
    <x-ui.card heading="Daftar Tugas Budi">
        <ul class="divide-y divide-slate-100">
            @foreach ($tugas as $t)
                <li class="flex items-center justify-between py-2">
                    <span class="text-sm text-slate-800">{{ $t['judul'] }}</span>
                    <x-ui.badge :tone="$t['tone']">{{ $t['status'] }}</x-ui.badge>
                </li>
            @endforeach
        </ul>

        <div class="mt-4 flex items-center gap-3">
            <x-ui.button>Simpan</x-ui.button>
            <x-ui.button variant="secondary">Batal</x-ui.button>
            <x-ui.button variant="danger">Hapus</x-ui.button>
        </div>
    </x-ui.card>

    <x-ui.modal judul="Hapus semua tugas?">
        Tindakan ini tidak bisa dibatalkan. Semua tugas Budi akan hilang permanen.
    </x-ui.modal>
</main>

Perhatikan :tone="$t['tone']" — data dari route mengalir ke prop component, sehingga status "Terlambat" otomatis tampil sebagai badge merah tanpa @if di halaman. Feature test memeriksa seluruh kontrak dari luar:

php
public function test_kit_ui_merender_variant_dan_modal(): void
{
    $response = $this->get('/kit-ui');
    $html = $response->getContent();

    $this->assertStringContainsString('bg-indigo-600 text-white', $html);
    $this->assertStringContainsString('bg-rose-600 text-white', $html);
    $this->assertStringContainsString('bg-amber-100 text-amber-800', $html);
    $this->assertStringContainsString('x-data="{ terbuka: false }"', $html);
    $this->assertStringContainsString('x-show="terbuka"', $html);
}

Output yang Dihasilkan:

text
  PASS  Tests\Feature\KitUiTest
  ✓ kit ui merender variant dan modal                          0.18s

  Tests:    1 passed (9 assertions)

Kit ini cukup untuk membangun lusinan halaman konsisten. Satu kebutuhan praktis belum kita sentuh: form. Bagaimana tombol Simpan kita berperilaku baik saat data belum valid, dan bagaimana memberi umpan balik cepat tanpa menunggu server — itu bahasan chapter Frontend Form Validation .