Form Layouts
Layout mengatur susunan form tanpa mengubah makna field di dalamnya. Layout dapat mengelompokkan field, membagi row menjadi beberapa column, memecah form panjang menjadi tabs atau steps, serta menempatkan catatan di posisi yang tepat. Gunakan layout segera setelah form mulai lebih panjang dari beberapa input. Prinsip yang membuat field aman dipindahkan antar-layout adalah: layout tidak memengaruhi validation maupun persistence. Field tetap memiliki behavior yang sama di mana pun ditempatkan.
Contoh minimal
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Forms\Layouts\Section;
public static function form(FormSchema $schema): FormSchema
{
return $schema
->columns(2)
->schema([
Section::make('Details')
->description('Shown on the public page.')
->columns(2)
->schema([
TextInput::make('title')->required(),
TextInput::make('slug'),
Textarea::make('excerpt')->columnSpanFull(),
]),
]);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Columns dan spans
Container dibagi menggunakan columns(). Field menentukan berapa bagian dari pembagian tersebut yang dipakai melalui columnSpan() atau columnSpanFull().
Jumlah column di-clamp ke 1–4 oleh PandaPanel\Support\ColumnCount::clamp(). Renderer memiliki literal Tailwind classes untuk satu sampai empat column; class dinamis seperti grid-cols-${n} tidak dapat dijamin masuk hasil compile. Layout juga responsive:
columns(n) | base | md (768px) | lg (1024px) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1 | 2 | 2 |
| 3 | 1 | 2 | 3 |
| 4 | 1 | 2 | 4 |
Span di-clamp terhadap tabel tersebut pada setiap breakpoint. Karena itu columnSpan(3) di dalam columns(4) menjadi dua column pada md dan tiga column pada lg. columnSpanFull() dikirim ke frontend sebagai string 'full' lalu dirender sebagai col-span-full, yaitu memenuhi seluruh row pada semua ukuran.
Span merupakan properti field dan infolist entry. Layout sendiri sudah menempati full row di lokasi tempat ia ditempatkan. Memanggil span pada schema menghasilkan BadMethodCallException yang menyebutkan kesalahan tersebut secara eksplisit.
Section
Kelompok field dengan heading.
| Method | Signature | Default |
|---|---|---|
make() | static make(string $heading): self | |
schema() | schema(array<array-key, FormComponent> $components): self | [] |
description() | description(string $description): self | null |
columns() | columns(int $columns): self | 1 |
collapsible() | collapsible(bool $collapsible = true): self | false |
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\Section;
Section::make('Security')
->description('Leave the password blank to keep the current one.')
->collapsible()
->columns(2)
->schema([TextInput::make('password')]);2
3
4
5
6
7
8
Grid
Grid tanpa heading untuk mengatur field ke dalam beberapa column tanpa menambahkan section title.
| Method | Signature | Default |
|---|---|---|
make() | static make(int $columns = 2): self | 2, di-clamp 1–4 |
schema() | schema(array<array-key, FormComponent> $components): self | [] |
use PandaPanel\Forms\Components\DatePicker;
use PandaPanel\Forms\Layouts\Grid;
Grid::make(3)->schema([
DatePicker::make('starts_at'),
DatePicker::make('ends_at'),
DatePicker::make('reviewed_at'),
]);2
3
4
5
6
7
8
Jumlah column ditentukan ketika object dibuat. Grid tidak memiliki setter columns().
Tabs dan Tab
Form dapat dibagi menjadi beberapa tab. Semua field pada semua tab tetap divalidasi ketika form disubmit, terlepas dari tab mana yang sedang terbuka. Karena itu frontend dapat otomatis membuka tab yang berisi field dengan validation error.
| Class | Method | Signature | Default |
|---|---|---|---|
Tabs | make() | static make(array<array-key, Tab> $tabs = []): self | [] |
Tabs | tabs() | tabs(array<array-key, Tab> $tabs): self | |
Tabs | persistTab() | persistTab(bool $persist = true): self | false |
Tab | make() | static make(string $label): self | |
Tab | schema() | schema(array<array-key, FormComponent> $components): self | [] |
Tab | icon() | icon(string $icon): self | null |
Tab | badge() | badge(string $badge): self | null |
Tab | columns() | columns(int $columns): self | 1 |
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\Tab;
use PandaPanel\Forms\Layouts\Tabs;
Tabs::make([
Tab::make('Details')->schema([TextInput::make('name')]),
Tab::make('Security')->icon('shield')->badge('2')->schema([
TextInput::make('password'),
]),
])->persistTab();2
3
4
5
6
7
8
9
10
Setiap Tab diserialisasi dengan key hasil Str::slug($label) serta daftar nama field yang dimilikinya. Frontend dapat menggunakan informasi ini untuk membuka tab yang benar ketika server mengembalikan validation error tanpa perlu mengetahui detail struktur layout. persistTab() menyimpan tab yang aktif melalui URL sehingga tetap terbuka setelah reload.
Icon menggunakan registry key, bukan path. Lihat Icons.
Wizard dan Step
Form dapat dibagi menjadi beberapa step. Wizard hanya mengubah presentation: validation tetap utuh dan dilakukan di server. Jika submit gagal, frontend berpindah ke step pertama yang memiliki rejected field.
| Class | Method | Signature | Default |
|---|---|---|---|
Wizard | make() | static make(array<array-key, Step> $steps = []): self | [] |
Wizard | steps() | steps(array<array-key, Step> $steps): self | |
Wizard | submitLabel() | submitLabel(string $submitLabel): self | 'Submit' |
Wizard | countSteps() | countSteps(): int | |
Wizard | fieldNamesForStep() | fieldNamesForStep(int $step): list<string> | |
Step | make() | static make(string $label): self | |
Step | schema() | schema(array<array-key, FormComponent> $components): self | [] |
Step | description() | description(string $description): self | null |
Step | icon() | icon(?string $icon): self | null |
Step | columns() | columns(int $columns): self | 1 |
use PandaPanel\Forms\Components\PasswordInput;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\Step;
use PandaPanel\Forms\Layouts\Wizard;
Wizard::make([
Step::make('Identity')
->description('Who they are')
->icon('user')
->schema([
TextInput::make('name')->required(),
TextInput::make('email')->email()->required(),
]),
Step::make('Access')->schema([
PasswordInput::make('password')->confirmed()->required(),
]),
])->submitLabel('Create user');2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Sebuah Wizard harus memiliki seluruh form atau tidak sama sekali. FormSchema::wizard() mengembalikan Wizard pertama pada top-level components, dan frontend menyerahkan rendering form kepada Wizard hanya ketika Wizard tersebut merupakan satu-satunya top-level node. Form yang hanya sebagian berada di dalam Wizard tidak memiliki jawaban konsisten terhadap pertanyaan “field ini berada di step mana?”.
Perpindahan antar-step dapat divalidasi tanpa mensubmit seluruh form. Lihat Wizard steps untuk endpoint, validationRulesForStep(), serta cara confirmation field dikelompokkan bersama password-nya.
Callout
Catatan di tengah form. Callout adalah content, bukan control. Secara default tidak memiliki field sendiri dan tidak mempersist apa pun.
| Method | Signature | Default |
|---|---|---|
make() | static make(string $body): self | |
tone() | tone(CalloutTone $tone): self | CalloutTone::Info |
heading() | heading(string $heading): self | null |
icon() | icon(string $icon): self | icon default berdasarkan tone |
schema() | schema(array<array-key, FormComponent> $components): self | [] |
use PandaPanel\Forms\Components\Checkbox;
use PandaPanel\Forms\Enums\CalloutTone;
use PandaPanel\Forms\Layouts\Callout;
Callout::make('Publishing sends an email to every subscriber.')
->heading('This is not reversible')
->tone(CalloutTone::Warning)
->schema([Checkbox::make('acknowledged')->required()]);2
3
4
5
6
7
8
PandaPanel\Forms\Enums\CalloutTone adalah closed set dan setiap case memiliki default icon:
| Case | Value | Icon |
|---|---|---|
Info | info | info |
Success | success | check |
Warning | warning | triangle-alert |
Danger | danger | circle-alert |
Kemampuan membungkus components membuat Callout lebih dari sekadar paragraf. Warning dapat ditempatkan bersama field yang berkaitan dengannya. Field di dalam Callout tetap divalidasi dan dipersist sama seperti field di layout lain.
EmptyState
Placeholder untuk bagian schema yang belum memiliki sesuatu untuk ditampilkan, misalnya relation tanpa record atau step yang baru berlaku setelah kondisi tertentu tersedia.
| Method | Signature | Default |
|---|---|---|
make() | static make(string $heading): self | |
description() | description(string $description): self | null |
icon() | icon(string $icon): self | null |
use PandaPanel\Forms\Layouts\EmptyState;
EmptyState::make('No invoices yet')
->description('They appear here once the first order is paid.')
->icon('receipt');2
3
4
5
EmptyState tidak memiliki dan tidak menerima field. Menampilkan alasan mengapa sebuah area kosong biasanya lebih jelas daripada hanya meninggalkan ruang kosong yang harus ditebak oleh user.
CustomComponent
Layout yang dirender oleh Vue component buatan Anda sendiri dan tetap dapat menampung ordinary fields.
| Method | Signature | Default |
|---|---|---|
make() | static make(string $component): self | |
schema() | schema(array<array-key, FormComponent> $components): self | [] |
config() | config(array<string, mixed> $config): self | [] |
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\CustomComponent;
CustomComponent::make('Panels/Admin/Schemas/Banner')
->config(['dismissible' => true])
->schema([TextInput::make('name')]);2
3
4
5
6
Nama component adalah registry key yang ditentukan pada build time, bukan markup dan bukan path bebas. Jika nama tidak terdaftar, child fields tetap dirender karena wrapper hanya presentation sementara field di dalamnya tetap merupakan form. Lihat Custom fields.
Relationship
Kelompok field yang dimiliki related record, diberi namespace berdasarkan nama relation, dan ditulis setelah owner record tersedia. Detail lengkap ada pada Relationship forms.
Data yang melintasi wire
Setiap layout diserialisasi dengan discriminator component yang digunakan frontend untuk memilih renderer:
| Class | component | Nested di bawah |
|---|---|---|
Section | section | schema |
Grid | grid | schema |
Tabs | tabs | tabs |
Tab | tab | schema |
Wizard | wizard | steps |
Step | step | schema |
Callout | callout | schema |
EmptyState | empty-state | — |
CustomComponent | custom | schema |
Relationship | relationship | schema |
| Fields | field | — |
| Prime components | prime-text, prime-icon, prime-image | — |
Membuat layout sendiri
Extend PandaPanel\Forms\Components\FormComponent dan implementasikan method berikut:
abstract public function fields(): array; // list<Field>, recursive
abstract public function toArray(?Model $record, string $page): ?array;
public function children(): array; // list<FormComponent>, optional2
3
fields() membuat validation, hydration, dan dehydration dapat “melihat menembus” layout. Kembalikan seluruh field yang dimiliki children, atau [] jika layout hanya berisi content. children() penting ketika Relationship mungkin berada di dalam custom layout, karena schema dapat menemukannya tanpa harus mengetahui semua layout type.
Frontend hanya mengenali closed set dari nilai component. Karena itu PHP layout type baru tetap membutuhkan renderer Vue yang sesuai. Untuk sebagian besar use case application, CustomComponent sudah menyediakan extension point tersebut tanpa menambah type framework baru.
Catatan
- Container selalu dirender. Field dapat hilang dari Page, tetapi container-nya tetap tampil sebagai empty container sampai Anda sendiri menghapus container tersebut.
- Hidden field benar-benar keluar dari layout. Field tidak ada di
schemadan tidak memiliki rules. Pengecualian adalah daftarfieldsmilik Tab dan Step: daftar tersebut tetap menyebut semua field, termasuk yang hidden, karena tujuannya hanya membantu frontend menemukan tab/step tempat validation error berada. Grid::make()melakukan clamp danGridtidak memiliki setter. Container lain melakukan clamp melaluicolumns().- Tab key berupa slug. Dua label yang menghasilkan slug sama akan memiliki key yang sama.
- Satu Wizard per form.
FormSchema::wizard()mengembalikan Wizard pertama dan frontend hanya menggunakannya jika Wizard menjadi satu-satunya top-level node. - Rules per-step pada Wizard diturunkan dari struktur Step, bukan dideklarasikan ulang. Step sudah mengetahui field yang dimilikinya; definisi rules kedua hanya berpotensi berbeda dan menyebabkan inkonsistensi.
- Layout tidak mengubah write. Hasil
dehydrate()dari form berbasis Step sama dengan form flat yang memiliki field setara.