@modufolio/panel
v0.10.0
Published
Schema-driven admin panel components for Inertia.js + Vue 3 — tables, blueprint forms, filters, actions, drawers, widgets.
Maintainers
Readme
@modufolio/panel
Schema-driven admin panel components for Inertia.js + Vue 3 — tables, blueprint forms, filters, actions, drawers, widgets. Tables can be driven entirely by a server-authored schema. The frontend companion to modufolio/appkit, usable with any Inertia-speaking backend.
Status: 0.x pre-release. APIs may move between minors; the barrel export list (
src/index.ts) is the public API surface.
Install
npm install @modufolio/panelPeer dependencies: vue ^3.5, @inertiajs/vue3 ^3, @heroicons/vue ^2,
and (optional) @vueuse/core, vuedraggable, lodash, tus-js-client
(only for useTusUploadQueue).
Setup
One plugin call configures everything:
import { createPanel } from '@modufolio/panel'
createApp({ render: () => h(App, props) })
.use(createPanel({
baseUrl: '/panel', // your backend mount path
icons: { camera: CameraIcon }, // extend/override <Icon> names
fields: { // app-specific blueprint fields
'block-editor': () => import('./Fields/BlockEditorField.vue'),
},
}))
.mount(el)Every URL the panel builds goes through panelUrl() with that baseUrl —
nothing in the package hardcodes a mount path (CI-enforced).
Failed requests
The panel reports a failed status itself: a toast for what the viewer can shrug off, a modal for what ends what they were doing (401, 403 and 5xx by default). Both are configuration:
createPanel({
errorMessages: {
403: 'Ask an admin for access.', // toast
409: { as: 'modal', title: 'Out of date', message: 'Reload and try again.' },
404: false, // leave it alone
},
})AppLayout mounts the toast host and the modal; an app laying out its own
chrome mounts <Toast /> and <ErrorModal /> once itself.
Generic resource pages
The pages the generated resource routes render ship with the package. Spread them into your Inertia resolver and a full-CRUD resource needs no Vue file of its own:
import { resourcePages } from '@modufolio/panel'
const pages = { ...resourcePages, ...appPages } // app pages win on a clashThat covers Resource/Index, Resource/Create, Resource/Edit and
Resource/Permissions. None of them declares a layout, so whatever your
resolver assigns applies to them like any other page.
Styles (Tailwind CSS 4)
The package ships source .vue files and source CSS — your Tailwind build
compiles both. In your CSS entry:
@import 'tailwindcss';
@import '@modufolio/panel/styles' layer(components);
@source "../node_modules/@modufolio/panel";That entry pulls in the design tokens, the button and form rules, and
components.css — the ui-* classes the components themselves emit. Import
your own stylesheet after it to override any of them.
Theming
tokens.css is three layers:
- Palette —
--color-primary-*,--color-success-*, … and the--color-media-*values. Literal, and identical in every theme. - Semantics —
--surface,--ink,--line, and five tokens per role (--danger,--danger-fill,--danger-on-fill,--danger-surface,--danger-on-surface). This is the only layer that differs between light and dark. - Bridge — an
@theme inlineblock turning layer 2 into utilities, sobg-surface,text-ink,border-lineandtext-danger-on-surfaceexist.
Components use layers 2 and 3 — bg-surface, not bg-white dark:bg-gray-900.
Both themes therefore come from one set of classes, and anything built on top
of the panel inherits dark mode without repeating the work. Override any value
in your own @theme block to re-brand.
The --color-media-* tokens deliberately do not change with the theme: a
photographer judging tone needs the same neutral surround whatever the chrome
is doing. bg-media-surround is #4d4d4d in both themes, and is lighter
than the dark chrome around it, the way professional raw developers mat an
image.
Light and dark
Dark mode is a dark class on <html>, not a media query, so the user can
override the OS. createPanel({ theme }) sets what the panel opens as —
'dark' (the default), 'light', or 'system' to follow the OS. A stored
choice always wins; theme only decides the first load.
Read or change it with useTheme():
const { theme, preference, isDark, options, set } = useTheme()
set('light') // 'system' | 'light' | 'dark', persisted to panel.themeAdd this to your document <head>, before any stylesheet. The composable
applies the class as soon as it is imported, but that is still after the
browser has painted once — this script runs first and removes the flash:
<script>
try {
var stored = localStorage.getItem('panel.theme')
var dark = stored === 'dark' || (stored !== 'light' &&
(stored === 'system'
? matchMedia('(prefers-color-scheme: dark)').matches
: true))
document.documentElement.classList.toggle('dark', dark)
} catch (e) {}
</script>The final : true is the panel's dark default; change it to
matchMedia('(prefers-color-scheme: dark)').matches if you pass
theme: 'system' to createPanel.
Quick example
<script setup>
import { Table, BlueprintForm } from '@modufolio/panel'
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email' },
]
const fields = [
{ type: 'text', key: 'name', label: 'Name', required: true, width: '1/2' },
{ type: 'text', key: 'email', label: 'Email', width: '1/2' },
{ type: 'toggle', key: 'active', label: 'Active', when: f => !!f.email },
]
</script>
<template>
<Table :columns="columns" :records="users.data" />
<BlueprintForm v-model="form" :fields="fields" label="Profile" />
</template>In practice a page rarely writes either list by hand: the table's columns come from a server-authored schema (below), and the form's fields from the resource's blueprint.
Server-driven tables
SchemaTable renders columns, filters, groups, summaries and a query builder
from a description the backend sends as a prop — no columns array in the
page:
<SchemaTable
:schema="table"
:records="organizations.data"
:summaries="organizations.meta?.summaries ?? {}"
:filter-values="form"
@update:filter="setFilter"
/>It wraps Table, so pages using Table directly are unaffected, and any
#cell-{key} or #filters slot you pass overrides the generated one.
See docs/table-schema.md.
Generated resource pages
A PanelResource served by the package's generated routes needs no page of
its own. The host registers one component for every listing and hands the
server's props straight through:
<script setup lang="ts">
import { Head } from '@inertiajs/vue3'
import { ResourcePage, humanize } from '@modufolio/panel'
import Layout from '../Shared/Layout.vue'
defineOptions({ layout: Layout, inheritAttrs: false })
const props = defineProps<{ resource: { key: string } }>()
</script>
<template>
<Head :title="humanize(props.resource.key)" />
<ResourcePage v-bind="{ ...$attrs, ...props }" />
</template>ResourcePage renders the table or board, filters, column toggle, export,
the create action, the drawer stack with each record's tabs, and the
over-drawer form that adds one row to a list — all from what
ResourceListing sent. #cell-{key} slots replace one cell; any other slot
is a drawer tab body. ResourceForm is the create/edit half. A page that
wants its own markup calls useResourceListing() and keeps only what differs.
Async writes
Overlapping writes are normal in an admin panel — a toggle in a row, a debounced field, an optimistic star — and the failure modes are quiet. The package ships the ordering primitives rather than leaving each page to re-invent them:
import { optimistic, writeKey, usePendingKeys } from '@modufolio/panel'
const { isPending, run } = usePendingKeys() // which row is busy
const toggle = (row) => run(row.id, () => optimistic(
() => { const was = row.active; row.active = !was; return () => { row.active = was } },
() => apiFetch(url, { method: 'PATCH', body: { active: row.active } }),
writeKey('rows', row.id, 'active'), // orders overlapping writes
))Also here: useFieldSaver (debounced saves with ordered status),
useUnsavedChangesWarning (prompt before a navigation discards edits,
logout included), writeGate (the ordering itself) and moduleSingleton
(state that survives a duplicated module copy).
See docs/async-writes.md.
Extension seams
registerFieldType(type, loader)— add custom blueprint field types (rich-text editors, media pickers, ...) without forking the package. See docs/custom-fields.md.registerIcons(map)— add or override icon names used by<Icon>.setPanelBaseUrl('/admin')/panelUrl(path)— mount-path handling.
Server contract
Works with any Inertia backend. A few small server-side conventions:
- Overlays — layer stack, scroll lock, positioning, keyboard: docs/overlays.md
- Drawers — docs/drawer-protocol.md
- Table schema — docs/table-schema.md
- Relation fields — lookup search/create endpoints, repeater rows and row-addressed errors: docs/relation-fields.md
Tests
npm test # inside ui/Specs live in tests/, one file per module or component, run by the
package's own vitest.config.ts against happy-dom. Component specs mount
with @vue/test-utils; field components resolve asynchronously through the
registry, so wait for rendered output (vi.waitFor) rather than a fixed
number of ticks.
License
MIT
