@modufolio/panel

Schema-driven admin panel components for Inertia.js + Vue 3 — tables, blueprint forms, filters, actions, drawers, widgets.
1,353
Install
npm install @modufolio/panel
Latest Version:0.10.0
Vue:3
License:MIT
Last Updated:Sep 16, 2026
Links: npm
Maintainer: nicecoder

@modufolio/panel

CI npm License: MIT

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/panel

Peer 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 clash

That 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:

  1. Palette — --color-primary-*, --color-success-*, … and the --color-media-* values. Literal, and identical in every theme.
  2. 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.
  3. Bridge — an @theme inline block turning layer 2 into utilities, so bg-surface, text-ink, border-line and text-danger-on-surface exist.

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.theme

Add 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:

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

Related Packages

@dynamia-tools/vue

Vue 3 adapter for Dynamia Platform UI

2,322 10
@alfakit/vue

Vue 3 + shadcn-vue renderer for Alfakit, the Laravel admin panel builder by Alfa...

351 0
smart-naive-table

Columns-driven data table for Vue 3 + Naive UI: auto search form, remote paginat...

3,308 0
c-admin-kit

基于 Vue 3 + Element Plus 的中后台企业级高阶通用组件与 Hooks 套件(强制使用 c-...

472

Version History

Version Released Vue License
0.10.0 ^3.5.0 MIT
0.9.0 ^3.5.0 MIT
0.8.0 ^3.5.0 MIT
0.7.0 ^3.5.0 MIT
0.6.0 ^3.5.0 MIT