# Composed patterns

> Plainkit 0.28.0. Generated by scripts/build-skills.mjs from core/samples/patterns and the element API. Do not edit: change the source and run the generator.

Small compositions of elements for a common job (confirming a delete, filtering a table, forms, notifications). The elements bring their behaviour; a pattern whose wiring is not declarative (a toast on demand, an unsaved bar, results that filter, steps that advance, a selection that fills a detail) also ships a script, shown after its markup, that you adapt to your data. Patterns without a script are markup only.

## filter-table: Filter bar, table and bulk actions

A pk-data-table: search, a status filter, sortable columns, a pager and row selection from one load(query) callback; choosing rows reveals a bulk-action bar.

Built from: Data table (search, filter, sort, pager, selection and the states), Button group, Button.

On a phone: Filters wrap; the table scrolls inside its wrapper; the bulk bar stays under the header.

Elements used: `pk-button` `pk-button-group` `pk-data-table`.

```html
<pk-data-table selectable data-table label="Items" page-size="5" search-label="Search items" columns='[{"key":"name","label":"Name","sortable":true},{"key":"status","label":"Status","sortable":true},{"key":"amount","label":"Amount","type":"number","sortable":true}]' filters='[{"key":"status","type":"multiselect","label":"Status","options":["Active","Draft","Review"]}]' empty='{"heading":"No items yet"}' no-results='{"heading":"No matching items","description":"Remove a filter to see more."}'>
<pk-button-group slot="bulk" label="Bulk actions"><pk-button size="mini" variant="ghost">Archive</pk-button><pk-button size="mini" variant="ghost">Export</pk-button><pk-button size="mini" variant="warn" data-delete>Delete</pk-button></pk-button-group>
</pk-data-table>
```

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 filter-table pattern, made live: pk-data-table owns the search, the status filter, the sort, the pager and the selection; this only supplies the rows
// through its load(query) callback (a real app calls its API here) and acts on the selection when a bulk button is pressed.
// mount(root) works on this sample's own DOM and returns { destroy() }.
import { createLogger } from './plainkit/js/log.js';
import { queryList } from './plainkit/js/list-query.js';

const log = createLogger('pattern:filter-table');
const STATUS = ['Active', 'Draft', 'Review'];

export default function mount(root) {
    const ac = new AbortController();
    const table = root.querySelector('[data-table]');
    if (!table) { log.warn('the filter-table sample needs a [data-table] pk-data-table', { root }); return { destroy() {} }; }
    let items = Array.from({ length: 23 }, (_, i) => ({ id: String(i + 1), name: `Item ${i + 1}`, status: STATUS[i % 3], amount: `$${(5 + i * 3.25).toFixed(2)}` }));
    let picked = [];
    table.load = q => queryList(items, { filter: q.filters?.status?.length ? r => q.filters.status.includes(r.status) : null, search: q.search, searchKeys: ['name'], sort: q.sort, sortDir: q.sortDir, page: q.page, pageSize: q.pageSize });
    table.addEventListener('pk-select', e => { picked = e.detail.selected; }, { signal: ac.signal });
    // The bulk bar's Delete: the ids of the chosen rows (scope "all" would run the query on the server instead), then a reload.
    root.addEventListener('click', e => {
        if (!e.target.closest?.('[data-delete]') || !picked.length) return;
        items = items.filter(r => !picked.includes(r.id));
        picked = []; table.selected = []; table.refresh();
    }, { signal: ac.signal });
    return { destroy() { ac.abort(); table.load = null; } };
}
```

## confirm-delete: Confirm before delete

A destructive action asks first: the modal names what will be lost and offers Cancel; the backdrop never dismisses it.

Built from: Button, Dialog, Alert, Card, Stack.

On a phone: The modal fills the screen; both buttons are full-size targets.

Elements used: `pk-alert` `pk-button` `pk-card` `pk-dialog` `pk-stack`.

```html
<pk-card heading="Item two"><pk-stack gap="sm"><p>Deleting removes the item and its history.</p><div><pk-button variant="warn" data-open="#confirm">Delete item</pk-button></div></pk-stack></pk-card>
<pk-dialog id="confirm" heading="Delete Item two?" size="sm"><pk-stack gap="sm"><pk-alert kind="warning">This cannot be undone.</pk-alert><p>Its 3 history entries are deleted as well.</p></pk-stack><pk-button slot="footer" variant="ghost" data-close>Cancel</pk-button><pk-button slot="footer" variant="warn" data-close>Delete</pk-button></pk-dialog>
```

## unsaved-settings: Settings with unsaved changes

A sectioned form with a sticky save bar that says there are unsaved changes and offers Discard.

Built from: Card, Field, Input, Select, Switch, Range, Accordion item, Form actions, Alert, Button.

On a phone: The save bar sticks to the bottom; buttons stay 44px.

Elements used: `pk-accordion-item` `pk-alert` `pk-button` `pk-card` `pk-field` `pk-field-row` `pk-form-actions` `pk-input` `pk-range` `pk-select` `pk-stack` `pk-switch`.

The full text is long (3417 characters): it is in `references/full/patterns.md`, section `unsaved-settings` (kept apart so the group files stay short).

## notifications: Notifications and toasts

Transient toasts for confirmations, inline notices for what needs attention, and a count badge on the bell.

Built from: Toast, Toast stack, Alert, Button, Badge.

On a phone: Toasts stack full-width at the bottom.

Elements used: `pk-alert` `pk-badge` `pk-button` `pk-cluster` `pk-stack` `pk-toast-stack`.

The full text is long (3147 characters): it is in `references/full/patterns.md`, section `notifications` (kept apart so the group files stay short).

## search-results: Search results and command palette

A search field with a keyboard hint over results grouped by type, each row showing a snippet with the match marked.

Built from: Input, List group, Empty state, Typography (kbd, mark).

On a phone: The field is full width; results are single-line rows with 44px targets.

Elements used: `pk-empty-state` `pk-input` `pk-list-group` `pk-stack` `pk-text`.

The full text is long (5475 characters): it is in `references/full/patterns.md`, section `search-results` (kept apart so the group files stay short).

## onboarding: Onboarding checklist

A short checklist with a progress bar: done, current and upcoming steps, and one clear next action.

Built from: Card, Progress, Stepper, List group, Button.

On a phone: The stepper wraps; the next action is full width.

Elements used: `pk-button` `pk-card` `pk-list-group` `pk-progress` `pk-stack` `pk-step` `pk-stepper` `pk-text`.

The full text is long (3719 characters): it is in `references/full/patterns.md`, section `onboarding` (kept apart so the group files stay short).

## data-display: Stock report (data display)

A report page: key figures with change and trend, charts drawn from tables, a sortable selectable table with paging, activity, people, reference fields, a calendar, a tree and code, all as elements.

Built from: Stat, Card, Chart, Progress, Table, Pagination, List group, Timeline, Avatar, Tag, Badge, Field list, Media, Calendar, Tree, Accordion, Code block, Skeleton, Spinner, Typography.

On a phone: Grids collapse to one column; the table drops secondary columns and becomes cards; every control keeps a 44px target.

Elements used: `pk-accordion` `pk-accordion-item` `pk-avatar` `pk-avatar-group` `pk-badge` `pk-button` `pk-calendar` `pk-card` `pk-chart` `pk-cluster` `pk-code-block` `pk-divider` `pk-empty-state` `pk-field-list` `pk-grid` `pk-hint` `pk-list-group` `pk-media` `pk-pagination` `pk-progress` `pk-skeleton` `pk-spinner` `pk-stack` `pk-stat` `pk-table` `pk-tag` `pk-timeline` `pk-timeline-item` `pk-tree` `pk-tree-item`.

The full text is long (6239 characters): it is in `references/full/patterns.md`, section `data-display` (kept apart so the group files stay short).

## forms: Product settings (forms and inputs)

A settings form built only from form elements: sectioned fields with help, counters and validation, money and stepper inputs, dual range, choice controls, tag and colour inputs, a drop zone and a sticky action bar.

Built from: Form, Form section, Field, Input, Textarea, Select, Range, Checkbox, Radio group, Switch, Tag input, Colour input, Dropzone, Form actions, Button.

On a phone: The form is one column; controls keep a 44px height and 16px text so a phone does not zoom on focus.

Elements used: `pk-button` `pk-checkbox` `pk-colour-input` `pk-combobox` `pk-dropzone` `pk-field` `pk-form` `pk-form-actions` `pk-form-section` `pk-input` `pk-otp-input` `pk-radio-group` `pk-range` `pk-rating` `pk-select` `pk-split-button` `pk-switch` `pk-tag-input` `pk-textarea`.

The full text is long (4832 characters): it is in `references/full/patterns.md`, section `forms` (kept apart so the group files stay short).

## pricing-cards: Pricing cards

Plans side by side: a name, a price, three or four inclusions and one action per card, with a badge on the recommended plan.

Built from: Grid, Card, Badge, List, Text, Button.

On a phone: The cards stack to one column; each button is full width and keeps 44px.

Elements used: `pk-badge` `pk-button` `pk-card` `pk-grid` `pk-list` `pk-stack` `pk-text`.

```html
<pk-grid min="16rem">
<pk-card heading="Starter" level="3"><pk-stack><pk-text variant="h2">$0 <pk-text inline tone="muted">per month</pk-text></pk-text><pk-list dense><li>One project</li><li>Community support</li></pk-list><pk-button block>Start free</pk-button></pk-stack></pk-card>
<pk-card heading="Team" level="3"><pk-badge slot="actions">Popular</pk-badge><pk-stack><pk-text variant="h2">$12 <pk-text inline tone="muted">per seat, per month</pk-text></pk-text><pk-list dense><li>Unlimited projects</li><li>Email support</li></pk-list><pk-button variant="primary" block>Choose Team</pk-button></pk-stack></pk-card>
<pk-card heading="Business" level="3"><pk-stack><pk-text variant="h2">Talk to us</pk-text><pk-list dense><li>Single sign-on</li><li>Named contact</li></pk-list><pk-button block>Contact sales</pk-button></pk-stack></pk-card>
</pk-grid>
```

## markdown-editor: Markdown editor (Write and Preview)

The supported answer to rich text: a pk-textarea holding Markdown, a Preview tab that draws it, no contenteditable and no HTML stored.

Built from: Card, Tabs, Field, Textarea, Stack.

On a phone: The tabs stay one row and the textarea fills the width at 16px text.

Elements used: `pk-card` `pk-field` `pk-stack` `pk-tab` `pk-tab-panel` `pk-tabs` `pk-textarea`.

```html
<pk-card heading="Release notes">
<pk-tabs value="write" data-tabs>
<pk-tab value="write">Write</pk-tab>
<pk-tab value="preview">Preview</pk-tab>
<pk-tab-panel value="write"><pk-field label="Markdown" help="# Heading, - list item, **bold**. Stored as text."><pk-textarea name="notes" rows="8" data-source value="## What changed
- Faster search
- **New** export button"></pk-textarea></pk-field></pk-tab-panel>
<pk-tab-panel value="preview"><pk-stack data-preview aria-live="polite"></pk-stack></pk-tab-panel>
</pk-tabs>
</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 markdown-editor pattern, made live: the Preview tab draws the textarea's text with DOM APIs only (textContent only), so
// the stored value stays Markdown and nothing typed can inject markup. Supports #, ##, ###, "- " lists and **bold**. mount(root) returns { destroy() }.
import { createLogger } from './plainkit/js/log.js';

const log = createLogger('pattern:markdown-editor');

// Text with **bold** runs appended to a parent as text nodes and <strong> elements.
function inline(parent, text) {
    text.split(/\*\*(.+?)\*\*/).forEach((part, i) => {
        if (i % 2) { const b = document.createElement('strong'); b.textContent = part; parent.append(b); } else parent.append(part);
    });
}

function render(md) {
    const out = [];
    let list = null;
    for (const line of md.split(/\r?\n/)) {
        const h = /^(#{1,3}) (.*)$/.exec(line), li = /^[-*] (.*)$/.exec(line);
        if (li) { if (!list) { list = document.createElement('ul'); out.push(list); } const item = document.createElement('li'); inline(item, li[1]); list.append(item); continue; }
        list = null;
        if (!line.trim()) continue;
        const el = document.createElement(h ? `h${h[1].length + 2}` : 'p');
        inline(el, h ? h[2] : line);
        out.push(el);
    }
    return out;
}

export default function mount(root) {
    const ac = new AbortController();
    const source = root.querySelector('[data-source]'), preview = root.querySelector('[data-preview]'), tabs = root.querySelector('[data-tabs]');
    if (!source || !preview || !tabs) { log.warn('the markdown-editor sample needs [data-source], [data-preview] and [data-tabs]', { root }); return { destroy() {} }; }
    tabs.addEventListener('pk-tab-change', e => { if (e.detail.value === 'preview') preview.replaceChildren(...render(source.value ?? '')); }, { signal: ac.signal });
    return { destroy() { ac.abort(); } };
}
```

## marketing-sections: Marketing sections

The four blocks of a marketing page: a testimonial, a logo strip, a contact block and a closing call-to-action band.

Built from: Quote and CtaBand (elements); logo strip (Grid, Media) and contact block (address, FieldList) as recipes.

On a phone: The logos wrap, the contact list stacks and the band's buttons are full width and keep 44px.

Elements used: `pk-avatar` `pk-button` `pk-cta-band` `pk-field-list` `pk-grid` `pk-media` `pk-quote` `pk-rating` `pk-stack` `pk-text`.

```html
<pk-stack gap="lg">
<pk-quote>
<blockquote><p>Setup took an afternoon, and nobody had to be retrained.</p></blockquote>
<pk-avatar slot="portrait" name="Alex Roe"></pk-avatar>
<span slot="name">Alex Roe</span>
<span slot="role">Operations lead</span>
<pk-rating slot="rating" readonly value="5" label="5 out of 5"></pk-rating>
</pk-quote>
<pk-stack gap="sm">
<pk-text variant="eyebrow">Trusted by teams at</pk-text>
<pk-grid min="9rem" tracks="repeat(5, minmax(0, 10rem))" gap="md">
<a href="#acme" aria-label="Acme"><pk-media ratio="3/1"><svg viewBox="0 0 3 1" role="img" aria-label="Acme logo"><rect width="3" height="1" fill="currentColor" opacity="0.15"></rect></svg></pk-media></a>
<a href="#globex" aria-label="Globex"><pk-media ratio="3/1"><svg viewBox="0 0 3 1" role="img" aria-label="Globex logo"><rect width="3" height="1" fill="currentColor" opacity="0.15"></rect></svg></pk-media></a>
<a href="#initech" aria-label="Initech"><pk-media ratio="3/1"><svg viewBox="0 0 3 1" role="img" aria-label="Initech logo"><rect width="3" height="1" fill="currentColor" opacity="0.15"></rect></svg></pk-media></a>
<a href="#umbrella" aria-label="Umbrella"><pk-media ratio="3/1"><svg viewBox="0 0 3 1" role="img" aria-label="Umbrella logo"><rect width="3" height="1" fill="currentColor" opacity="0.15"></rect></svg></pk-media></a>
<a href="#hooli" aria-label="Hooli"><pk-media ratio="3/1"><svg viewBox="0 0 3 1" role="img" aria-label="Hooli logo"><rect width="3" height="1" fill="currentColor" opacity="0.15"></rect></svg></pk-media></a>
</pk-grid>
</pk-stack>
<pk-field-list heading="Contact us">
<dt>Phone</dt><dd>+1 555 0100</dd>
<dt>Email</dt><dd>hello@example.com</dd>
<dt>Address</dt><dd>1 Example Street, Springfield</dd>
<dt>Hours</dt><dd>Monday to Friday, 9 to 17</dd>
</pk-field-list>
<pk-cta-band tone="brand" align="center">
<h2 slot="heading">Ready to start?</h2>
<p>Free for 30 days. No card needed.</p>
<span slot="actions"><pk-button block href="#signup">Start free</pk-button><pk-button block href="#demo">Book a demo</pk-button></span>
</pk-cta-band>
</pk-stack>
```

## Lists: select rows across the pages of a `pk-table`

A `manual` table shows one page, so select-all has two meanings. Set `total` to the number of rows that match the current query (all pages) and the selection spans pages: `selected` keeps its ids when you replace `rows` for paging, search or sort (clear it yourself when the query changes). Once every loaded row is selected the bulk bar offers a "Select all N rows" button (a real, named button inside the polite status); choosing it sets `selectScope` to `"all"` and raises `pk-select-all` with `{ scope: 'all', count: total }`. The event never carries the ids: the host expands "all" by asking its data source again with the query it already holds. Selecting the loaded rows raises `pk-select-all` with `{ scope: 'page', count }` (and `pk-select` with the ids); any change of the selection, or clearing it ("Clear selection" in the same button), returns `selectScope` to `"page"`.

## Lists: page, search and select with `pk-data-table`

Do not wire `pk-table`, `pk-table-filters` and `pk-pagination` together by hand: one `pk-data-table` owns the query, the load and the states. Set its plain props (`columns`, `filters`, `empty`, `noResults`, `loadError`, `page-size`, `page-size-options`, `sort`, `sort-dir`, `hide-search`, `search`, `search-label`, `search-debounce`, `pager-label`, `label`, `caption`, `add-label`) and the `load(query)` callback property (`query` is `{ page, pageSize, sort, sortDir, search, filters }`, return `{ rows, total }`; a second argument `{ signal }` is an `AbortSignal` aborted when a newer request replaces it, so pass it to `fetch`); it shows loading, an error with Retry and the empty state, and drops a response that a newer request has overtaken. `refresh()` reloads, `query` reads the current query. With `selectable` the ids in `selected` survive paging and searching and `pk-select` carries `{ selected, scope, query }`: for scope `"all"` ("Select all N rows") run the bulk action on the server against `query`, never against `selected`; a new search or filter returns the scope to `"page"`. Custom cell content is a child with `slot="cell-<rowId>-<key>"` (as in `pk-table`). Rows open through `clickable` (or a `rowHref(row)` callback): a click or Enter on the focused row raises `pk-row-click` with `{ id, row }` on the table, and `currentRow` (a row id) marks the row open elsewhere. `cards`, `striped`, `density`, `maxHeight` and `stickyHeader` go straight to the table as on `pk-table`. Bulk-action buttons go in a child with `slot="bulk"` (shown in the selection bar). A child with `slot="empty"` replaces the built-in empty state (when no search is active); `no-results` words the empty state while searching and `load-error` the error heading; a rejected load also raises `pk-load-error` with `{ error }`. A reader's sort, search, filter, page or page-size change raises `pk-query-change` with `{ sortKey, descending, search, page, pageSize, filters }` (not for props set from script). A list page is `pk-list-page` (title bar, actions, route wiring) on top of it; its `list` page type takes `selectable`, `rowKey`, `bulkActions: [{ id, label, variant? }]` and the callbacks `onSelect(detail, ctx)` (the `pk-select` detail) and `onBulk({ action, selected, scope, query }, ctx)` (return a promise: the list reloads and the selection clears when it settles).
