Heading Page
Setiap screen Panel mengirim tiga bagian teks: title untuk browser tab, heading untuk <h1> di atas content, dan subheading opsional di bawahnya. Ketiganya dipisahkan karena menjawab kebutuhan berbeda — edit page dapat memiliki title Edit Ada Lovelace di tab tetapi heading Ada Lovelace pada screen, karena breadcrumb di atas heading sudah menjelaskan Page apa yang sedang dibuka.
Standalone Page dan Resource Page sama-sama memiliki ketiganya, tetapi mekanismenya sedikit berbeda: Page mendeklarasikannya sebagai static property, sedangkan ResourcePage me-resolve-nya melalui method yang dapat menerima record.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Pages;
use PandaPanel\Pages\Page;
final class Settings extends Page
{
protected static ?string $title = 'Settings';
protected static ?string $subheading = 'Application-wide configuration.';
}2
3
4
5
6
7
8
9
10
11
12
13
14
Settings::title(); // 'Settings'
Settings::heading(); // 'Settings' — follows the title2
Browser tab menampilkan Settings, heading Page juga Settings, dan Application-wide configuration. muncul di bawahnya dengan muted text.
Pada standalone Page
protected static ?string $title = null;
protected static ?string $heading = null;
protected static ?string $subheading = null;
public static function title(): string;
public static function heading(): string;2
3
4
5
6
| Bagian | Dideklarasikan sebagai | Fallback |
|---|---|---|
title | $title | Str::headline(class_basename(static::class)) |
heading | $heading | title() |
subheading | $subheading | null |
Karena itu Page bernama AuditLog yang tidak mendeklarasikan apa pun akan memiliki title dan heading Audit Log.
use PandaPanel\Pages\Page;
final class AuditLog extends Page
{
protected static ?string $title = 'Audit log';
// Separate on screen: the tab says what the page is, the heading says
// what is on it.
protected static ?string $heading = 'Recent activity';
protected static ?string $subheading = 'Everything written in the last 30 days.';
}2
3
4
5
6
7
8
9
10
11
12
Tersedia accessor title() dan heading(), tetapi tidak ada subheading(). Subheading yang bergantung pada runtime state harus ditetapkan dengan meng-override metadata():
/**
* @return array<string, mixed>
*/
protected function metadata(): array
{
return [
...parent::metadata(),
'subheading' => 'Last run '.$this->lastRunAt()->diffForHumans(),
];
}2
3
4
5
6
7
8
9
10
title() dan heading() bersifat static dan dapat di-override dengan pola yang sama:
public static function title(): string
{
return 'Audit log — '.now()->year;
}2
3
4
$navigationLabel fallback ke title(), sehingga meng-override title juga mengganti nama sidebar entry kecuali Page mendeklarasikan label sendiri. Lihat Navigation groups.
Pada Resource Page
PandaPanel\Resources\Pages\ResourcePage mendeklarasikan tiga property yang sama dan me-resolve-nya melalui method yang menerima record:
public function getTitle(?Model $record = null): string;
public function getHeading(?Model $record = null): string;
public function getSubheading(?Model $record = null): ?string;
protected function defaultTitle(?Model $record): string;
protected function defaultHeading(?Model $record): string;
protected function defaultSubheading(?Model $record): ?string;
/** @return array{title: string, heading: string, subheading: string|null} */
protected function headingMetadata(?Model $record = null): array;2
3
4
5
6
7
8
9
10
Static property yang dideklarasikan selalu menang. Jika tidak ada, default milik Page yang digunakan.
| Page | title | heading | subheading |
|---|---|---|---|
ListRecords | Resource::pluralLabel() | mengikuti title | null |
CreateRecord | 'New '.Resource::label() | mengikuti title | null |
ViewRecord | Resource::recordTitle($record) | mengikuti title | Resource::label() |
EditRecord | 'Edit '.recordTitle($record) | recordTitle($record) | 'Edit '.Resource::label() |
ManageRelatedRecords | RelationManager::title() | mengikuti title | recordTitle($owner) |
Jika dibaca sebagai teks untuk user bernama Ada Lovelace pada UserResource:
(new ListUsers) => ['Users', 'Users', null]
(new CreateUser) => ['New User', 'New User', null]
(new ViewUser) => ['Ada Lovelace', 'Ada Lovelace', 'User']
(new EditUser) => ['Edit Ada Lovelace','Ada Lovelace', 'Edit User']2
3
4
Edit Page sengaja memisahkan title dan heading. Mengulangi verb "Edit" pada breadcrumb, heading, dan tab terlihat berlebihan; karena itu heading menunjukkan record, sedangkan browser tab membawa verb.
Mendeklarasikan nilainya
use PandaPanel\Resources\Pages\ListRecords;
final class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected static ?string $title = 'Team directory';
protected static ?string $subheading = 'Everyone with an account.';
}2
3
4
5
6
7
8
9
10
heading tidak dideklarasikan, sehingga mengikuti title, bukan Resource label: Team directory tampil di browser tab dan pada screen.
Menghitung berdasarkan record
Override method, bukan property, ketika teks bergantung pada data yang tidak dapat diwakili static property:
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Resources\Pages\EditRecord;
final class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected static ?string $heading = 'Account';
public function getSubheading(?Model $record = null): ?string
{
return $record === null
? null
: 'Editing '.$record->getAttribute('email');
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Ketiga method dapat dipanggil dengan null pada Page tanpa record — misalnya create Page, atau Page yang headings-nya di-resolve di luar request. Kode yang langsung dereference $record tanpa guard akan gagal. Karena itu setiap built-in default yang menyentuh record selalu memulai dengan pola $record === null ? … : …: ViewRecord::defaultTitle(), EditRecord::defaultTitle() dan defaultHeading(), serta ManageRelatedRecords::defaultSubheading(). Method yang tidak menyentuh record tetap menerima parameter tetapi mengabaikannya.
$page = new EditUser;
$page->getHeading(); // 'Account'
$page->getSubheading(); // null2
3
4
Data yang dikirim ke frontend
Ketiganya berada di prop page, berdampingan dengan breadcrumb dan metadata lain:
[
'title' => 'Settings',
'heading' => 'Settings',
'subheading' => 'Application-wide configuration.',
'breadcrumbs' => [/* … */],
'headerActions' => [],
'scope' => 'page:settings',
'cluster' => null,
]2
3
4
5
6
7
8
9
Mirror TypeScript-nya adalah PageMetadata di resources/js/panel/types/page.ts, dinormalisasi oleh normalizePageMetadata(). title yang hilang fallback ke heading daripada melempar error karena shape mismatch seharusnya terdegradasi menjadi Page sederhana, bukan merusak layout.
Rendering
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PageHeader from '@/panel/components/PageHeader.vue';
import type { PageMetadata } from '@/panel/types/page';
defineProps<{ page: PageMetadata }>();
</script>
<template>
<Head :title="page.title" />
<PageHeader :heading="page.heading" :subheading="page.subheading" />
</template>2
3
4
5
6
7
8
9
10
11
12
13
PageHeader.vue milik PandaBear menerima tepat dua prop:
defineProps<{
heading: string;
subheading?: string | null;
}>();2
3
4
Heading dirender dengan text-xl, bukan text-2xl. Alasannya breadcrumb di atas sudah menjelaskan posisi user, sehingga heading berperan sebagai label Page, bukan hero title. Ruang vertikal yang tidak dipakai heading berarti lebih banyak row data dapat terlihat di screen.
Slot #actions di sisi kanan adalah tempat header action dirender:
<PageHeader :heading="page.heading" :subheading="page.subheading">
<template #actions>
<ActionButton
v-for="action in page.headerActions as ActionDefinition[]"
:key="action.name"
:action="action"
size="default"
/>
</template>
</PageHeader>2
3
4
5
6
7
8
9
10
Kedua built-in renderer sudah menggunakan pola ini: panel/Page merender header action pada slot tersebut, sedangkan panel/Dashboard merender heading tanpa action.
Header action pada standalone Page
/** @return list<array<string, mixed>> */
public function headerActions(): array;2
Berupa plain array dengan shape yang mengikuti ActionDefinition di frontend:
use PandaPanel\Actions\Enums\ActionVariant;
use PandaPanel\Pages\Settings\ProfileSettings;
/**
* @return list<array<string, mixed>>
*/
public function headerActions(): array
{
return [[
'name' => 'edit-profile',
'label' => 'Edit profile',
'icon' => 'settings',
'variant' => ActionVariant::Default->value,
'type' => 'link',
'url' => ProfileSettings::url($this->panel()),
'confirmation' => null,
]];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Pertahankan action tersebut sebagai link. ActionButton merender action bertipe link sebagai anchor, sedangkan type lain dirender sebagai button yang mengemit run; generic page renderer tidak mendengarkan event tersebut. Lihat Actions.
Gotchas
headingmengikutititle, bukan label. Mendeklarasikan hanya$titlepada Resource Page juga mengubah heading di screen. Deklarasikan$headingjika keduanya harus berbeda.- Tidak ada accessor
subheading()padaPage. Teks static berada di$subheading; value yang dihitung ditempatkan melaluimetadata(). - Default title menggunakan
Str::headline(), bukan class name mentah.AuditLogmenjadiAudit Log, sedangkanAPIKeysmenjadiA P I Keys. Deklarasikan$titleuntuk acronym. ViewRecorddanEditRecordmemanggilResource::recordTitle(). Method tersebut membaca$recordTitleAttribute, default kename, lalu fallback ke primary key ketika attribute hilang atau bukan scalar. Page yang heading-nya berupa id biasanya berarti Resource belum mendefinisikan title attribute yang dapat digunakan.- Browser tab title adalah milik Page, bukan milik Panel. Brand name Panel muncul di shell, bukan di
<Head>; Page yang ingin menampilkan keduanya harus merender<Head :title="…">sendiri.