Struktur Component Vue
Seluruh isi di bawah resources/js/panel setelah vendor:publish: component mana yang menggambar bagian tertentu, composable mana yang membaca prop tertentu, dan module mana yang perlu Anda import ketika membuat component sendiri. Gunakan halaman ini ketika Anda ingin menggunakan kembali bagian panel di screen milik Anda, atau ketika membaca stack trace dan ingin memahami fungsi sebuah file.
Contoh minimal yang berfungsi
Setiap file hasil publish dapat di-import melalui alias @, sehingga page milik Anda dapat menggunakan bagian frontend milik panel:
<!-- resources/js/pages/Panels/Admin/Pages/Reports.vue -->
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PageHeader from '@/panel/components/PageHeader.vue';
import { usePanel } from '@/panel/composables/usePanel';
import PanelLayout from '@/panel/layouts/PanelLayout.vue';
import type { PageMetadata } from '@/panel/types/page';
defineOptions({ layout: PanelLayout });
defineProps<{ page: PageMetadata }>();
const { panel } = usePanel();
</script>
<template>
<Head :title="page.title" />
<PageHeader :heading="page.heading" :subheading="page.subheading" />
<p class="text-sm text-muted-foreground">{{ panel?.brandName }}</p>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Tiga import, tiga layer: layout, component, dan composable. Bagian berikutnya menjelaskan isi lain pada masing-masing layer tersebut.
Struktur directory
resources/js/panel/
layouts/ PanelLayout, SidebarPanelLayout, HeaderPanelLayout,
PanelBlankLayout, PanelAuthLayout
components/ PanelSidebar, PanelNavigation, PanelNavigationItem,
PanelHeader, PanelBreadcrumb, PanelSubNavigation,
PanelClusterBar, PanelSearch, PanelNotifications,
PanelSwitcher, PanelTenantSwitcher, PanelRenderHook,
PanelRecordLayout, PageHeader, EmptyState, LoadingState,
DashboardGuide
tables/ DataTable, DataTableCell, DataTableToolbar, DataTableFilters,
DataTableQueryBuilder, DataTablePagination,
DataTableBulkActions, DataTableColumnManager, DataTableTabs,
filterParams, useFrozenColumns, registry, registryEmptyStates
forms/ FormRenderer, FormComponentRenderer, FormSection, FormGrid,
FormTabs, FormWizard, FormRelationship, FormCustomComponent,
FormCallout, FormPrime, FormField, FormEmptyState,
fields/*, conditions, validation, http, markdown,
optionsEndpoint, uploadEndpoint, formStateEndpoint, registry
infolists/ InfolistRenderer, InfolistNode, InfolistEntry, InfolistTabs
widgets/ WidgetGrid, WidgetRenderer, WidgetShell, StatsWidget,
TableWidget, ChartWidget, CustomWidget, WidgetFilters,
PageWidgets, WidgetFallback, registry
actions/ ActionButton, ActionGroup, ActionDialog, ActionModal
relations/ RelationManagerList, RelationManagerPanel, RelationFormDialog
composables/ usePanel, usePanelPage, usePanelShell, usePanelStyling,
usePanelBroadcasting, useNavigation, useResource, useActions,
useInfolistActions, useRelationActions, useRelationTable,
useErrorNotifications, useUnsavedChangesAlert
icons/ registry
hooks/ registry
shell/ registry
lib/ grid
palette.ts
types/ panel, shared, navigation, breadcrumb, page, table, form,
infolist, relation, action, widget, cellGuards, widgetGuards2
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
Layouts
Setiap panel page mendeklarasikan layout-nya sendiri, sehingga tidak ada wiring tambahan yang perlu dilakukan di resources/js/app.ts.
| Layout | Props | Peran |
|---|---|---|
PanelLayout.vue | breadcrumbs?: PanelBreadcrumbItem[] | Layout yang dideklarasikan oleh page. Memilih shell dari panel.sidebar.variant, mendaftarkan listener error dan broadcasting, lalu menggunakan breadcrumbs milik page jika prop tidak diberikan |
SidebarPanelLayout.vue | breadcrumbs? | Shell dengan side rail/sidebar |
HeaderPanelLayout.vue | breadcrumbs? | Shell dengan top navigation, digunakan ketika panel mengatur sidebar(variant: 'header') |
PanelBlankLayout.vue | tidak ada | Tanpa chrome sama sekali — digunakan oleh auth pages milik panel |
PanelAuthLayout.vue | panel: PanelDefinition, title: string, description?: string | Frame yang digambar sendiri oleh auth pages: brand panel, heading, dan tidak lebih dari itu |
<script setup lang="ts">
import PanelLayout from '@/panel/layouts/PanelLayout.vue';
defineOptions({ layout: PanelLayout });
</script>2
3
4
5
PanelLayout me-resolve breadcrumbs dari page.breadcrumbs ketika prop tidak diberikan, sehingga page tidak perlu melakukan wiring trail secara manual:
<script setup lang="ts">
import PanelLayout from '@/panel/layouts/PanelLayout.vue';
import type { PanelBreadcrumbItem } from '@/panel/types/breadcrumb';
// Hanya untuk page yang jarang terjadi: breadcrumb dibangun di client.
const breadcrumbs: PanelBreadcrumbItem[] = [
{ label: 'Dashboard', href: '/admin', current: false },
{ label: 'Reports', href: null, current: true },
];
defineOptions({ layout: PanelLayout });
</script>2
3
4
5
6
7
8
9
10
11
12
PanelBlankLayout ada karena layout: null tidak bekerja pada resolver host yang umum digunakan: page.default.layout = page.default.layout || AppLayout, dan null || AppLayout menghasilkan AppLayout. Component yang hanya merender slot adalah satu-satunya cara yang jelas untuk mengatakan “jangan gunakan layout application”.
Shell components
| Component | Yang dirender |
|---|---|
PanelSidebar.vue | rail/sidebar: brand, navigation, user footer. Membaca panel.sidebar.appearance dan memvalidasinya sebelum diteruskan ke shadcn |
PanelNavigation.vue | groups navigation, termasuk nested groups yang di-indent di bawah parent |
PanelNavigationItem.vue | satu navigation item, termasuk active icon dan badge |
PanelHeader.vue | top bar: sidebar trigger, breadcrumbs, search, switchers, notifications, theme toggle |
PanelBreadcrumb.vue | breadcrumb trail |
PanelSearch.vue | command palette; tidak merender apa pun kecuali search aktif dan ada resource yang opt-in |
PanelNotifications.vue | notification bell dan notification centre |
PanelSwitcher.vue | berpindah antar-panel; tidak dirender jika user hanya boleh masuk satu panel |
PanelTenantSwitcher.vue | berpindah antar-tenant; membutuhkan tenancy, lebih dari satu tenant, dan tenant URL |
PanelClusterBar.vue | sub-navigation milik cluster, sebagai bar atau column |
PanelSubNavigation.vue | link antar-page untuk satu record |
PanelRecordLayout.vue | menyusun record page mengelilingi sub-navigation |
PanelRenderHook.vue | merender content yang di-inject panel pada titik bernama |
PageHeader.vue | heading, subheading, dan slot actions |
EmptyState.vue, LoadingState.vue | state netral |
DashboardGuide.vue | content yang muncul pada dashboard sebelum ada widget/content lain |
PanelRenderHook adalah component yang paling mungkin Anda pasang sendiri, dan hanya membutuhkan satu prop:
<script setup lang="ts">
import PanelRenderHook from '@/panel/components/PanelRenderHook.vue';
</script>
<template>
<PanelRenderHook name="page.start" />
</template>2
3
4
5
6
7
Nama tersebut adalah PanelRenderHookName: body.start, body.end, sidebar.start, sidebar.end, header.start, header.end, page.start, page.end. Filtering scope dilakukan di sini, bukan di server, karena shared props dibangun oleh middleware sebelum request mencapai sebuah page. Shell mengetahui page mana yang sedang dirender, sedangkan middleware belum.
Renderers
Ada empat tree dengan pola yang sama: top-level renderer, node dispatcher yang melakukan recursion, lalu leaves.
| Area | Entry | Dispatcher | Leaves |
|---|---|---|---|
| Tables | DataTable.vue | DataTableCell.vue | switch pada column.type |
| Forms | FormRenderer.vue | FormComponentRenderer.vue | forms/fields/*.vue |
| Infolists | InfolistRenderer.vue | InfolistNode.vue | InfolistEntry.vue |
| Widgets | WidgetGrid.vue | WidgetRenderer.vue | StatsWidget, TableWidget, ChartWidget, CustomWidget |
Setiap dispatcher melakukan switch pada discriminant dan diakhiri exhaustive never check. Artinya menambahkan PHP type tanpa Vue renderer menjadi compile error, bukan menghasilkan cell kosong tanpa penjelasan.
Tables
| File | Peran |
|---|---|
DataTable.vue | rows, headers, grouping, frozen columns, reordering, empty state |
DataTableCell.vue | satu cell, termasuk editable controls dan custom columns |
DataTableToolbar.vue | search, filter trigger, toolbar actions, deferred filter staging |
DataTableFilters.vue | filter controls; setiap accessor melakukan narrowing, bukan assertion |
DataTableQueryBuilder.vue | composed conditions berdasarkan declaration dari server tentang hal yang boleh dikonstrain |
DataTableTabs.vue | filter tabs — sebuah tab adalah URL, bukan local state |
DataTablePagination.vue | page links dan per-page |
DataTableBulkActions.vue | sticky selection bar |
DataTableColumnManager.vue | column mana yang ditampilkan dan urutannya |
useFrozenColumns.ts | offset untuk pinned columns |
filterParams.ts | menulis dan membersihkan filter query parameters |
TanStack Table v9 hanya memiliki row model dan row selection — tableFeatures({ rowSelectionFeature }) adalah seluruh registration-nya. Sorting, filtering, pagination, column visibility, dan column order semuanya dikelola server-side, sehingga feature TanStack untuk concern tersebut sengaja tidak diregistrasikan.
Forms
forms/fields/ berisi satu component untuk setiap field type, ditambah dua component yang bukan field type:
BuilderField CheckboxField CheckboxListField CodeEditorField
ColorPickerField DateField DateTimeField FileUploadField
KeyValueField MarkdownEditorField NumberField PasswordField
RadioField RepeaterField RichEditorField SelectField
SliderField TagsInputField TextInputField TextareaField
TimeField ToggleButtonsField ToggleField
FieldWrapper label, helper text, dan error yang dipakai semua field
CustomFieldRenderer me-resolve component milik CustomField melalui registry2
3
4
5
6
7
8
9
Layout components — FormSection, FormGrid, FormTabs, FormWizard, FormRelationship, FormCustomComponent — semuanya recurse kembali melalui FormComponentRenderer. Dengan demikian kedalaman nesting adalah concern data, bukan concern component.
Supporting modules:
| Module | Exports |
|---|---|
conditions.ts | matchesConditions(), conditionDependencies(), isBlankValue() |
validation.ts | validateFields() — subset rules yang memang dapat diperiksa browser dengan jujur |
http.ts | csrfToken(), postJson(), postForm() |
markdown.ts | renderMarkdown() |
optionsEndpoint.ts | provideOptionsUrl(), useOptionsUrl(), fetchOptions() |
uploadEndpoint.ts | provideUploadUrl(), useUploadUrl(), uploadFile() |
formStateEndpoint.ts | provideFormStateUrl(), useFormStateUrl(), fetchFormState() |
Widgets
WidgetShell.vue membungkus setiap widget dengan heading, description, filter form, polling timer, dan class hook panel-widget, sehingga custom widget hanya perlu menggambar body-nya. PageWidgets.vue merender widgets yang ditempatkan resource page di atas dan di bawah content-nya. WidgetFallback.vue digunakan ketika nama component tidak dapat di-resolve.
Actions
| File | Peran |
|---|---|
ActionButton.vue | satu action sebagai button atau link |
ActionGroup.vue | row actions yang diringkas menjadi menu |
ActionDialog.vue | confirmation untuk destructive action |
ActionModal.vue | satu dialog yang digunakan semua action: confirmation, custom content, dan form milik action |
Composables
Seluruhnya berada di bawah @/panel/composables/.
| Composable | Signature |
|---|---|
usePanel | usePanel(): UsePanelReturn |
usePanelPage | usePanelPage(): ComputedRef<PageMetadata | null> |
usePanelShell | usePanelShell(): UsePanelShellReturn |
usePanelStyling | usePanelStyling(): UsePanelStylingReturn |
usePanelBroadcasting | usePanelBroadcasting(): void |
useNavigation | useNavigation(): UseNavigationReturn |
useResource | useResource(resource: () => ResourceMeta, state: () => TableState): UseResourceReturn |
useActions | useActions(resourceSlug: () => string, endpoints: () => ActionEndpoints, parentKey?: () => string | number | null): UseActionsReturn |
useInfolistActions | useInfolistActions(...): UseInfolistActionsReturn |
useRelationActions | useRelationActions(...): UseRelationActionsReturn |
useRelationTable | useRelationTable(...): UseRelationTableReturn |
useErrorNotifications | useErrorNotifications(): void |
useUnsavedChangesAlert | useUnsavedChangesAlert(isDirty: Ref<boolean>): void |
usePanel()
import { usePanel } from '@/panel/composables/usePanel';
const {
panel, // ComputedRef<PanelDefinition | null>
hasPanel, // ComputedRef<boolean>
maxContentWidthClass, // ComputedRef<string> — literal Tailwind class
panels, // ComputedRef<PanelSummary[]>
canSwitchPanels, // ComputedRef<boolean>
broadcasting, // ComputedRef<PanelBroadcasting>
search, // ComputedRef<PanelSearchSettings>
notifications, // ComputedRef<PanelNotificationSettings>
shell, // ComputedRef<PanelShellSettings>
tenancy, // ComputedRef<PanelTenancy | null>
canSwitchTenants, // ComputedRef<boolean>
} = usePanel();2
3
4
5
6
7
8
9
10
11
12
13
14
15
Setiap consumer harus bisa menerima panel bernilai null: shell dapat tetap dirender ketika navigation membawa user keluar dari panel. shell menggunakan fallback semua-feature-aktif, karena shell untuk panel yang tidak menyatakan konfigurasi apa pun adalah shell lengkap.
usePanelPage()
import { usePanelPage } from '@/panel/composables/usePanelPage';
const page = usePanelPage();
// page.value?.heading, page.value?.breadcrumbs, page.value?.scope2
3
4
Prop page divalidasi, bukan sekadar di-cast. normalizePageMetadata(value: unknown): PageMetadata | null diekspor dari module yang sama untuk kasus ketika Anda memiliki raw value.
usePanelShell()
import { usePanelShell } from '@/panel/composables/usePanelShell';
const { reloadNavigation, reloadTopbar, reloadShell } = usePanelShell();
reloadNavigation(); // router.reload({ only: ['navigation'] })
reloadTopbar(); // router.reload({ only: ['panel', 'notifications', 'panels'] })
reloadShell(); // keduanya2
3
4
5
6
7
Tidak ada endpoint khusus yang menjawab “seperti apa sidebar sekarang”. Untuk memberi jawaban yang benar endpoint tersebut tetap harus me-resolve panel, user, dan URL, yang pada dasarnya sama dengan pekerjaan sebuah request biasa.
usePanelStyling()
import { usePanelStyling } from '@/panel/composables/usePanelStyling';
const { themeStyle, hook } = usePanelStyling();
// themeStyle.value === { '--primary': '#4f46e5' }
// hook('topbar') === 'panel-topbar border-b-2 border-amber-500'2
3
4
5
Lihat CSS Hooks.
useNavigation()
import { useNavigation } from '@/panel/composables/useNavigation';
const { groups, items, activeItem, isCollapsed, toggle } = useNavigation();2
3
Groups adalah read-only server data dan tidak pernah disalin ke local state. Satu-satunya state yang benar-benar dimiliki client adalah daftar collapsible groups yang ditutup user, disimpan per panel pada panel:{id}:collapsed-groups.
useResource()
import { useResource } from '@/panel/composables/useResource';
const {
setSearch, setSort, setPage, setPerPage,
setFilter, setFilters, clearFilters,
setColumns, resetColumns, setColumnSearch,
setTab, nextDirectionFor,
} = useResource(() => props.resource, () => props.state);
setSearch('ada'); // ?search=ada
setFilter('verified', 'true'); // ?filters[verified]=true2
3
4
5
6
7
8
9
10
11
Setiap control menulis ke query string dan membiarkan server memberikan hasil. Visit menggunakan preserveState dan preserveScroll, sehingga mengetik di search box tidak menghilangkan focus atau posisi scroll.
Registries
Enam allowlist import.meta.glob yang dibangun saat build, ditambah generated icon map.
| Module | Glob | Resolver |
|---|---|---|
@/panel/tables/registry | pages/Panels/**/Columns/*.vue | resolveColumnComponent(name) |
@/panel/forms/registry | pages/Panels/**/{Fields,Schemas,Entries,Modals}/*.vue | resolveFormComponent(name) |
@/panel/widgets/registry | pages/Panels/**/Widgets/*.vue | resolveWidgetComponent(name) |
@/panel/hooks/registry | pages/Panels/**/Hooks/*.vue | resolveHookComponent(name) |
@/panel/shell/registry | pages/Panels/**/Shell/*.vue | resolveShellComponent(name) |
@/panel/tables/registryEmptyStates | pages/Panels/**/EmptyStates/*.vue | resolveEmptyStateComponent(name) |
@/panel/icons/registry | generated oleh panel:icons | resolveIcon(name) |
Setiap resolver mengembalikan loader atau null; tidak ada yang melempar exception. Empat registry juga menyediakan membership test — hasColumnComponent, hasFormComponent, hasWidgetComponent, hasHookComponent. Lihat Component Registries.
Types dan guards
types/ mencerminkan serialization PHP, satu module per area: panel, shared, navigation, breadcrumb, page, table, form, infolist, relation, action, widget.
Dua module merupakan runtime code, bukan hanya type definition:
import { asBadgeCell, asTextCell } from '@/panel/types/cellGuards';
import { asStats, asChart } from '@/panel/types/widgetGuards';2
| Module | Exports |
|---|---|
cellGuards.ts | asTextCell, asNumberCell, asBadgeCell, asBooleanCell, asDateCell, asImageCell, asIconCell, asColorCell, asEditableCell |
widgetGuards.ts | asStats, asTable, asChart, asCustomData |
Masing-masing menerima unknown dan mengembalikan value yang sudah di-narrow atau fallback yang bernilai null/empty. Value yang datang dari PHP di-narrow, bukan di-assert, sehingga shape mismatch turun menjadi empty cell alih-alih melempar error di dalam table.
types/shared.ts menyimpan PanelSharedProps dan panelSharedProps(), satu deliberate cast pada seluruh frontend:
import { panelSharedProps } from '@/panel/types/shared';
const props = panelSharedProps(); // ComputedRef<PanelSharedProps>2
3
Return-nya berupa ComputedRef, bukan snapshot, karena usePage() bersifat reactive dan props berubah saat client-side navigation.
Layout helpers
import { MAX_COLUMNS, gridClass, spanClass } from '@/panel/lib/grid';
MAX_COLUMNS; // 4
gridClass(3); // 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
spanClass('full', 3); // 'col-span-full'
spanClass(3, 4); // di-clamp menjadi dua column pada md, tiga pada lg2
3
4
5
6
Container empat column hanya selebar dua column pada breakpoint md — 768px, sementara panel memakai sekitar 256px untuk sidebar — sehingga span di-clamp secara terpisah pada tiap breakpoint. Tanpa ini, grid-column: span 3 pada grid yang hanya memiliki dua track akan membuat implicit track ketiga dan row meluber ke samping.
import { BADGE_CLASSES, ICON_CLASSES, SELECTED_CLASSES } from '@/panel/palette';
import type { BadgeColorName } from '@/panel/palette';
BADGE_CLASSES.success; // 'bg-emerald-100 text-emerald-800 dark:...'2
3
4
Kedua module ada karena alasan yang sama: setiap class ditulis lengkap. String interpolasi seperti md:grid-cols-${n} atau bg-${color}-100 tidak terlihat oleh Tailwind compiler, sehingga class tersebut tidak akan pernah masuk bundle.
Notes
- Tidak menggunakan
any. Metadata unions dibedakan berdasarkantype, dan setiap switch diakhiri exhaustivenevercheck. - Validasi, jangan assert.
cellGuards,widgetGuards, danusePanelPagemelakukan narrowing terhadap value yang melintasi wire.panelSharedProps()adalah satu-satunya deliberate cast dan ditempatkan di satu file agar risikonya berada di dalam package, bukan tersebar pada build application. - Server state tidak disalin ke local state. Local state hanya digunakan untuk debounced search input, nilai kerja form, row selection, dan navigation group mana yang sedang collapsed.
- Tidak ada hardcoded Panel URL. Setiap href datang dari server atau Wayfinder.
- Content column menggunakan
overflow-x-clip, bukanoverflow-x-hidden.hiddenpada satu axis membuat axis lainnya dihitung sebagaiauto, sehingga element menjadi scroll container — dan scroll container menangkap setiapposition: stickydi dalamnya. Selection bar dan save row milik form sama-sama berada di area tersebut. Test memastikan spelling ini tidak berubah. - File-file ini dipublish, sehingga menjadi milik Anda. Mengeditnya didukung;
panel:assetsakan melaporkan file tersebut sebagaimodifieddan tidak overwrite pada upgrade.