# Page templates, the long samples

> Plainkit 0.28.0. Generated by scripts/build-skills.mjs from core/samples and templates.md. Do not edit: change the source and run the generator.

The sections of `../templates.md` that are too long to keep in the npm package (markup, script or module header), by id.

## app

```html
<div id="app"></div>
<noscript>This app needs JavaScript.</noscript>
```

Page script:

```js
// App shell template (#354): mountApp builds the chrome from this config. An overview (dashboard page) and an inbox (master-detail page, /inbox/:id).
import { mountApp, defineModule } from './plainkit/js/app.js';

const MESSAGES = Array.from({ length: 24 }, (_, i) => ({ id: String(i + 1), subject: `Message ${i + 1}`, from: ['Ada Lovelace', 'Grace Hopper', 'Alan Turing'][i % 3], unread: i % 4 === 0 }));

const overview = defineModule({
    id: 'overview', title: 'Overview',
    nav: () => [{ id: 'home', title: 'Overview', route: '/', icon: 'dashboard' }],
    routes: [
        // Composed from what modules contribute (Inbox).
        { path: '/', label: 'Overview', page: 'dashboard', config: { heading: 'Overview', surface: 'overview' } },
        { path: '*', page: 'not-found' },
    ],
});

// One config serves both routes, so the list stays put when a message opens. The route is the only state; a row click and Back only navigate.
const messages = {
    heading: 'Messages',
    list: { columns: [{ key: 'subject', label: 'Subject' }, { key: 'from', label: 'From' }], pageSize: 10 },
    none: { heading: 'Select a message', description: 'Pick one to read it.' },
    load: async q => {
        const rows = MESSAGES.filter(m => `${m.subject} ${m.from}`.toLowerCase().includes((q.search ?? '').toLowerCase()));
        const size = q.pageSize ?? 10, start = ((q.page ?? 1) - 1) * size;
        return { rows: rows.slice(start, start + size), total: rows.length };
    },
    rowHref: row => `/${row.id}`, listHref: '/',
    mountDetail: async (pane, id) => {
        const m = MESSAGES.find(x => x.id === id);
        if (!m) throw new Error(`There is no message ${id}.`);
        const h = pane.ownerDocument.createElement('pk-heading'), p = pane.ownerDocument.createElement('pk-text');
        h.setAttribute('level', '2'); h.textContent = m.subject; p.textContent = `From ${m.from}.`;
        pane.append(h, p);
        return { destroy() { h.remove(); p.remove(); } };
    },
};

const inbox = defineModule({
    id: 'inbox', title: 'Inbox',
    widgets: {
        total: { load: async () => ({ value: String(MESSAGES.length) }) },
        unread: { load: async () => ({ value: String(MESSAGES.filter(m => m.unread).length) }) },
    },
    nav: () => [{ id: 'messages', title: 'Messages', route: '/', icon: 'orders' }],
    routes: [
        { path: '/', label: 'Messages', page: 'master-detail', config: messages, children: [{ path: '/:id', label: p => `Message ${p.id}`, page: 'master-detail', config: messages }] },
        { path: '*', page: 'not-found' },
    ],
});

mountApp(document.getElementById('app'), { brand: { text: 'My app' }, modules: [
    { id: 'overview', title: 'Overview', icon: 'dashboard', load: async () => ({ default: overview }) },
    { id: 'inbox', title: 'Inbox', icon: 'orders', load: async () => ({ default: inbox }),
      contributes: [{ key: 'total', for: ['overview'], label: 'Messages', kind: 'stat' }, { key: 'unread', for: ['overview'], label: 'Unread', kind: 'stat' }] },
] });
```

## routed-pair

```html
<div id="app"></div>
<noscript>This app needs JavaScript.</noscript>
```

Page script:

```js
// A list page and a record page as two routes (#/things, #/things/7, #/things/new): the page types do the work, this file is data and three callbacks.
// Copy it and replace `things` and the callbacks with your API. The route is the only state: a row click and Save only navigate.
import { mountApp, defineModule } from './plainkit/js/app.js';
import { queryList } from './plainkit/js/list-query.js';

const STATUS = ['Active', 'Draft', 'Archived'];
let things = Array.from({ length: 60 }, (_, i) => ({ id: String(i + 1), name: `Thing ${i + 1}`, status: STATUS[i % 3] }));

// The list query applied to the data: what load() answers and what a bulk action on the whole query (scope 'all') acts on.
const run = (q, over) => queryList(things, { filter: q.filters?.status ? r => r.status === q.filters.status : null, search: q.search, searchKeys: ['name'], sort: q.sort, sortDir: q.sortDir, page: q.page, pageSize: q.pageSize, ...over });

// One record config for both routes: `load` is left out for a new record. Save returns to the list; reject with { errors: { field: message } } to mark a field instead.
const record = (load, title, id) => ({
    heading: 'Details', title, load,
    fields: [{ name: 'name', label: 'Name', required: true }, { name: 'status', label: 'Status', type: 'select', options: STATUS }],
    save: async (values, ctx) => {
        things = id ? things.map(r => (r.id === id ? { ...r, ...values } : r)) : [...things, { ...values, id: String(things.length + 1) }];
        ctx.navigate('/');
    },
    // An existing record can be deleted: the page asks first, calls this, and toasts "Deleted" (config.toasts words or silences it); Save toasts "Saved" the same way.
    ...(id ? { delete: async (_, ctx) => { things = things.filter(r => r.id !== id); ctx.navigate('/'); } } : {}),
});

const thingsModule = defineModule({
    id: 'things', title: 'Things',
    nav: () => [{ id: 'all', title: 'All things', route: '/', icon: 'orders' }],
    routes: [
        // The list page type draws pk-data-table: search, sort, filter and paging come from `load(query)`; rowHref turns a row click into a route change.
        { path: '/', label: 'Things', page: 'list', config: {
            heading: 'Things',
            columns: [{ key: 'name', label: 'Name', sortable: true }, { key: 'status', label: 'Status', sortable: true }],
            filters: [{ key: 'status', type: 'select', label: 'Status', options: STATUS }],
            actions: [{ label: 'New thing', href: '#/things/new', variant: 'primary' }],
            empty: { heading: 'No things yet', description: 'Add one with New thing.' },
            pageSize: 10,
            // Selection and a bulk action: onBulk gets { action, selected, scope, query }; for scope 'all' act on the query (the server), never on the ids. The list reloads when it returns.
            selectable: true, bulkActions: [{ id: 'archive', label: 'Archive' }],
            onBulk: async ({ selected, scope, query }, ctx) => {
                const hit = scope === 'all' ? run(query, { page: 1, pageSize: things.length }).rows.map(r => r.id) : selected;
                things = things.map(r => (hit.includes(r.id) ? { ...r, status: 'Archived' } : r));
            },
            load: async q => run(q),
            rowHref: row => `/${row.id}`,
        }, children: [
            // The record page type: a form with Save, a dirty flag (closing the tab with unsaved edits asks first) and its own loading, not-found and error states.
            { path: '/new', label: 'New thing', page: 'record', config: record(undefined, 'New thing') },
            { path: '/:id', label: p => `Thing ${p.id}`, page: 'record', config: ({ params }) => record(async id => {
                const thing = things.find(r => r.id === id);
                if (!thing) throw new Error(`There is no thing ${id}.`);
                return thing;
            }, `Thing ${params.id}`, params.id) },
        ] },
        { path: '*', page: 'not-found' },
    ],
});

mountApp(document.getElementById('app'), { brand: { text: 'Admin' }, modules: [{ id: 'things', title: 'Things', icon: 'orders', load: async () => ({ default: thingsModule }) }] });
```

## overlays-nav

```html
<pk-skip-link href="#main">Skip to content</pk-skip-link>
<pk-scroll-progress for="#main-body"></pk-scroll-progress>

<pk-app-shell>
  <pk-side-nav slot="nav" label="Main" filterable persist="pk-sample-overlays-nav">
    <a slot="brand" href="#">Brand</a>
    <pk-nav-item href="#" current>Dashboard</pk-nav-item>
    <pk-nav-item href="#"><span slot="badge">12</span>Orders</pk-nav-item>
    <pk-nav-item expanded>Products
      <pk-nav-item slot="children" href="#">All products</pk-nav-item>
      <pk-nav-item slot="children" href="#">Drafts</pk-nav-item>
      <pk-nav-item slot="children" href="#">Categories</pk-nav-item>
    </pk-nav-item>
    <pk-nav-item>Stock
      <pk-nav-item slot="children" href="#">Receiving</pk-nav-item>
      <pk-nav-item slot="children" href="#">Adjustments</pk-nav-item>
    </pk-nav-item>
    <pk-nav-item href="#" disabled>Finance (soon)</pk-nav-item>
    <pk-nav-item slot="footer" href="#">Settings</pk-nav-item>
  </pk-side-nav>

  <pk-cluster slot="header">
    <pk-button variant="ghost" data-nav-toggle label="Open the menu">Menu</pk-button>
    <pk-breadcrumb label="Breadcrumb"><a href="#">Home</a><a href="#">Products</a><a href="#">Books</a><a href="#">Item 12</a><a href="#" aria-current="page">Variants</a></pk-breadcrumb>
    <pk-dropdown>
      <pk-button slot="trigger" variant="ghost" size="mini">Account</pk-button>
      <pk-menu-item type="header">Signed in as Ada</pk-menu-item>
      <pk-menu-item href="#">Profile</pk-menu-item>
      <pk-menu-item type="checkbox" checked>Email alerts</pk-menu-item>
      <pk-menu-item type="divider"></pk-menu-item>
      <pk-menu-item danger>Sign out</pk-menu-item>
    </pk-dropdown>
  </pk-cluster>

  <pk-stack id="main-body">
    <pk-alert kind="warning" banner dismissible>Scheduled maintenance tonight at 22:00.<a slot="action" href="#">Details</a></pk-alert>

    <main id="main" tabindex="-1">
      <pk-stack>
        <h1>Create product</h1>
        <pk-stepper label="Create product" current="1" clickable>
          <pk-step heading="Basics" description="Name and type"></pk-step>
          <pk-step heading="Pricing" description="Cost and price"></pk-step>
          <pk-step heading="Images"></pk-step>
          <pk-step heading="Review"></pk-step>
        </pk-stepper>

        <pk-grid columns="2" min="20rem">
          <pk-stack id="article">
            <pk-card>
              <pk-stack>
                <h2 id="pricing">Pricing</h2>
                <pk-alert kind="info" heading="Tip">Cost is stamped on the ledger when the purchase order arrives.</pk-alert>
                <pk-cluster>
                  <pk-select name="currency" value="usd"><option value="usd">USD</option><option value="cad">CAD</option><option value="eur">EUR</option></pk-select>
                  <pk-tooltip text="Saves the draft without publishing"><pk-button variant="ghost">Save draft</pk-button></pk-tooltip>
                  <pk-popover variant="confirm" heading="Discard changes?" message="Unsaved edits are lost." confirm-label="Discard" danger><pk-button slot="trigger" variant="warn">Discard</pk-button></pk-popover>
                </pk-cluster>
              </pk-stack>
            </pk-card>

            <pk-card>
              <pk-stack>
                <h2 id="actions">Actions</h2>
                <pk-cluster>
                  <pk-button data-open="#filters">Open filters drawer</pk-button>
                  <pk-button variant="ghost" data-open="#terms">Open dialog</pk-button>
                  <pk-button variant="ghost" data-toast="success">Show a toast</pk-button>
                  <pk-button variant="ghost" data-open="#palette">Command palette (Ctrl+K)</pk-button>
                </pk-cluster>
                <pk-context-menu>
                  <pk-card><p>Right-click, long-press or press Shift+F10 here.</p></pk-card>
                  <pk-menu-item slot="menu">Rename</pk-menu-item>
                  <pk-menu-item slot="menu">Duplicate</pk-menu-item>
                  <pk-menu-item slot="menu" danger>Delete</pk-menu-item>
                </pk-context-menu>
              </pk-stack>
            </pk-card>

            <pk-card>
              <pk-stack>
                <h2 id="loading">Loading state</h2>
                <pk-loading-overlay busy label="Loading variants…"><pk-card><p>Variant list.</p><pk-text tone="muted">Covered and inert while busy.</pk-text></pk-card></pk-loading-overlay>
              </pk-stack>
            </pk-card>

            <pk-pager label="Page navigation"><a slot="prev" href="#">Previous: Basics</a><span>Step 2 of 4</span><a slot="next" href="#">Next: Images</a></pk-pager>
          </pk-stack>

          <pk-toc for="#article" scroller="#main-body" title="On this page" label="On this page"></pk-toc>
        </pk-grid>
      </pk-stack>
    </main>
  </pk-stack>

  <span slot="footer">Plainkit sample</span>
</pk-app-shell>

<pk-drawer id="filters" side="right" heading="Filters">
  <p>Filter controls go here.</p>
  <pk-button slot="footer" variant="ghost" data-close>Cancel</pk-button>
  <pk-button slot="footer" data-close>Apply</pk-button>
</pk-drawer>

<pk-dialog id="terms" heading="Publish this product?" size="sm">
  <p>It will appear on every connected channel. You can archive it later.</p>
  <pk-button slot="footer" variant="ghost" data-close>Cancel</pk-button>
  <pk-button slot="footer" data-close>Publish</pk-button>
</pk-dialog>

<pk-command-palette id="palette"></pk-command-palette>
<pk-back-to-top for="#main-body" label="Back to top"></pk-back-to-top>
```

Page script:

```js
// Wiring for the overlays and navigation sample: the elements do the work; this only connects the demo buttons to their public API
// (show/hide, the palette's items property) and to the toast helper. Nothing here is needed by the components themselves.
// The overlay elements install the data-open / data-toggle / data-close openers themselves (js/invokers.js).
// The element loader (js/element.js) registers the pk-* tags on demand when the page uses them.

import { loadElements, observeElements } from './plainkit/js/loader.js';
import Toast from './plainkit/elements/toast-stack.js';

loadElements(document);
observeElements(document);
globalThis.PkToast = Toast;

const $ = selector => document.querySelector(selector);

document.addEventListener('click', event => {
    const toaster = event.target.closest('[data-toast]');
    if (toaster) globalThis.PkToast?.show('Draft saved.', { kind: toaster.getAttribute('data-toast'), heading: 'Saved', action: { label: 'Undo' } });
});

const palette = $('#palette');
if (palette) {
    // `items` is a property: set it once the element is defined, or the upgrade would leave it shadowed.
    await customElements.whenDefined('pk-command-palette');
    palette.items = [
        { id: 'dash', label: 'Go to Dashboard', group: 'Navigate', href: '#' },
        { id: 'orders', label: 'Go to Orders', group: 'Navigate', href: '#', keywords: 'sales' },
        { id: 'products', label: 'Go to Products', group: 'Navigate', href: '#', keywords: 'catalog inventory' },
        { id: 'new-po', label: 'New purchase order', group: 'Create', shortcut: 'N' },
        { id: 'new-product', label: 'New product', group: 'Create' },
        { id: 'theme', label: 'Toggle dark theme', group: 'Settings' },
    ];
}
```

## routed-list-detail

```html
<pk-workspace id="content" fill main-label="Things" aside-label="Thing">
  <pk-data-table id="things" clickable sticky-header row-key="id" label="Things" search-label="Search things" page-size="25" sort="name" columns='[{"key":"name","label":"Name","sortable":true},{"key":"status","label":"Status","sortable":true},{"key":"updated","label":"Updated","sortable":true,"hidePhone":true}]' filters='[{"key":"status","type":"select","label":"Status","options":["Active","Draft","Archived"]}]'></pk-data-table>
  <pk-stack slot="aside" id="detail" gap="sm">
    <pk-page-header id="detail-head" variant="section" heading="Thing">
      <pk-button slot="actions" id="detail-back" size="mini" variant="ghost">&larr; Things</pk-button>
      <pk-button slot="actions" id="detail-close" size="mini" variant="ghost" icon label="Close the record" icon-name="x"></pk-button>
    </pk-page-header>
    <pk-form summary>
      <form id="detail-form">
        <pk-stack gap="sm">
          <pk-field label="Name" required><pk-input name="name" required></pk-input></pk-field>
          <pk-field label="Status"><pk-select name="status"><option>Active</option><option>Draft</option><option>Archived</option></pk-select></pk-field>
          <pk-form-actions><pk-button type="button" id="detail-cancel" variant="plain">Cancel</pk-button><pk-button type="submit">Save</pk-button></pk-form-actions>
        </pk-stack>
      </form>
    </pk-form>
  </pk-stack>
</pk-workspace>
```

Page script:

```js
import { queryList } from './plainkit/js/list-query.js';
// Icon-only, like the shell's own Search trigger: `icon` + `label` gives it its accessible name ("Actions") without a visible text label.

// The route owns the page. #/things shows the list; #/things/2 and #/things/new show the same page with the record open. A real app uses its
// router's paths (/things, /things/2, /things/new) the same way: everything below is a function of the route, and the elements only report what
// the user did (pk-row-click, Save, Cancel) so the handlers can change the route or the list state.
const workspace = document.getElementById('content');
const table = document.getElementById('things');
const head = document.getElementById('detail-head');
const validation = document.querySelector('pk-form');
const form = document.getElementById('detail-form');

// 1000 rows (not a handful): the point of sticky-header plus a pager is moot over a page that never needs to scroll or page on its own.
const ADJ = ['Blue', 'Red', 'Green', 'Grey', 'Yellow', 'Orange', 'Purple', 'Teal', 'Silver', 'Copper', 'Bronze', 'Steel', 'Iron', 'Brass', 'Nickel', 'Chrome', 'Zinc', 'Titanium', 'Aluminium', 'Golden'];
const NOUN = ['widget', 'gadget', 'gizmo', 'sprocket', 'bracket', 'hinge', 'latch', 'bolt', 'rivet', 'fitting', 'washer', 'trim', 'plate', 'frame', 'panel', 'valve', 'coupler', 'bushing', 'spindle', 'clamp'];
const STATUSES = ['Active', 'Draft', 'Archived'];
const ROW_COUNT = 1000;
let things = Array.from({ length: ROW_COUNT }, (_, i) => ({
    id: String(i + 1), name: `${ADJ[i % ADJ.length]} ${NOUN[Math.floor(i / ADJ.length) % NOUN.length]} ${Math.floor(i / (ADJ.length * NOUN.length)) + 1}`,
    status: STATUSES[i % STATUSES.length], updated: `Sep ${(i % 28) + 1}`,
}));

// pk-data-table owns the query (search, status filter, sort, pager) and the states; this only answers load(query) from js/list-query.js (a generic
// filter/search/sort/paginate function) and asks for a reload after a save.
table.load = q => queryList(things, { filter: q.filters?.status ? row => row.status === q.filters.status : null, search: q.search, searchKeys: ['name'], sort: q.sort, sortDir: q.sortDir, page: q.page, pageSize: q.pageSize });

const route = () => { const [, id] = /^#\/things\/([^/]+)$/.exec(location.hash) ?? []; return id ?? null; };
const go = id => { location.hash = id ? `/things/${encodeURIComponent(id)}` : '/things'; };

// Route to screen: the aside opens when a record is active, the phone shows the list or the record, the tab and the header carry the record's name,
// the open row is marked in the table, and the form is filled once per record (a route change to the same record does not wipe what was typed).
let shown;
function render() {
    const id = route();
    const row = id && id !== 'new' ? things.find(r => r.id === id) : null;
    const name = id === 'new' ? 'New thing' : row?.name ?? (id ? 'Thing not found' : 'Thing');
    workspace.asideOpen = id !== null;
    workspace.activePane = id !== null ? 'aside' : 'main';
    workspace.asideLabel = name;
    head.heading = name;
    table.currentRow = row ? row.id : '';
    if (id !== shown) { shown = id; form.reset(); form.elements.name.value = row?.name ?? ''; form.elements.status.value = row?.status ?? 'Draft'; }
}

// The list: opening a row is a navigation, not a state change of its own.
table.addEventListener('pk-row-click', e => go(e.detail.id));
document.getElementById('new-thing').addEventListener('pk-select', () => go('new'));

// Back, close and Cancel leave the record. The phone strip's list tab does the same: the workspace announces the tab first (pk-pane-change is
// cancelable), so the handler keeps the pane the route asked for and changes the route instead; the route then sets the pane.
for (const id of ['detail-back', 'detail-close', 'detail-cancel']) document.getElementById(id).addEventListener('click', () => go(null));
workspace.addEventListener('pk-pane-change', e => { if (e.detail.pane === 'main' && route() !== null) { e.preventDefault(); go(null); } });

// Save: pk-form raises pk-valid when the browser's checks pass; the demo keeps the change in memory and returns to the list.
validation.addEventListener('pk-valid', () => {
    const id = route(); const data = new FormData(form); const name = String(data.get('name')); const status = String(data.get('status'));
    things = id === 'new' ? [...things, { id: String(things.length + 1), name, status, updated: 'Today' }] : things.map(r => (r.id === id ? { ...r, name, status, updated: 'Today' } : r));
    table.refresh();
    go(null);
});

// The elements load on demand: wait until the ones this page sets properties on are defined, then follow the route.
await Promise.all(['pk-workspace', 'pk-data-table', 'pk-page-header', 'pk-dropdown', 'pk-menu-item', 'pk-form', 'pk-input', 'pk-select'].map(tag => customElements.whenDefined(tag)));
window.addEventListener('hashchange', render);
render();
```
