# Composed patterns, the long samples

> Plainkit 0.28.0. Generated by scripts/build-skills.mjs from core/samples and patterns.md. Do not edit: change the source and run the generator.

The sections of `../patterns.md` that are too long to keep in the npm package (markup, script or module header), by id.

## unsaved-settings

```html
<form><pk-stack>
<pk-alert kind="success" data-saved hidden>Settings saved.</pk-alert>
<pk-card heading="Store"><pk-field-row><pk-field label="Name" help="Shown on packing slips."><pk-input value="Example store"></pk-input></pk-field><pk-field label="Currency"><pk-select value="USD"><option>USD</option><option>EUR</option><option>GBP</option></pk-select></pk-field></pk-field-row></pk-card>
<pk-card heading="Sync"><pk-switch checked>Sync automatically</pk-switch></pk-card>
<pk-accordion-item heading="Advanced"><pk-field label="Rate limit"><pk-range min="10" max="120" value="60" output></pk-range></pk-field></pk-accordion-item>
<pk-form-actions sticky data-bar><span slot="status"><pk-alert kind="warning" plain inline compact>Unsaved changes</pk-alert></span><pk-button type="button" data-save>Save</pk-button><pk-button type="button" variant="ghost" data-discard>Discard</pk-button></pk-form-actions>
</pk-stack></form>
```

Script (the gallery runs it after the markup is on the page, in the full-page view and in the inline views alike: `mount(root)` gets the element that holds the markup, works only inside it, and returns `{ destroy() }` (call it when the markup is removed, so its listeners go); its imports assume `plainkit/` is a copy of `dist`):

```js
// The unsaved-settings pattern, made live: the save bar appears when a field differs from what was last saved, Save keeps the values
// and hides it, Discard puts the saved values back. mount(root) works on this sample's own DOM and returns { destroy() }.
import { createLogger } from './plainkit/js/log.js';

const log = createLogger('pattern:unsaved-settings');

const FIELDS = 'pk-input, pk-select, pk-switch, pk-range';
// The value a field holds, in the shape its element commits it.
const read = f => (f.localName === 'pk-switch' ? f.checked : f.value);
const write = (f, v) => { if (f.localName === 'pk-switch') f.checked = v; else f.value = v; };

export default function mount(root) {
    const ac = new AbortController();
    const form = root.querySelector('form');
    const bar = root.querySelector('[data-bar]');
    const saved = root.querySelector('[data-saved]');
    if (!form || !bar) { log.warn('the unsaved-settings sample needs a form and a [data-bar]', { root }); return { destroy() {} }; }

    const fields = [...form.querySelectorAll(FIELDS)];
    let base = new Map();
    const snapshot = () => { base = new Map(fields.map(f => [f, read(f)])); };
    const dirty = () => fields.some(f => read(f) !== base.get(f));
    const paint = () => { bar.hidden = !dirty(); };

    // Elements upgrade on load: take the saved values once every field element is defined.
    Promise.all([...new Set(fields.map(f => f.localName))].map(t => customElements.whenDefined(t))).then(() => {
        if (ac.signal.aborted) return;
        snapshot(); paint();
    });
    bar.hidden = true;

    const onChange = () => { if (saved) saved.hidden = true; paint(); };
    for (const type of ['input', 'change', 'pk-change', 'pk-range']) form.addEventListener(type, onChange, { signal: ac.signal });
    root.addEventListener('click', e => {
        if (e.target.closest?.('[data-save]')) { snapshot(); paint(); if (saved) saved.hidden = false; }
        else if (e.target.closest?.('[data-discard]')) { for (const [f, v] of base) write(f, v); paint(); }
    }, { signal: ac.signal });

    return { destroy() { ac.abort(); } };
}
```

## notifications

```html
<pk-stack>
<pk-cluster><pk-button variant="ghost" data-bell aria-label="Notifications, mark all as read">Notifications <pk-badge count="3" variant="danger" data-unread></pk-badge></pk-button></pk-cluster>
<pk-alert kind="success">Item saved.</pk-alert>
<pk-alert kind="warning">Two items need a price.</pk-alert>
<pk-cluster><pk-button data-toast="saved">Save item</pk-button><pk-button variant="ghost" data-toast="archived">Archive item</pk-button><pk-button variant="ghost" data-toast="failed">Export</pk-button></pk-cluster>
<pk-toast-stack></pk-toast-stack>
</pk-stack>
```

Script (the gallery runs it after the markup is on the page, in the full-page view and in the inline views alike: `mount(root)` gets the element that holds the markup, works only inside it, and returns `{ destroy() }` (call it when the markup is removed, so its listeners go); its imports assume `plainkit/` is a copy of `dist`):

```js
// The notifications pattern, made live: each button raises a toast, the bell counts what was raised and clears when it is pressed.
// mount(root) works on this sample's own DOM (root) and returns { destroy() }; the toasts it raised are removed by destroy().
import { createLogger } from './plainkit/js/log.js';

const log = createLogger('pattern:notifications');

export default function mount(root) {
    const ac = new AbortController();
    const raised = new Set();
    const badge = root.querySelector('[data-unread]');
    let unread = Number(badge?.getAttribute('count')) || 0;
    if (!badge || !root.querySelector('[data-bell]')) log.warn('the notifications sample needs [data-bell] and [data-unread]', { root });

    const paint = () => { if (!badge) return; badge.hidden = unread === 0; badge.setAttribute('count', String(unread)); };
    // PkToast is defined by the toast stack in the sample: nothing can be shown before it has loaded.
    const toast = (message, options) => {
        const PkToast = globalThis.PkToast;
        if (!PkToast) { log.warn('PkToast is not defined yet: the toast stack has not loaded', { message }); return; }
        const t = PkToast.show(message, options);
        raised.add(t);
        t.addEventListener('pk-dismiss', () => raised.delete(t), { once: true, signal: ac.signal });
        unread += 1; paint();
    };
    const say = {
        saved: () => toast('Item saved.', { kind: 'success' }),
        archived: () => toast('Item archived.', { action: { label: 'Undo', onClick: () => toast('Archive undone.') } }),
        failed: () => toast('The export failed.', { kind: 'danger', action: { label: 'Retry', onClick: () => toast('Export ready.', { kind: 'success' }) } }),
    };

    root.addEventListener('click', e => {
        const b = e.target.closest?.('[data-toast]');
        if (b) { const run = say[b.dataset.toast]; if (run) run(); else log.warn(`data-toast="${b.dataset.toast}" is not one of ${Object.keys(say).join(', ')}`, { button: b }); return; }
        if (e.target.closest?.('[data-bell]')) { unread = 0; paint(); }
    }, { signal: ac.signal });

    return { destroy() { ac.abort(); for (const t of raised) t.remove(); raised.clear(); } };
}
```

## search-results

```html
<pk-stack>
<pk-input type="search" label="Search everything" value="item" data-query><span slot="prefix">Search</span><span slot="suffix"><kbd>Ctrl</kbd> <kbd>K</kbd></span></pk-input>
<pk-stack gap="sm" data-group><pk-text variant="eyebrow">Products <pk-text inline tone="muted" data-count>(2)</pk-text></pk-text><pk-list-group variant="action" label="Products"><button type="button"><span><pk-text inline tone="muted" data-n>1</pk-text> <span data-label><mark>Item</mark> one</span></span></button><button type="button"><span><pk-text inline tone="muted" data-n>2</pk-text> <span data-label><mark>Item</mark> two</span></span></button><button type="button" hidden><span><pk-text inline tone="muted" data-n>3</pk-text> <span data-label>Widget kit</span></span></button><button type="button" hidden><span><pk-text inline tone="muted" data-n>4</pk-text> <span data-label>Shipping label</span></span></button></pk-list-group></pk-stack>
<pk-stack gap="sm" data-group><pk-text variant="eyebrow">Orders <pk-text inline tone="muted" data-count>(1)</pk-text></pk-text><pk-list-group variant="action" label="Orders"><button type="button"><span><pk-text inline tone="muted" data-n>1</pk-text> <span data-label>Order for <mark>item</mark> three</span></span></button><button type="button" hidden><span><pk-text inline tone="muted" data-n>2</pk-text> <span data-label>Order 1042</span></span></button></pk-list-group></pk-stack>
<pk-stack gap="sm" data-group hidden><pk-text variant="eyebrow">Customers <pk-text inline tone="muted" data-count>(0)</pk-text></pk-text><pk-list-group variant="action" label="Customers"><button type="button" hidden><span><pk-text inline tone="muted" data-n>1</pk-text> <span data-label>Acme Supply</span></span></button></pk-list-group></pk-stack>
<pk-empty-state heading="No results" description="Try a different word." data-empty hidden></pk-empty-state>
</pk-stack>
```

Script (the gallery runs it after the markup is on the page, in the full-page view and in the inline views alike: `mount(root)` gets the element that holds the markup, works only inside it, and returns `{ destroy() }` (call it when the markup is removed, so its listeners go); its imports assume `plainkit/` is a copy of `dist`):

```js
// The search-results pattern, made live: typing filters the rows of every group, marks the match, renumbers the rows, hides a group with no
// match and shows an empty state when nothing is left. Ctrl+K (Cmd+K) focuses the field; ArrowDown moves into the results.
// mount(root) works on this sample's own DOM and returns { destroy() }; the one listener outside it (the shortcut on the document) is removed there.
import { createLogger } from './plainkit/js/log.js';
import { isPaletteShortcut } from './plainkit/js/palette-logic.js';

const log = createLogger('pattern:search-results');

export default function mount(root) {
    const ac = new AbortController();
    const query = root.querySelector('[data-query]');
    const groups = [...root.querySelectorAll('[data-group]')];
    const empty = root.querySelector('[data-empty]');
    if (!query || !groups.length) { log.warn('the search-results sample needs a [data-query] and [data-group] blocks', { root }); return { destroy() {} }; }

    // Each row's plain text, taken once: the marks are drawn from it, so a new query never reads text a previous one changed.
    const rows = groups.map(g => [...g.querySelectorAll('pk-list-group > button')].map(row => {
        const label = row.querySelector('[data-label]');
        if (!label) log.warn('a result row has no [data-label]', { row });
        return { row, label, n: row.querySelector('[data-n]'), text: label?.textContent ?? '' };
    }));

    const draw = (label, text, needle) => {
        const at = needle ? text.toLowerCase().indexOf(needle) : -1;
        if (at < 0) { label.textContent = text; return; }
        const mark = document.createElement('mark');
        mark.textContent = text.slice(at, at + needle.length);
        label.replaceChildren(text.slice(0, at), mark, text.slice(at + needle.length));
    };
    const filter = value => {
        const needle = String(value ?? '').trim().toLowerCase();
        let total = 0;
        groups.forEach((g, gi) => {
            let shown = 0;
            for (const r of rows[gi]) {
                const hit = r.label && (needle === '' || r.text.toLowerCase().includes(needle));
                r.row.hidden = !hit;
                if (!hit) continue;
                shown += 1;
                draw(r.label, r.text, needle);
                if (r.n) r.n.textContent = String(shown);
            }
            g.hidden = shown === 0;
            const count = g.querySelector('[data-count]');
            if (count) count.textContent = `(${shown})`;
            total += shown;
        });
        if (empty) empty.hidden = total > 0;
    };

    filter(query.getAttribute('value'));
    query.addEventListener('input', () => filter(query.value), { signal: ac.signal });
    query.addEventListener('keydown', e => {
        if (e.key !== 'ArrowDown') return;
        const first = root.querySelector('[data-group]:not([hidden]) pk-list-group > button:not([hidden])');
        if (first) { e.preventDefault(); first.focus(); }
    }, { signal: ac.signal });
    document.addEventListener('keydown', e => {
        if (isPaletteShortcut(e)) { e.preventDefault(); query.focus(); }
    }, { signal: ac.signal });

    return { destroy() { ac.abort(); } };
}
```

## onboarding

```html
<pk-card heading="Get set up"><pk-text inline tone="muted" slot="actions" data-done>2 of 4 done</pk-text>
<pk-stack>
<pk-progress label="Setup progress" value="50" data-progress></pk-progress>
<pk-stepper label="Setup steps" current="2" data-stepper><pk-step heading="Create account"></pk-step><pk-step heading="Add a product"></pk-step><pk-step heading="Connect a channel"></pk-step><pk-step heading="Invite a teammate"></pk-step></pk-stepper>
<pk-list-group label="Next steps" data-next><div>Connect a channel <pk-button size="mini" data-start>Start</pk-button></div><div class="muted">Invite a teammate</div></pk-list-group>
</pk-stack></pk-card>
```

Script (the gallery runs it after the markup is on the page, in the full-page view and in the inline views alike: `mount(root)` gets the element that holds the markup, works only inside it, and returns `{ destroy() }` (call it when the markup is removed, so its listeners go); its imports assume `plainkit/` is a copy of `dist`):

```js
// The onboarding pattern, made live: Start completes the current step, the stepper moves on, and the progress bar, the "n of m done" text
// and the list of next steps follow it. After the last step the list says the setup is finished.
// mount(root) works on this sample's own DOM and returns { destroy() }.
import { createLogger } from './plainkit/js/log.js';

const log = createLogger('pattern:onboarding');

export default function mount(root) {
    const ac = new AbortController();
    const stepper = root.querySelector('[data-stepper]');
    const progress = root.querySelector('[data-progress]');
    const count = root.querySelector('[data-done]');
    const list = root.querySelector('[data-next]');
    if (!stepper || !progress || !list) { log.warn('the onboarding sample needs [data-stepper], [data-progress] and [data-next]', { root }); return { destroy() {} }; }

    const titles = [...stepper.querySelectorAll('pk-step')].map(s => s.getAttribute('heading') ?? '');
    let done = Number(stepper.getAttribute('current')) || 0; // steps completed: the stepper's current step is the first one not done
    const row = (text, { start = false, muted = false } = {}) => {
        const d = document.createElement('div');
        if (muted) d.className = 'muted';
        d.append(text);
        if (start) {
            const b = document.createElement('pk-button');
            b.setAttribute('size', 'mini'); b.setAttribute('data-start', ''); b.textContent = 'Start';
            d.append(' ', b);
        }
        return d;
    };
    const paint = () => {
        progress.setAttribute('value', String(Math.round((done / titles.length) * 100)));
        if (count) count.textContent = `${done} of ${titles.length} done`;
        list.replaceChildren(...(done >= titles.length ? [row('All steps are done.')] : [row(titles[done], { start: true }), ...titles.slice(done + 1).map(t => row(t, { muted: true }))]));
    };

    root.addEventListener('click', e => {
        if (!e.target.closest?.('[data-start]') || done >= titles.length) return;
        done += 1;
        // The stepper has no step after the last one, so it stays on it; the rest of the sample shows that everything is done.
        // The stepper is linear (its next() will not skip ahead) and this sample is the host, so it sets the step it now shows.
        if (done < titles.length) stepper.current = done;
        paint();
    }, { signal: ac.signal });
    // A step the reader picks in the stepper (when it is made clickable) moves the checklist with it.
    stepper.addEventListener('pk-step-change', e => { done = e.detail.index; paint(); }, { signal: ac.signal });

    paint();
    return { destroy() { ac.abort(); } };
}
```

## data-display

```html
<pk-stack>
<pk-grid min="14rem">
<pk-stat label="Sales, September" value="$18,420" delta="12.5" versus="last month" values="[12,15,14,17,18]"></pk-stat>
<pk-stat label="Open orders" value="37" delta="-3" versus="last week"></pk-stat>
<pk-stat label="Return rate" value="2.1%" delta="-0.4" invert tone="positive" versus="last month"></pk-stat>
<pk-stat label="Negative on hand" value="3" tone="critical" href="#products"></pk-stat>
</pk-grid>
<pk-grid columns="2" min="20rem">
<pk-card heading="Sales by month"><pk-chart kind="bar" caption="Sales, thousands of dollars"><table><thead><tr><th>Month</th><th>Sales</th></tr></thead><tbody><tr><th scope="row">Jun</th><td>12</td></tr><tr><th scope="row">Jul</th><td>15</td></tr><tr><th scope="row">Aug</th><td>17</td></tr><tr><th scope="row">Sep</th><td>18</td></tr></tbody></table></pk-chart></pk-card>
<pk-card heading="Orders by channel"><pk-chart kind="donut" caption="Orders by channel"><table><thead><tr><th>Channel</th><th>Orders</th></tr></thead><tbody><tr><th scope="row">Web</th><td>52</td></tr><tr><th scope="row">Market</th><td>31</td></tr><tr><th scope="row">Live</th><td>17</td></tr></tbody></table></pk-chart></pk-card>
</pk-grid>
<pk-card heading="Fulfilment"><pk-stack gap="sm"><pk-chart kind="stack" caption="Orders by stage"><table><thead><tr><th>Stage</th><th>Orders</th></tr></thead><tbody><tr><th scope="row">Shipped</th><td>50</td></tr><tr><th scope="row">Packed</th><td>30</td></tr><tr><th scope="row">Waiting</th><td>20</td></tr></tbody></table></pk-chart><pk-progress label="Receiving PO 1042" value="18" max="24" show-value variant="ok"></pk-progress></pk-stack></pk-card>
<pk-table label="Products" caption="Stock by product" striped hover selectable sticky-header cards filterable max-height="24rem" columns='[{"key":"sku","label":"SKU","sortable":true},{"key":"title","label":"Title","sortable":true},{"key":"publisher","label":"Publisher","sortable":true,"hidePhone":true},{"key":"price","label":"Price","type":"number","sortable":true},{"key":"onHand","label":"On hand","type":"number","sortable":true}]' rows='[{"id":1,"sku":"AC-001","title":"Widget 1","publisher":"Red","price":"$4.99","onHand":12},{"id":2,"sku":"AC-002","title":"Widget 2","publisher":"Red","price":"$3.99","onHand":4},{"id":3,"sku":"AC-010","title":"Widget 10","publisher":"Green","price":"$4.99","onHand":21},{"id":4,"sku":"AC-005","title":"Saga #5","publisher":"Blue","price":"$16.99","onHand":7}]'>
<pk-button slot="bulk" size="mini" variant="ghost">Archive</pk-button>
<pk-empty-state slot="empty" heading="No products match" description="Clear the filters to see everything."></pk-empty-state>
<pk-pagination slot="footer" page="1" pages="12" total="290" page-size="25"></pk-pagination>
</pk-table>
<pk-list-group variant="action" label="Shortcuts"><a href="#products">Receive PO 1042 <pk-badge count="3" variant="warn"></pk-badge></a><a href="#products">Print labels <pk-badge count="120"></pk-badge></a><a href="#products" aria-current="page">Low stock report</a></pk-list-group>
<pk-grid columns="2" min="20rem">
<pk-card heading="Order 1042"><pk-timeline label="Order history"><pk-timeline-item heading="Delivered" time="2026-09-18" status="done">Left at the front desk.</pk-timeline-item><pk-timeline-item heading="Shipped" time="2026-09-16" status="done">Tracking 9400 1000 0000.</pk-timeline-item><pk-timeline-item heading="Packed" time="2026-09-15" status="active"></pk-timeline-item><pk-timeline-item heading="Payment failed once" time="2026-09-14" status="danger">Retried and cleared.</pk-timeline-item></pk-timeline></pk-card>
<pk-card heading="Team"><pk-stack gap="sm"><pk-avatar-group max="3" label="Assigned to Ada, Sam, Ana and 2 others"><pk-avatar name="Ada Lovelace" status="online"></pk-avatar><pk-avatar name="Sam Lee" status="away"></pk-avatar><pk-avatar name="Ana Ruiz"></pk-avatar><pk-avatar name="Jo Park"></pk-avatar><pk-avatar name="Ty Ng"></pk-avatar></pk-avatar-group><pk-cluster gap="sm"><pk-tag removable>Green</pk-tag><pk-tag removable>Variant</pk-tag><pk-tag>Signed</pk-tag></pk-cluster><pk-cluster gap="md"><pk-badge dot variant="ok">Online</pk-badge><pk-badge dot variant="warn">Syncing</pk-badge><pk-badge dot variant="danger">Offline</pk-badge></pk-cluster></pk-stack></pk-card>
</pk-grid>
<pk-grid columns="2" min="20rem">
<pk-card heading="Product"><pk-field-list><dt>SKU</dt><dd><code>AC-001</code></dd><dt>Status</dt><dd><pk-badge variant="ok">Active</pk-badge></dd><dt>Vendor</dt><dd>Acme Supply</dd><dt>Cost</dt><dd>$2.10</dd></pk-field-list><pk-divider>Notes</pk-divider><pk-hint label="What is landed cost?">Freight and duties added to the item cost; it is a margin view and never changes the recorded cost.</pk-hint></pk-card>
<pk-card heading="Widget 1" orientation="horizontal"><pk-media slot="media" ratio="4/3" caption="Variant cover"><svg viewBox="0 0 4 3" role="img" aria-label="Placeholder cover"><rect width="4" height="3" fill="currentColor" opacity="0.15"></rect></svg></pk-media><p>First printing, near mint.</p></pk-card>
</pk-grid>
<pk-grid columns="2" min="20rem">
<pk-card heading="Receive date"><pk-calendar value="2026-09-19" marks='["2026-09-05","2026-09-24"]'></pk-calendar></pk-card>
<pk-card heading="Categories"><pk-tree label="Categories"><pk-tree-item label="Books" expanded selected><pk-tree-item label="Green"></pk-tree-item><pk-tree-item label="DC"></pk-tree-item></pk-tree-item><pk-tree-item label="Cards"><pk-tree-item label="Puzzles"></pk-tree-item></pk-tree-item></pk-tree></pk-card>
</pk-grid>
<pk-accordion exclusive><pk-accordion-item heading="Shipping" open>Ships in 2 business days.</pk-accordion-item><pk-accordion-item heading="Returns">30 days, unopened.</pk-accordion-item></pk-accordion>
<pk-code-block label="import.json" line-numbers max-height="12rem">{ "sku": "AC-001", "qty": 24, "unitCost": 2.10 }</pk-code-block>
<pk-cluster gap="md"><pk-skeleton variant="circle"></pk-skeleton><pk-spinner label="Saving"></pk-spinner><pk-spinner variant="dots" label="Working"></pk-spinner></pk-cluster>
<div class="prose"><p class="eyebrow">Note</p><p class="lead">Press <kbd>Ctrl</kbd> <kbd>K</kbd> to search.</p><blockquote class="quote">Count twice, receive once.</blockquote></div>
</pk-stack>
```

## forms

```html
<pk-form summary>
<form>
<pk-form-section heading="Details" description="Shown on the storefront, invoices and packing slips.">
<pk-field label="Product title" required help="Keep it under 80 characters." max="80"><pk-input name="title" value="Widget 1 Cover A" required data-msg-required="Enter a title."></pk-input></pk-field>
<pk-field label="SKU" required><pk-input name="sku" value="AC-1001" required></pk-input></pk-field>
<pk-field label="Status"><pk-select name="status" value="Active"><option>Active</option><option>Draft</option><option>Archived</option></pk-select></pk-field>
<pk-field label="Release date"><pk-input type="date" name="release" value="2026-09-19"></pk-input></pk-field>
<pk-field class="form-span" label="Description" help="Plain text; the storefront theme adds the formatting."><pk-textarea name="description" autogrow max-height="240" rows="3"></pk-textarea></pk-field>
</pk-form-section>

<pk-form-section heading="Pricing and stock" description="Cost feeds FIFO; price is what customers pay.">
<pk-field label="Price"><pk-input name="price" format="money" min="0" value="19.99"></pk-input></pk-field>
<pk-field label="Unit cost" help="From the purchase order line."><pk-input name="cost" format="money" min="0" value="9.5"></pk-input></pk-field>
<pk-field label="Quantity on hand"><pk-input name="onhand" type="number" stepper min="0" max="999" value="12"></pk-input></pk-field>
<pk-field label="Weight (oz)"><pk-input name="weight" type="number" stepper min="0" step="0.5" value="6.4"></pk-input></pk-field>
<pk-field class="form-span" label="Price range shown in filters"><pk-range name="range" dual output value-low="20" value-high="80"></pk-range></pk-field>
</pk-form-section>

<pk-form-section heading="Channels" description="Where this product is sold.">
<pk-field class="form-span" label="Primary channel"><pk-radio-group variant="segmented" name="channel" value="web"><option value="web">Web</option><option value="market">Market</option><option value="live">Live</option></pk-radio-group></pk-field>
<pk-field label="Vendor"><pk-combobox name="vendor" placeholder="Type to search"><option value="acme">Acme Supply</option><option value="diamond">Diamond</option><option value="local">Local shop</option></pk-combobox></pk-field>
<pk-field label="Shipping profile"><pk-combobox mode="select" name="shipping" value="ground" placeholder="Choose a profile"><option value="ground">Ground (3-5 days)</option><option value="priority">Priority</option><option value="pickup">Local pickup</option></pk-combobox></pk-field>
<div class="form-span"><pk-switch name="storefront" checked label-position="between">Active on the storefront</pk-switch><pk-switch name="preorder" label-position="between">Pre-order until the release date</pk-switch><pk-checkbox name="tax" checked>Charge sales tax</pk-checkbox></div>
</pk-form-section>

<pk-form-section heading="Condition, media and labels" description="Grading, images and search tags.">
<pk-field label="Condition"><pk-radio-group name="condition" value="nm"><option value="nm">Near mint</option><option value="vf">Very fine</option><option value="g">Good</option></pk-radio-group></pk-field>
<pk-field label="Customer rating shown on the page"><pk-rating name="rating" value="4"></pk-rating></pk-field>
<pk-field class="form-span" label="Tags" help="Enter or comma adds a tag."><pk-tag-input name="tags" max="8" value="dc,variant"></pk-tag-input></pk-field>
<pk-field class="form-span" label="Images" help="JPG or PNG, up to 5 MB each."><pk-dropzone name="images" accept="image/*" multiple max-size="5MB" max-files="4">Drop images here or click to choose<span slot="hint">JPG or PNG, up to 5 MB each, 4 files at most</span></pk-dropzone></pk-field>
<pk-field label="Label colour"><pk-colour-input name="colour" value="#4a90e2"></pk-colour-input></pk-field>
</pk-form-section>

<pk-form-section heading="Access" description="Sign-in details for the linked supplier portal.">
<pk-field label="Portal password"><pk-input type="password" name="portal" reveal autohide="15" autocomplete="current-password"></pk-input></pk-field>
<pk-field label="Verification code" help="From the authenticator app."><pk-otp-input name="code" length="6" separator-at="3"></pk-otp-input></pk-field>
<pk-field class="form-span" label="Search the catalogue"><pk-input type="search" name="q" clearable debounce="250" placeholder="Search products"></pk-input></pk-field>
</pk-form-section>

<pk-form-actions sticky align="end"><span slot="status">Unsaved changes</span><pk-button variant="ghost" type="reset">Cancel</pk-button><pk-split-button type="submit"><span>Save</span><button slot="menu" type="submit" value="close">Save and close</button><button slot="menu" type="submit" value="new">Save and new</button></pk-split-button></pk-form-actions>
</form>
</pk-form>
```
