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/:
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:
{{-- 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:
<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:
{{-- 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:
{{-- 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:
{{-- 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:
<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:
| Component | Prop | Tipe | Default | Keterangan |
|---|---|---|---|---|
x-ui.button | variant | string | primary | primary, secondary, danger |
x-ui.button | type | string | button | Atribut type HTML |
x-ui.badge | tone | string | netral | netral, sukses, peringatan, bahaya |
x-ui.card | heading | string | null | Judul di header kartu |
x-ui.modal | judul | string | null | Judul modal |
x-ui.modal | pemicu | string | Buka Modal | Teks 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:
<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:
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:
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 .