Custom Shell Components
Sebuah panel dapat mengganti sidebar atau top bar bawaan shell dengan component buatan Anda sendiri. Gunakan fitur ini ketika navigation memang perlu digambar dengan cara berbeda — misalnya rail dua tingkat, tenant picker yang menyatu dengan brand block, atau bar dengan product switcher — dan render hook tidak cukup karena Anda perlu mengganti component, bukan sekadar menambahkan sesuatu ke dalamnya.
Replacement component menerima sumber navigation yang sama dengan component bawaan. Artinya Anda hanya mengganti cara menggambar panel, bukan membuat sumber kebenaran kedua untuk navigation.
Contoh minimal yang berfungsi
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('admin')
->auth()
->sidebarComponent('Panels/Admin/Shell/Sidebar');
}
}2
3
4
5
6
7
8
9
10
11
12
13
<!-- resources/js/pages/Panels/Admin/Shell/Sidebar.vue -->
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { useNavigation } from '@/panel/composables/useNavigation';
import { usePanel } from '@/panel/composables/usePanel';
import { usePanelStyling } from '@/panel/composables/usePanelStyling';
import { resolveIcon } from '@/panel/icons/registry';
const { panel } = usePanel();
const { groups } = useNavigation();
const { hook } = usePanelStyling();
</script>
<template>
<aside
class="w-64 shrink-0 border-r bg-sidebar text-sidebar-foreground"
:class="hook('sidebar')"
>
<Link :href="`/${panel?.path}`" class="block p-4 font-semibold">
{{ panel?.brandName }}
</Link>
<nav class="flex flex-col gap-4 p-2">
<div v-for="group in groups" :key="group.label ?? 'root'">
<p
v-if="group.label"
class="px-2 py-1 text-xs text-sidebar-foreground/60"
>
{{ group.label }}
</p>
<Link
v-for="item in group.items"
:key="item.href"
:href="item.href"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
:class="item.active ? 'bg-sidebar-accent font-medium' : ''"
>
<component
:is="resolveIcon(item.icon)"
v-if="resolveIcon(item.icon)"
class="size-4"
/>
{{ item.label }}
</Link>
</div>
</nav>
</aside>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
npm run build # atau: npm run devDua method utama
public function sidebarComponent(?string $component): self
public function topbarComponent(?string $component): self2
$panel
->sidebarComponent('Panels/Admin/Shell/Sidebar')
->topbarComponent('Panels/Admin/Shell/Topbar');
$panel->sidebarComponent(null); // kembali ke rail bawaan2
3
4
5
| Method | Dibaca oleh | Diserialisasi sebagai | Default |
|---|---|---|---|
sidebarComponent | SidebarPanelLayout.vue | panel.sidebar.component | null |
topbarComponent | HeaderPanelLayout.vue | panel.shell.topbarComponent | null |
Keduanya menerima build-time registry key: path di bawah resources/js/pages/, tanpa extension .vue. Bukan markup, bukan filesystem path, dan bukan class name.
Baca kembali value melalui accessor milik panel:
$panel->getSidebar();
// ['collapsible' => true, 'defaultOpen' => true, 'variant' => 'sidebar',
// 'appearance' => 'inset', 'width' => '16rem', 'collapsedWidth' => '3rem',
// 'component' => 'Panels/Admin/Shell/Sidebar']
$panel->getShell();
// ['navigation' => true, 'topbar' => true, 'breadcrumbs' => true,
// 'topbarComponent' => null, 'userMenuItems' => []]2
3
4
5
6
7
8
Shell mana yang membaca replacement mana
sidebar(variant: …) menentukan layout yang dipakai panel, dan masing-masing layout hanya membaca replacement yang relevan dengannya:
| Setting Panel | Layout | sidebarComponent | topbarComponent |
|---|---|---|---|
sidebar(variant: 'sidebar'), default | SidebarPanelLayout | mengganti rail | tidak dibaca |
sidebar(variant: 'header') / topNavigation() | HeaderPanelLayout | tidak dibaca | mengganti navigation bar |
Mengatur replacement yang tidak dibaca current variant bukan error dan tidak menghasilkan warning — value tersebut hanya tidak pernah di-resolve. Jika replacement tidak muncul, periksa variant terlebih dahulu.
Perhatikan juga bar mana yang diganti oleh topbarComponent. HeaderPanelLayout menggambar dua row: navigation bar di bagian paling atas, lalu PanelHeader di bawahnya yang berisi breadcrumbs, search, switchers, notifications, dan theme toggle. topbarComponent mengganti navigation bar. PanelHeader sendiri dikontrol oleh topbar(false), yang menghapusnya sepenuhnya.
Prop yang diterima replacement
| Replacement | Props |
|---|---|
| Sidebar | tidak ada |
| Topbar | groups: NavigationGroup[] |
<!-- resources/js/pages/Panels/Admin/Shell/Topbar.vue -->
<script setup lang="ts">
import type { NavigationGroup } from '@/panel/types/navigation';
defineProps<{ groups: NavigationGroup[] }>();
</script>2
3
4
5
6
Sidebar tidak menerima prop karena tidak membutuhkannya. Semua data yang digambar built-in rail sudah tersedia melalui shared props, dan composable adalah integration seam yang lebih stabil daripada daftar props yang harus terus bertambah setiap kali shell mendapatkan capability baru.
import { useNavigation } from '@/panel/composables/useNavigation';
import { usePanel } from '@/panel/composables/usePanel';
import { usePanelShell } from '@/panel/composables/usePanelShell';
import { usePanelStyling } from '@/panel/composables/usePanelStyling';
const { groups, items, activeItem, isCollapsed, toggle } = useNavigation();
const { panel, shell, panels, canSwitchPanels, notifications, search, tenancy } =
usePanel();
const { reloadNavigation } = usePanelShell();
const { hook } = usePanelStyling();2
3
4
5
6
7
8
9
10
| Composable | Data/capability yang diberikan |
|---|---|
useNavigation() | groups, flattened items, active item, dan collapse state per group yang disimpan di panel:{id}:collapsed-groups |
usePanel() | brand name, path, icon, sidebar width/appearance, shell flags, data panel/tenant switcher, notification counts |
usePanelStyling() | hook('sidebar') / hook('topbar') agar cssHooks() milik panel tetap menjangkau custom component |
usePanelShell() | reloadNavigation() setelah operasi yang mengubah isi navigation |
Menggunakan kembali shipped components
Replacement biasanya tidak perlu menggambar ulang semuanya. Seluruh panel component dapat di-import:
<script setup lang="ts">
import PanelNavigation from '@/panel/components/PanelNavigation.vue';
import PanelNotifications from '@/panel/components/PanelNotifications.vue';
import PanelRenderHook from '@/panel/components/PanelRenderHook.vue';
import PanelSearch from '@/panel/components/PanelSearch.vue';
import PanelSwitcher from '@/panel/components/PanelSwitcher.vue';
import PanelTenantSwitcher from '@/panel/components/PanelTenantSwitcher.vue';
import { usePanelStyling } from '@/panel/composables/usePanelStyling';
const { hook } = usePanelStyling();
</script>
<template>
<aside class="w-72 border-r" :class="hook('sidebar')">
<div class="p-3"><PanelSearch /></div>
<PanelRenderHook name="sidebar.start" />
<PanelNavigation />
<PanelRenderHook name="sidebar.end" />
<div class="flex items-center gap-1 p-3">
<PanelTenantSwitcher />
<PanelSwitcher />
<PanelNotifications />
</div>
</aside>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
PanelNavigation tidak membutuhkan props dan membaca navigation sendiri. Karena itu replacement yang hanya membutuhkan frame berbeda di sekitar link yang sama dapat tetap sangat sederhana.
Perlu diperhatikan bahwa render hooks tidak otomatis ikut muncul. sidebar.start dan sidebar.end dihasilkan oleh PanelSidebar.vue; replacement yang tidak memasang PanelRenderHook tidak akan merender hook tersebut. Hal yang sama berlaku untuk header.start dan header.end, yang berasal dari PanelHeader.vue.
Lokasi file
resources/js/pages/Panels/**/Shell/*.vueimport { resolveShellComponent } from '@/panel/shell/registry';
resolveShellComponent('Panels/Admin/Shell/Sidebar');
// () => Promise<{ default: Component }> — atau null2
3
4
| Function | Signature |
|---|---|
resolveShellComponent | (name: string) => (() => Promise<{ default: Component }>) | null |
Tidak ada hasShellComponent(). Layout langsung memanggil resolver dan mempertahankan built-in bar ketika return-nya null.
Pattern berakhir dengan *.vue, sehingga hanya direct child dari directory Shell/ yang diregistrasikan. Shell/Parts/Rail.vue tidak akan terdeteksi.
Ketika nama tidak dapat di-resolve
Shell mempertahankan built-in bar. Ini adalah satu-satunya area frontend di mana fallback bukan neutral placeholder. Alasannya spesifik: typo pada nama component tidak boleh membuat user terjebak di page tanpa navigation untuk keluar.
Registry ini tidak menulis console warning. Jika replacement tidak muncul:
- periksa
sidebar(variant: …)berdasarkan tabel di atas; - periksa spelling dan case registry key terhadap path file;
- pastikan file merupakan direct child dari
Shell/di bawahresources/js/pages/Panels/; - rebuild, karena glob dievaluasi saat build.
Alternatif yang lebih ringan
Mengganti seluruh bar adalah perubahan terbesar dari empat cara mengubah shell dan sering kali bukan pilihan pertama:
| Kebutuhan | Gunakan |
|---|---|
| Warna berbeda | colors() |
| Class tambahan pada bagian tertentu | cssHooks() |
| Menambahkan sesuatu di titik bernama | renderHook() |
| Mengganti rail/bar sepenuhnya | sidebarComponent() / topbarComponent() |
Render hook mempertahankan semua behavior built-in bar — navigation, notifications, switchers, collapse state — dan hanya menambahkan component Anda di sampingnya. Replacement berarti Anda mengambil alih responsibility tersebut.
Gotchas
- Replacement bukan authorization. Navigation yang diterima replacement sudah difilter sesuai visibility user; menggambarnya dengan cara berbeda tidak mengubah apa yang dapat diakses. Link baru yang Anda buat sendiri tetap akan diuji authorization oleh route tujuan.
- Render hooks berada pada component yang Anda ganti. Pasang
PanelRenderHooksendiri atau hook yang sudah didaftarkan panel akan berhenti tampil. navigation(false)berperilaku berbeda pada dua shell. Sidebar shell tidak merender rail sama sekali ketika navigation off, termasuk replacement. Header shell mengecek replacement terlebih dahulu, sehinggatopbarComponenttetap dapat dirender sementara built-in bar dihilangkan.- Sidebar width adalah CSS custom property, bukan class.
panel.sidebar.widthdanpanel.sidebar.collapsedWidthadalah CSS length; gunakan:style, karena interpolated Tailwind class tidak akan ada di bundle. paneldapat bernilai null. Shell dapat dirender ketika navigation sedang meninggalkan panel, sehingga access harus null-safe — seperti contohpanel?.brandName.- Account menu adalah concern host application.
panel.shell.userMenuItemsdiserialisasi agar Anda dapat merendernya; package tidak menggambar entries tersebut karenaUserMenuContent.vueadalah milik starter kit. Lihat Host Modules. - File baru membutuhkan rebuild.
import.meta.globmerupakan build-time allowlist.