@adia-ai/adia-ui-factory 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -3
- package/CHANGELOG.md +8 -0
- package/README.md +24 -1
- package/bin/adia-scaffold +93 -13
- package/package.json +2 -2
- package/references/shell-embed.md +10 -0
- package/references/spa-architecture.md +7 -0
- package/skills/adia-ui-kit/CHANGELOG.md +41 -0
- package/skills/adia-ui-kit/SKILL.md +442 -0
- package/skills/adia-ui-kit/assets/figma-make/guidelines/Guidelines.md +87 -0
- package/skills/adia-ui-kit/assets/figma-make/guidelines/components.md +142 -0
- package/skills/adia-ui-kit/assets/figma-make/guidelines/styles.md +118 -0
- package/skills/adia-ui-kit/assets/figma-make/guidelines/tokens.md +120 -0
- package/skills/adia-ui-kit/assets/team-scaffold/archive-README.md +30 -0
- package/skills/adia-ui-kit/assets/team-scaffold/drafts-README.md +57 -0
- package/skills/adia-ui-kit/assets/team-scaffold/inbox-README.md +76 -0
- package/skills/adia-ui-kit/assets/team-scaffold/outbox-README.md +46 -0
- package/skills/adia-ui-kit/assets/team-scaffold/team-root.README.md +51 -0
- package/skills/adia-ui-kit/assets/team-scaffold/tickets-README.md +208 -0
- package/skills/adia-ui-kit/assets/team-scaffold/tickets-REGISTRY.md +81 -0
- package/skills/adia-ui-kit/assets/templates/AGENTS.template.md +128 -0
- package/skills/adia-ui-kit/assets/templates/CLAUDE.template.md +1 -0
- package/skills/adia-ui-kit/assets/templates/FEEDBACK-OMISSIONS-DEFENSE.template.md +150 -0
- package/skills/adia-ui-kit/assets/templates/FEEDBACK.template.md +213 -0
- package/skills/adia-ui-kit/assets/templates/RESPONSE.template.md +114 -0
- package/skills/adia-ui-kit/assets/templates/figma-make-guidelines.md +90 -0
- package/skills/adia-ui-kit/evals/adversarial-content-trust.json +83 -0
- package/skills/adia-ui-kit/evals/evals.json +55 -0
- package/skills/adia-ui-kit/evals/routing-corpus.json +121 -0
- package/skills/adia-ui-kit/evals/teach-routing-cases.json +92 -0
- package/skills/adia-ui-kit/references/capabilities.md +245 -0
- package/skills/adia-ui-kit/references/catalog-lookup.md +186 -0
- package/skills/adia-ui-kit/references/changelog-archive/CHANGELOG-v1.md +8 -0
- package/skills/adia-ui-kit/references/changelog-archive/CHANGELOG-v2.md +3585 -0
- package/skills/adia-ui-kit/references/changelog-archive/CHANGELOG-v3.md +1899 -0
- package/skills/adia-ui-kit/references/common-gotchas-consumer.md +522 -0
- package/skills/adia-ui-kit/references/component-literacy.md +291 -0
- package/skills/adia-ui-kit/references/composition.md +245 -0
- package/skills/adia-ui-kit/references/correction-loop.md +462 -0
- package/skills/adia-ui-kit/references/css-policy.md +343 -0
- package/skills/adia-ui-kit/references/decision-model.md +158 -0
- package/skills/adia-ui-kit/references/diagnostic-output.md +57 -0
- package/skills/adia-ui-kit/references/docs-catalog.md +239 -0
- package/skills/adia-ui-kit/references/exclusions.md +225 -0
- package/skills/adia-ui-kit/references/feedback-authoring.md +466 -0
- package/skills/adia-ui-kit/references/figma-make.md +244 -0
- package/skills/adia-ui-kit/references/flows-auth.md +307 -0
- package/skills/adia-ui-kit/references/flows-content.md +206 -0
- package/skills/adia-ui-kit/references/flows-data.md +348 -0
- package/skills/adia-ui-kit/references/flows-interaction.md +181 -0
- package/skills/adia-ui-kit/references/flows-shells.md +336 -0
- package/skills/adia-ui-kit/references/gap-detection.md +266 -0
- package/skills/adia-ui-kit/references/harness.md +284 -0
- package/skills/adia-ui-kit/references/loading-protocol.md +406 -0
- package/skills/adia-ui-kit/references/local-link.md +198 -0
- package/skills/adia-ui-kit/references/mcp.md +401 -0
- package/skills/adia-ui-kit/references/migration.md +116 -0
- package/skills/adia-ui-kit/references/mission.md +194 -0
- package/skills/adia-ui-kit/references/mode-routing.md +298 -0
- package/skills/adia-ui-kit/references/patterns-agent.md +223 -0
- package/skills/adia-ui-kit/references/patterns-data.md +1066 -0
- package/skills/adia-ui-kit/references/patterns-forms.md +223 -0
- package/skills/adia-ui-kit/references/patterns-layout.md +326 -0
- package/skills/adia-ui-kit/references/patterns-recipes.md +1416 -0
- package/skills/adia-ui-kit/references/patterns-shells.md +1097 -0
- package/skills/adia-ui-kit/references/patterns-traits.md +259 -0
- package/skills/adia-ui-kit/references/patterns-wiring.md +339 -0
- package/skills/adia-ui-kit/references/pev-rationale.md +137 -0
- package/skills/adia-ui-kit/references/reasoning-gate.md +146 -0
- package/skills/adia-ui-kit/references/reasoning-methodologies.md +112 -0
- package/skills/adia-ui-kit/references/recon.md +192 -0
- package/skills/adia-ui-kit/references/rendering-model.md +431 -0
- package/skills/adia-ui-kit/references/response-authoring.md +398 -0
- package/skills/adia-ui-kit/references/session-log-schema.md +203 -0
- package/skills/adia-ui-kit/references/session-log-template.md +118 -0
- package/skills/adia-ui-kit/references/setup.md +652 -0
- package/skills/adia-ui-kit/references/skill-hygiene.md +135 -0
- package/skills/adia-ui-kit/references/spec-to-ui-reasoning.md +394 -0
- package/skills/adia-ui-kit/references/state.md +229 -0
- package/skills/adia-ui-kit/references/teach-protocol.md +421 -0
- package/skills/adia-ui-kit/references/theme.md +142 -0
- package/skills/adia-ui-kit/references/version-check.md +69 -0
- package/skills/adia-ui-kit/scripts/audit-decision-trace.mjs +198 -0
- package/skills/adia-ui-kit/scripts/audit-kit-roster.mjs +366 -0
- package/skills/adia-ui-kit/scripts/changelog-archive.mjs +126 -0
- package/skills/adia-ui-kit/scripts/check-adia-versions.py +404 -0
- package/skills/adia-ui-kit/scripts/run-skill-evals.mjs +358 -0
- package/skills/adia-ui-kit/scripts/validate-session-log.mjs +242 -0
- package/skills/adia-ui-kit/skill.json +102 -0
- package/skills/adia-ui-migration/CHANGELOG.md +47 -0
- package/skills/adia-ui-migration/SKILL.md +381 -0
- package/skills/adia-ui-migration/skill.json +29 -0
- package/skills/adia-ui-spa/SKILL.md +1 -1
- package/skills/adia-ui-verify/SKILL.md +2 -2
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
# Data & Dashboard Patterns
|
|
2
|
+
|
|
3
|
+
## Composition rule: no `<style>`, no inline `style=`
|
|
4
|
+
|
|
5
|
+
Training-data and gen-UI example pages are expected to be **pure primitive
|
|
6
|
+
composition**: the HTML contains only AdiaUI component tags + native HTML
|
|
7
|
+
(h1-h6, p, article, section, etc.). A `<style>` block or any `style="…"`
|
|
8
|
+
attribute in a page is a signal that the page has drifted — either the
|
|
9
|
+
author hasn't discovered the right primitive, or the layout needs a new
|
|
10
|
+
primitive extracted.
|
|
11
|
+
|
|
12
|
+
Concretely:
|
|
13
|
+
|
|
14
|
+
- **Layout comes from `col-ui`, `row-ui`, `grid-ui`, `stack-ui`** — not from
|
|
15
|
+
`display: flex; flex-direction: column` on a `<div>`.
|
|
16
|
+
- **Width constraints are set via design-token CSS variables on a
|
|
17
|
+
controller-owned element, not inline style.** For a 48rem reading column
|
|
18
|
+
on a doc-template page, setup.js does
|
|
19
|
+
`section.style.setProperty('--page-content-max-width', '48rem')` on the
|
|
20
|
+
existing `[data-content-body] > section` instead of declaring a local
|
|
21
|
+
`<style>` block.
|
|
22
|
+
- **Asymmetric ratios use `grid-ui[columns="N"]` + `[span="M"]`** — never
|
|
23
|
+
`style="grid-template-columns: …"`. See "Asymmetric Grid" below.
|
|
24
|
+
- **Child sizing inside row-ui** uses `[grow]` — never `style="flex:1"`.
|
|
25
|
+
- **"Fake" elements** — custom tag names dressed up in page-local CSS
|
|
26
|
+
(`<tool-call-ui>`, `<citation-ui>`, `<link-preview-ui>`, `<map-ui>` used
|
|
27
|
+
purely as styled `<div>` replacements without a real component class)
|
|
28
|
+
are a hard reject. Either promote them to a real component with its own
|
|
29
|
+
CSS file, or replace with a primitive composition (`accordion-ui` for
|
|
30
|
+
collapsibles, `tag-ui` for chips, `card-ui` + `[slot="media"]` for link
|
|
31
|
+
previews, `image-ui` for map placeholders).
|
|
32
|
+
|
|
33
|
+
JS-driven hydration via `setup.js` (setting data, attaching listeners,
|
|
34
|
+
reading URL state, setting CSS variables via `setProperty`) is always
|
|
35
|
+
allowed. The rule isn't "no JS styling" — it's "no styling stored in the
|
|
36
|
+
HTML page."
|
|
37
|
+
|
|
38
|
+
**Recognition:** if grep of an example page finds `<style`, `style="`, or a
|
|
39
|
+
custom tag name not present in `packages/web-components/components/`, the
|
|
40
|
+
page needs a rewrite before it's fit for training-data extraction.
|
|
41
|
+
|
|
42
|
+
## Stat Cards (KPI Grid)
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<grid-ui columns="4" gap="2">
|
|
46
|
+
<card-ui>
|
|
47
|
+
<header><span slot="heading" variant="label"><small>Revenue</small></span></header>
|
|
48
|
+
<section><col-ui gap="1">
|
|
49
|
+
<strong variant="title">$48,200</strong>
|
|
50
|
+
<badge-ui text="+12.5%" variant="success" size="xs"></badge-ui>
|
|
51
|
+
</col-ui></section>
|
|
52
|
+
</card-ui>
|
|
53
|
+
<card-ui>
|
|
54
|
+
<header><span slot="heading" variant="label"><small>Users</small></span></header>
|
|
55
|
+
<section><col-ui gap="1">
|
|
56
|
+
<strong variant="title">2,340</strong>
|
|
57
|
+
<badge-ui text="+8%" variant="success" size="xs"></badge-ui>
|
|
58
|
+
</col-ui></section>
|
|
59
|
+
</card-ui>
|
|
60
|
+
<card-ui>
|
|
61
|
+
<header><span slot="heading" variant="label"><small>Bounce Rate</small></span></header>
|
|
62
|
+
<section><col-ui gap="1">
|
|
63
|
+
<strong variant="title">24.3%</strong>
|
|
64
|
+
<badge-ui text="-3.1%" variant="danger" size="xs"></badge-ui>
|
|
65
|
+
</col-ui></section>
|
|
66
|
+
</card-ui>
|
|
67
|
+
<card-ui>
|
|
68
|
+
<header><span slot="heading" variant="label"><small>Avg Session</small></span></header>
|
|
69
|
+
<section><col-ui gap="1">
|
|
70
|
+
<strong variant="title">4m 23s</strong>
|
|
71
|
+
<badge-ui text="+15s" variant="info" size="xs"></badge-ui>
|
|
72
|
+
</col-ui></section>
|
|
73
|
+
</card-ui>
|
|
74
|
+
</grid-ui>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Key rule: `Grid > Card > Header + Section > Column`. Never Grid inside Grid for stats.
|
|
78
|
+
|
|
79
|
+
## Data Table
|
|
80
|
+
|
|
81
|
+
Table data is set via JS properties. **Pair every non-trivial table with
|
|
82
|
+
`<table-toolbar-ui for="…">`** for filter / sort / columns-visibility /
|
|
83
|
+
search affordances — the toolbar already wires those into the bound table
|
|
84
|
+
+ auto-detects column types. Use **cell-types** in the column descriptors
|
|
85
|
+
to delegate formatting + alignment + sort to the table itself.
|
|
86
|
+
|
|
87
|
+
### Canonical shape — toolbar above card (per `table-toolbar.yaml` a2ui.rules)
|
|
88
|
+
|
|
89
|
+
The yaml-preferred composition: toolbar OUTSIDE `<card-ui>`, paired via a
|
|
90
|
+
`<col-ui gap="3">`. Card holds only the table, in a `<section bleed>` for
|
|
91
|
+
edge-to-edge rows:
|
|
92
|
+
|
|
93
|
+
```html
|
|
94
|
+
<col-ui gap="3">
|
|
95
|
+
<table-toolbar-ui for="orders" text="Recent orders"></table-toolbar-ui>
|
|
96
|
+
<card-ui>
|
|
97
|
+
<section bleed>
|
|
98
|
+
<table-ui id="orders" sortable selectable></table-ui>
|
|
99
|
+
</section>
|
|
100
|
+
<footer>
|
|
101
|
+
<pagination-ui total="100" page="1" siblings="2"></pagination-ui>
|
|
102
|
+
</footer>
|
|
103
|
+
</card-ui>
|
|
104
|
+
</col-ui>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Alternative — toolbar inside card, two-`<section>` split
|
|
108
|
+
|
|
109
|
+
When the surface already has a card-ui header (title + non-toolbar
|
|
110
|
+
actions like "Export" or "New"), keep the toolbar **inside** the same
|
|
111
|
+
card. The composition uses **two adjacent `<section>` slots**: toolbar
|
|
112
|
+
in normal-inset section, table in `<section bleed>`. Substrate-supported,
|
|
113
|
+
intentional — the `bleed` attribute is a per-section knob, not a
|
|
114
|
+
per-card knob.
|
|
115
|
+
|
|
116
|
+
```html
|
|
117
|
+
<card-ui>
|
|
118
|
+
<header>
|
|
119
|
+
<span slot="heading" variant="section">Recent orders</span>
|
|
120
|
+
<button-ui slot="action" text="Export" variant="ghost" size="sm" icon="download"></button-ui>
|
|
121
|
+
</header>
|
|
122
|
+
<section>
|
|
123
|
+
<table-toolbar-ui for="orders"></table-toolbar-ui>
|
|
124
|
+
</section>
|
|
125
|
+
<section bleed>
|
|
126
|
+
<table-ui id="orders" sortable selectable></table-ui>
|
|
127
|
+
</section>
|
|
128
|
+
<footer>
|
|
129
|
+
<pagination-ui total="100" page="1" siblings="2"></pagination-ui>
|
|
130
|
+
</footer>
|
|
131
|
+
</card-ui>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
> ⚠️ **Do NOT place `<table-toolbar-ui>` and `<table-ui>` inside the same
|
|
135
|
+
> `<section bleed>`.** Both get edge-to-edge treatment; the toolbar's
|
|
136
|
+
> Filter / Sort / Columns / Search controls then touch the card border
|
|
137
|
+
> with zero inset. The right shape uses **two** sections — `<section>`
|
|
138
|
+
> (normal inset) for the toolbar, `<section bleed>` (opt-out) for the
|
|
139
|
+
> table.
|
|
140
|
+
>
|
|
141
|
+
> Likewise, do NOT place both inside a single bare `<section>` (no
|
|
142
|
+
> bleed). The table rows then get the card's content padding on every
|
|
143
|
+
> row — wasted horizontal real estate on a data-dense surface.
|
|
144
|
+
>
|
|
145
|
+
> Reference: `card-ui.yaml` `<section[bleed]>` slot; `table-toolbar.yaml`
|
|
146
|
+
> a2ui.rules: "Place the toolbar ABOVE the `<card-ui>` containing the
|
|
147
|
+
> table-ui, or use `[variant="card"]` when standing alone outside a
|
|
148
|
+
> card-ui parent."
|
|
149
|
+
|
|
150
|
+
### Anti-pattern — hand-rolled toolbar children
|
|
151
|
+
|
|
152
|
+
`<table-toolbar-ui>` **always auto-renders** Filter / Sort / Columns /
|
|
153
|
+
Search (each opt-out-able via `no-filter` / `no-sort` / `no-columns` /
|
|
154
|
+
`no-search` booleans). Slotted filter children render in **addition**
|
|
155
|
+
to the auto-chrome — produces a doubled toolbar:
|
|
156
|
+
|
|
157
|
+
```html
|
|
158
|
+
<!-- WRONG — double chrome -->
|
|
159
|
+
<table-toolbar-ui for="orders">
|
|
160
|
+
<input-ui placeholder="Search…" icon="magnifying-glass"></input-ui>
|
|
161
|
+
<select-ui placeholder="Status"></select-ui> <!-- redundant -->
|
|
162
|
+
<select-ui placeholder="Tags"></select-ui> <!-- redundant -->
|
|
163
|
+
</table-toolbar-ui>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The right shape: drop the children, let the toolbar own its chrome.
|
|
167
|
+
For trailing primary actions (Invite / Export / +New), use the
|
|
168
|
+
`slot="actions"` slot:
|
|
169
|
+
|
|
170
|
+
```html
|
|
171
|
+
<table-toolbar-ui for="orders" text="Recent orders">
|
|
172
|
+
<button-ui slot="actions" text="New" variant="primary" icon="plus"></button-ui>
|
|
173
|
+
</table-toolbar-ui>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Equally wrong: re-implementing filter / search affordances in the parent
|
|
177
|
+
`<card-ui>`'s `<header>` while `<table-toolbar-ui>` is mounted — same
|
|
178
|
+
doubled-chrome class. The toolbar owns those affordances; card-header
|
|
179
|
+
buttons should be page-level actions (Export, +New, Invite), not data
|
|
180
|
+
controls.
|
|
181
|
+
|
|
182
|
+
Reference: `table-toolbar.yaml` props lines 32-81 (six boolean opt-outs
|
|
183
|
+
+ `for=` binding); a2ui.rules: "Do NOT also use `<card-ui>`'s `<header>`
|
|
184
|
+
on the same card — that produces a doubled chrome row."
|
|
185
|
+
|
|
186
|
+
### Column descriptors — pass raw values, use cell-types
|
|
187
|
+
|
|
188
|
+
`<table-ui>` ships **12 built-in cell types** + a `render(value, row, cell, meta)`
|
|
189
|
+
escape hatch for custom rendering. Each cell type handles formatting,
|
|
190
|
+
default alignment, and the sort comparator automatically — pre-formatting
|
|
191
|
+
values in the data layer **defeats the cell-types system + leaves columns
|
|
192
|
+
left-aligned by default**.
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
const STATUS_VARIANTS = {
|
|
196
|
+
'submitted': 'info',
|
|
197
|
+
'in-review': 'warning',
|
|
198
|
+
'approved': 'success',
|
|
199
|
+
'denied': 'danger',
|
|
200
|
+
'paid': 'success',
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const table = document.querySelector('#orders');
|
|
204
|
+
table.columns = [
|
|
205
|
+
{ key: 'id', label: 'Order ID' },
|
|
206
|
+
{ key: 'customer', label: 'Customer' },
|
|
207
|
+
{ key: 'status', label: 'Status', type: 'badge',
|
|
208
|
+
meta: { variants: STATUS_VARIANTS } },
|
|
209
|
+
{ key: 'amount', label: 'Amount', type: 'currency',
|
|
210
|
+
meta: { currency: 'USD' } },
|
|
211
|
+
{ key: 'submitted', label: 'Submitted', type: 'date' },
|
|
212
|
+
{ key: 'tags', label: 'Tags', render: renderTagsCell,
|
|
213
|
+
sortable: false },
|
|
214
|
+
];
|
|
215
|
+
table.data = [
|
|
216
|
+
{ id: '#4021', customer: 'Jane Doe', status: 'approved',
|
|
217
|
+
amount: 240.00, submitted: '2026-05-19T22:34:03Z',
|
|
218
|
+
tags: [{ id: 't1', label: 'priority', color: 'warning' }] },
|
|
219
|
+
{ id: '#4022', customer: 'John Smith', status: 'in-review',
|
|
220
|
+
amount: 89.50, submitted: '2026-05-20T08:12:11Z',
|
|
221
|
+
tags: [] },
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
function renderTagsCell(value, row, cell) {
|
|
225
|
+
cell.replaceChildren();
|
|
226
|
+
const tags = Array.isArray(value) ? value : [];
|
|
227
|
+
if (tags.length === 0) return;
|
|
228
|
+
const chips = document.createElement('row-ui');
|
|
229
|
+
chips.setAttribute('gap', '1');
|
|
230
|
+
for (const t of tags) {
|
|
231
|
+
const chip = document.createElement('tag-ui');
|
|
232
|
+
chip.setAttribute('variant', t.color ?? 'neutral');
|
|
233
|
+
chip.setAttribute('size', 'sm');
|
|
234
|
+
chip.textContent = t.label;
|
|
235
|
+
chips.appendChild(chip);
|
|
236
|
+
}
|
|
237
|
+
cell.appendChild(chips);
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Note: `row.amount` is a raw `number` (not `"$240.00"`), `row.submitted`
|
|
242
|
+
is a raw ISO string (not `"May 19, 2026"`), `row.tags` is a raw array
|
|
243
|
+
(not `"priority, paid"`). The data layer stays domain-shaped; the
|
|
244
|
+
cell-type formats on render — and the `currency` type right-aligns
|
|
245
|
+
the column automatically (consumer-set `align: 'end'` on the column
|
|
246
|
+
descriptor has no effect without a matching `type:`).
|
|
247
|
+
|
|
248
|
+
### Built-in cell types
|
|
249
|
+
|
|
250
|
+
Source-of-truth: `node_modules/@adia-ai/web-components/components/table/cell-types.js`.
|
|
251
|
+
The 12 registered types:
|
|
252
|
+
|
|
253
|
+
| Type | Value shape | Renders as | Align | Sort |
|
|
254
|
+
|---|---|---|---|---|
|
|
255
|
+
| `text` (default) | string | flat text | left | alphanumeric |
|
|
256
|
+
| `number` | number | `Intl.NumberFormat()` | right | numeric |
|
|
257
|
+
| `currency` | number | `Intl.NumberFormat(currency)` (default USD; override via `meta.currency`) | right | numeric |
|
|
258
|
+
| `percent` | number | `${value}%` | right | numeric |
|
|
259
|
+
| `date` | ISO string \| Date | localized date (`Intl.DateTimeFormat`, year/month/day) | left | datetime |
|
|
260
|
+
| `datetime` | ISO string \| Date | localized date + time | left | datetime |
|
|
261
|
+
| `boolean` | boolean | `<check-ui disabled>` (checked if truthy) | center | basic |
|
|
262
|
+
| `badge` | string (also lookup key) | `<badge-ui>` with variant from `meta.variants[value]` | left | alphanumeric |
|
|
263
|
+
| `avatar` | string (name) | `<avatar-ui>` + name span | left | alphanumeric |
|
|
264
|
+
| `link` | string (URL or email) | `<a href>` (auto `mailto:` when string contains `@`); use `meta.label` to override display text | left | alphanumeric |
|
|
265
|
+
| `progress` | number (0-100) | `<progress-ui>` + `${value}%` label | left | numeric |
|
|
266
|
+
| `actions` | (no value) — uses `meta.actions: [{ icon, label, event }]` | `<row-ui gap="1">` of `<button-ui icon variant="ghost" size="sm">`; click dispatches `CustomEvent(action.event, { detail: { row } })` | center | (no sort) |
|
|
267
|
+
|
|
268
|
+
The `meta` field on a column descriptor is per-cell-type configuration:
|
|
269
|
+
`meta.variants` for `badge`, `meta.currency` for `currency`,
|
|
270
|
+
`meta.actions` for `actions`, `meta.label` for `link`.
|
|
271
|
+
|
|
272
|
+
### Custom rendering — the `render` escape hatch
|
|
273
|
+
|
|
274
|
+
For cells that need multiple elements, complex layout, or a primitive
|
|
275
|
+
not in the built-in registry (multi-tag chip rows, avatar stacks,
|
|
276
|
+
inline editors), use `render(value, row, cell, meta)`:
|
|
277
|
+
|
|
278
|
+
```js
|
|
279
|
+
{ key: 'tags', label: 'Tags', sortable: false,
|
|
280
|
+
render(value, row, cell, meta) {
|
|
281
|
+
cell.replaceChildren();
|
|
282
|
+
// ...build DOM into `cell`...
|
|
283
|
+
},
|
|
284
|
+
format(value /* , row, meta */) {
|
|
285
|
+
return (value ?? []).map(t => t.label).join(', ');
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Constraints:
|
|
291
|
+
|
|
292
|
+
- `render` receives the cell element directly — write into it via
|
|
293
|
+
`cell.replaceChildren()` + `cell.appendChild(...)`. Don't return a
|
|
294
|
+
string or a node.
|
|
295
|
+
- `render` is called on every re-render (filter / sort / paginate /
|
|
296
|
+
data refresh). Memoize expensive content per row; reuse existing
|
|
297
|
+
child nodes when possible. The built-in `badge` / `avatar` /
|
|
298
|
+
`progress` cell types model this pattern (re-attach to the cached
|
|
299
|
+
child element rather than recreate).
|
|
300
|
+
- Set `sortable: false` on columns whose value isn't a sortable scalar
|
|
301
|
+
(multi-element cells like tag-chip rows have no obvious scalar to
|
|
302
|
+
sort on).
|
|
303
|
+
- Provide `format(value, row, meta)` as the **text-export fallback** —
|
|
304
|
+
used by CSV export, accessibility (screen reader), and aria-label
|
|
305
|
+
composition. Without it, `render`-only cells export as empty strings.
|
|
306
|
+
|
|
307
|
+
### Toolbar — opt-outs
|
|
308
|
+
|
|
309
|
+
`<table-toolbar-ui>` shows search + filter + sort + columns by default.
|
|
310
|
+
Hide individually via boolean attributes (each `default: false`,
|
|
311
|
+
`reflect: true`):
|
|
312
|
+
|
|
313
|
+
| Attribute | Hides |
|
|
314
|
+
|---|---|
|
|
315
|
+
| `no-filter` | Filter popover button |
|
|
316
|
+
| `no-sort` | Sort popover button |
|
|
317
|
+
| `no-columns` | Columns visibility popover button |
|
|
318
|
+
| `no-search` | Search input |
|
|
319
|
+
|
|
320
|
+
Plus: `variant="card"` wraps the toolbar in card-ui-style chrome when
|
|
321
|
+
standing alone outside a parent `<card-ui>` (used when the toolbar
|
|
322
|
+
floats above a bare table without its own card frame).
|
|
323
|
+
|
|
324
|
+
Filter rows auto-pick a primitive per column from the bound table's
|
|
325
|
+
`.columns` metadata:
|
|
326
|
+
|
|
327
|
+
- ≤ 50 distinct values → multi-select (searchable when ≥ 12 options)
|
|
328
|
+
- id-like keys → free-text contains input
|
|
329
|
+
- Override per column via the column descriptor's `filterType: 'select' | 'text'`
|
|
330
|
+
|
|
331
|
+
Use `<section bleed>` for edge-to-edge table inside a card.
|
|
332
|
+
Table `kind` options: `traditional` (default), `data-list`, `spreadsheet`.
|
|
333
|
+
|
|
334
|
+
## Chart
|
|
335
|
+
|
|
336
|
+
```html
|
|
337
|
+
<card-ui>
|
|
338
|
+
<header>
|
|
339
|
+
<span slot="heading" variant="section">Revenue</span>
|
|
340
|
+
<span slot="description">Last 6 months</span>
|
|
341
|
+
</header>
|
|
342
|
+
<section>
|
|
343
|
+
<chart-ui id="revenue" type="bar" x="month" y="revenue"></chart-ui>
|
|
344
|
+
</section>
|
|
345
|
+
</card-ui>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
```js
|
|
349
|
+
document.querySelector('#revenue').data = [
|
|
350
|
+
{ month: 'Jan', revenue: 4200 },
|
|
351
|
+
{ month: 'Feb', revenue: 5100 },
|
|
352
|
+
{ month: 'Mar', revenue: 4800 },
|
|
353
|
+
];
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Chart palette: `--a-chart-1` through `--a-chart-10` (OKLCH categorical colors, 36 degree spacing).
|
|
357
|
+
|
|
358
|
+
## Progress & Loading
|
|
359
|
+
|
|
360
|
+
```html
|
|
361
|
+
<!-- Linear progress -->
|
|
362
|
+
<col-ui gap="1">
|
|
363
|
+
<row-ui justify="between">
|
|
364
|
+
<small>Storage</small><small>6.5 / 10 GB</small>
|
|
365
|
+
</row-ui>
|
|
366
|
+
<progress-ui value="65"></progress-ui>
|
|
367
|
+
</col-ui>
|
|
368
|
+
|
|
369
|
+
<!-- Skeleton placeholder -->
|
|
370
|
+
<card-ui>
|
|
371
|
+
<section><col-ui gap="4">
|
|
372
|
+
<skeleton-ui height="1.5rem" width="60%"></skeleton-ui>
|
|
373
|
+
<skeleton-ui height="1rem" width="80%"></skeleton-ui>
|
|
374
|
+
<skeleton-ui height="200px"></skeleton-ui>
|
|
375
|
+
</col-ui></section>
|
|
376
|
+
</card-ui>
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Live Data (DataStreamController)
|
|
380
|
+
|
|
381
|
+
```js
|
|
382
|
+
import { DataStreamController } from '@adiahealth/web-components/controllers';
|
|
383
|
+
|
|
384
|
+
const ctrl = new DataStreamController({ max: 50, throttle: 200 });
|
|
385
|
+
ctrl.connect(chartElement);
|
|
386
|
+
ctrl.commands.connect('sse', '/api/metrics/stream');
|
|
387
|
+
// Chart auto-updates as data arrives via ctrl.notify()
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Supports: `connect('sse'|'ws', url)`, `poll(url, interval)`, `consume(asyncIterable)`, `push(point)`.
|
|
391
|
+
|
|
392
|
+
## Dashboard Layout
|
|
393
|
+
|
|
394
|
+
When this fragment is hosted inside an `<admin-shell>` route fragment,
|
|
395
|
+
wrap it in `<admin-page-body>` → `<section>` per the shell-anatomy
|
|
396
|
+
contract (see `patterns-shells.md` §Wrapper-vs-column + recipes-route-
|
|
397
|
+
fragment). The `<col-ui gap="4">` below is the **content owner inside
|
|
398
|
+
the `<section>`**, not the direct child of `<admin-page-body>`.
|
|
399
|
+
|
|
400
|
+
```html
|
|
401
|
+
<admin-page>
|
|
402
|
+
<admin-page-header>
|
|
403
|
+
<header>
|
|
404
|
+
<div>
|
|
405
|
+
<h1>Dashboard</h1>
|
|
406
|
+
<div data-actions>
|
|
407
|
+
<select-ui size="sm">
|
|
408
|
+
<option>Last 7 days</option>
|
|
409
|
+
<option>Last 30 days</option>
|
|
410
|
+
</select-ui>
|
|
411
|
+
<button-ui text="Export" variant="outline" size="sm" icon="download"></button-ui>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</header>
|
|
415
|
+
</admin-page-header>
|
|
416
|
+
<admin-page-body>
|
|
417
|
+
<section>
|
|
418
|
+
<col-ui gap="4">
|
|
419
|
+
|
|
420
|
+
<!-- KPI row -->
|
|
421
|
+
<grid-ui columns="4" gap="2"><!-- stat cards --></grid-ui>
|
|
422
|
+
|
|
423
|
+
<!-- Charts -->
|
|
424
|
+
<grid-ui columns="2" gap="4">
|
|
425
|
+
<card-ui>
|
|
426
|
+
<header><span slot="heading" variant="section">Revenue</span></header>
|
|
427
|
+
<section><chart-ui id="rev" type="bar" x="month" y="value"></chart-ui></section>
|
|
428
|
+
</card-ui>
|
|
429
|
+
<card-ui>
|
|
430
|
+
<header><span slot="heading" variant="section">Users</span></header>
|
|
431
|
+
<section><chart-ui id="users" type="bar" x="month" y="value"></chart-ui></section>
|
|
432
|
+
</card-ui>
|
|
433
|
+
</grid-ui>
|
|
434
|
+
|
|
435
|
+
<!-- Activity table — toolbar above card per table-toolbar.yaml canonical -->
|
|
436
|
+
<col-ui gap="3">
|
|
437
|
+
<table-toolbar-ui for="activity" text="Recent activity"></table-toolbar-ui>
|
|
438
|
+
<card-ui>
|
|
439
|
+
<section bleed><table-ui id="activity" sortable></table-ui></section>
|
|
440
|
+
<footer><pagination-ui total="100" page="1" siblings="2"></pagination-ui></footer>
|
|
441
|
+
</card-ui>
|
|
442
|
+
</col-ui>
|
|
443
|
+
|
|
444
|
+
</col-ui>
|
|
445
|
+
</section>
|
|
446
|
+
</admin-page-body>
|
|
447
|
+
</admin-page>
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
The `<section>` inside `<admin-page-body>` owns the page-content inset
|
|
451
|
+
(`--card-inset`). The `<col-ui gap="4">` is the content column inside
|
|
452
|
+
that inset — never a direct child of `<admin-page-body>`. Same contract
|
|
453
|
+
as `patterns-shells.md` §Wrapper-vs-column.
|
|
454
|
+
|
|
455
|
+
## Asymmetric Grid (3:2, 2:1)
|
|
456
|
+
|
|
457
|
+
Dashboards routinely want charts wider than sidebars. Express the split via
|
|
458
|
+
`grid-ui[columns="N"]` + `[span="M"]` on children. Do **not** use inline
|
|
459
|
+
`style="grid-template-columns: …"`.
|
|
460
|
+
|
|
461
|
+
```html
|
|
462
|
+
<!-- 2:1 — main chart + sidebar donut -->
|
|
463
|
+
<grid-ui columns="3" gap="4">
|
|
464
|
+
<card-ui span="2">
|
|
465
|
+
<header><h3>Traffic</h3></header>
|
|
466
|
+
<section><chart-ui type="line" ...></chart-ui></section>
|
|
467
|
+
</card-ui>
|
|
468
|
+
<card-ui>
|
|
469
|
+
<header><h3>Sources</h3></header>
|
|
470
|
+
<section><chart-ui type="donut" ...></chart-ui></section>
|
|
471
|
+
</card-ui>
|
|
472
|
+
</grid-ui>
|
|
473
|
+
|
|
474
|
+
<!-- 3:2 — overview + recent activity -->
|
|
475
|
+
<grid-ui columns="5" gap="4">
|
|
476
|
+
<card-ui span="3">
|
|
477
|
+
<header><h3>Overview</h3></header>
|
|
478
|
+
<section><chart-ui type="bar" ...></chart-ui></section>
|
|
479
|
+
</card-ui>
|
|
480
|
+
<card-ui span="2">
|
|
481
|
+
<header><h3>Recent Sales</h3></header>
|
|
482
|
+
<section><list-ui divider>...</list-ui></section>
|
|
483
|
+
</card-ui>
|
|
484
|
+
</grid-ui>
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Available ratios out of the box: 1:1 (`columns="2"`), 2:1 (`columns="3"` +
|
|
488
|
+
`span="2"`), 3:1 (`columns="4"` + `span="3"`), 3:2 (`columns="5"` +
|
|
489
|
+
`span="3"+"2"`), 4:1 (`columns="5"` + `span="4"`). Don't reach for 7:5 or
|
|
490
|
+
similar — round to 3:2 instead.
|
|
491
|
+
|
|
492
|
+
## Dashboard Tabs (with routing)
|
|
493
|
+
|
|
494
|
+
Declare tabs in the header; put each tab's body in a `[data-tab-panel]`
|
|
495
|
+
sibling. A 10-line setup.js switches visibility on change.
|
|
496
|
+
|
|
497
|
+
```html
|
|
498
|
+
<div data-content-header>
|
|
499
|
+
<header>
|
|
500
|
+
<div>
|
|
501
|
+
<h1>Analytics</h1>
|
|
502
|
+
<div data-actions>
|
|
503
|
+
<segmented-ui value="30d">
|
|
504
|
+
<segment-ui value="7d" text="7d"></segment-ui>
|
|
505
|
+
<segment-ui value="30d" text="30d"></segment-ui>
|
|
506
|
+
<segment-ui value="90d" text="90d"></segment-ui>
|
|
507
|
+
</segmented-ui>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
<p>Visitor behavior, acquisition, and conversion.</p>
|
|
511
|
+
<tabs-ui value="audience">
|
|
512
|
+
<tab-ui value="audience" text="Audience"></tab-ui>
|
|
513
|
+
<tab-ui value="acquisition" text="Acquisition"></tab-ui>
|
|
514
|
+
<tab-ui value="behavior" text="Behavior"></tab-ui>
|
|
515
|
+
<tab-ui value="conversion" text="Conversion"></tab-ui>
|
|
516
|
+
</tabs-ui>
|
|
517
|
+
</header>
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
<div data-content-body>
|
|
521
|
+
<section>
|
|
522
|
+
<div data-dashboard-content>
|
|
523
|
+
|
|
524
|
+
<!-- cross-tab filter bar lives OUTSIDE the panels -->
|
|
525
|
+
<grid-ui><card-ui><section>...</section></card-ui></grid-ui>
|
|
526
|
+
|
|
527
|
+
<div data-tab-panel="audience">...</div>
|
|
528
|
+
<div data-tab-panel="acquisition">...</div>
|
|
529
|
+
<div data-tab-panel="behavior">...</div>
|
|
530
|
+
<div data-tab-panel="conversion">...</div>
|
|
531
|
+
|
|
532
|
+
</div>
|
|
533
|
+
</section>
|
|
534
|
+
</div>
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
```javascript
|
|
538
|
+
// index.setup.js — 10-line tab wiring (identical on every dashboard)
|
|
539
|
+
const tabs = router.querySelector('tabs-ui');
|
|
540
|
+
if (tabs) {
|
|
541
|
+
const panels = router.querySelectorAll('[data-tab-panel]');
|
|
542
|
+
const sync = (value) => {
|
|
543
|
+
for (const p of panels) p.hidden = p.dataset.tabPanel !== value;
|
|
544
|
+
};
|
|
545
|
+
tabs.addEventListener('change', (e) => sync(e.detail.value));
|
|
546
|
+
sync(tabs.value || 'audience');
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Cross-tab chrome (filter bars, date range pickers, subtitles) goes OUTSIDE
|
|
551
|
+
the panels so it persists across tab switches.
|
|
552
|
+
|
|
553
|
+
## Row-Click → Detail Drawer
|
|
554
|
+
|
|
555
|
+
Populated tables can open a detail drawer on row click via `cell-click`.
|
|
556
|
+
The drawer pre-declares structure; setup.js fills data-attribute-marked
|
|
557
|
+
fields from the clicked row.
|
|
558
|
+
|
|
559
|
+
```html
|
|
560
|
+
<!-- Markup -->
|
|
561
|
+
<table-ui id="tx-table" sortable striped raw paginate="5"></table-ui>
|
|
562
|
+
|
|
563
|
+
<drawer-ui id="drawer-transaction" side="right" size="md">
|
|
564
|
+
<header>
|
|
565
|
+
<span slot="icon"><avatar-ui icon="receipt"></avatar-ui></span>
|
|
566
|
+
<span slot="heading">
|
|
567
|
+
<text-ui strong data-tx-customer>Transaction</text-ui>
|
|
568
|
+
<badge-ui data-tx-status text="Completed" variant="success"></badge-ui>
|
|
569
|
+
</span>
|
|
570
|
+
<span slot="action"></span>
|
|
571
|
+
</header>
|
|
572
|
+
<section>
|
|
573
|
+
<text-ui color="subtle" size="sm" data-tx-meta>Details.</text-ui>
|
|
574
|
+
</section>
|
|
575
|
+
<section>
|
|
576
|
+
<grid-ui columns="2" gap="3">
|
|
577
|
+
<card-ui size="sm"><section><stat-ui label="Amount" data-tx-amount value="—"></stat-ui></section></card-ui>
|
|
578
|
+
<card-ui size="sm"><section><stat-ui label="Date" data-tx-date value="—"></stat-ui></section></card-ui>
|
|
579
|
+
</grid-ui>
|
|
580
|
+
<divider-ui></divider-ui>
|
|
581
|
+
<col-ui gap="3">
|
|
582
|
+
<text-ui strong>Customer</text-ui>
|
|
583
|
+
<input-ui label="Name" data-tx-name readonly></input-ui>
|
|
584
|
+
<input-ui label="Email" data-tx-email readonly></input-ui>
|
|
585
|
+
</col-ui>
|
|
586
|
+
</section>
|
|
587
|
+
<footer slot="footer">
|
|
588
|
+
<grid-ui>
|
|
589
|
+
<button-ui text="Refund" variant="danger"></button-ui>
|
|
590
|
+
<button-ui text="Close" variant="ghost" onclick="this.closest('drawer-ui').open = false"></button-ui>
|
|
591
|
+
<button-ui text="View receipt" variant="primary"></button-ui>
|
|
592
|
+
</grid-ui>
|
|
593
|
+
</footer>
|
|
594
|
+
</drawer-ui>
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
```javascript
|
|
598
|
+
// setup.js — the fill() helper handles adia-form + text-element asymmetry
|
|
599
|
+
const VALUE_TAGS = new Set(['input-ui', 'textarea-ui', 'select-ui', 'stat-ui']);
|
|
600
|
+
const fill = (scope, attr, val) => {
|
|
601
|
+
const el = scope.querySelector(`[${attr}]`);
|
|
602
|
+
if (!el) return;
|
|
603
|
+
const tag = el.localName;
|
|
604
|
+
if (tag === 'input-ui' || tag === 'textarea-ui') {
|
|
605
|
+
// input-ui/textarea-ui sync their [slot="text"] + data-empty from `value`
|
|
606
|
+
// (0.6.44+). Do NOT also mirror [slot="text"].textContent — pre-writing it
|
|
607
|
+
// makes input-ui's update() see no textContent change, so it skips clearing
|
|
608
|
+
// data-empty → the placeholder renders over the value (FB-87).
|
|
609
|
+
el.value = val;
|
|
610
|
+
} else if (VALUE_TAGS.has(tag)) {
|
|
611
|
+
el.setAttribute('value', val);
|
|
612
|
+
} else {
|
|
613
|
+
el.textContent = val;
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
table.addEventListener('cell-click', (e) => {
|
|
618
|
+
const row = e.detail?.row;
|
|
619
|
+
if (!row) return;
|
|
620
|
+
fill(drawer, 'data-tx-customer', row.customer);
|
|
621
|
+
fill(drawer, 'data-tx-meta', `${row.email} · ${row.date}`);
|
|
622
|
+
fill(drawer, 'data-tx-name', row.customer);
|
|
623
|
+
fill(drawer, 'data-tx-email', row.email);
|
|
624
|
+
fill(drawer, 'data-tx-amount', row.amount);
|
|
625
|
+
fill(drawer, 'data-tx-date', row.date);
|
|
626
|
+
// Status badge uses attribute for both text and variant.
|
|
627
|
+
const status = drawer.querySelector('[data-tx-status]');
|
|
628
|
+
const variant = { Completed:'success', Pending:'warning', Failed:'danger' }[row.status] || '';
|
|
629
|
+
status?.setAttribute('text', row.status);
|
|
630
|
+
status?.setAttribute('variant', variant);
|
|
631
|
+
drawer.open = true;
|
|
632
|
+
});
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### Editable drawer fields — wrap with `<field-ui>`
|
|
636
|
+
|
|
637
|
+
The recipe above shows the **read-only** display case (`input-ui readonly`)
|
|
638
|
+
and **numeric KPIs in cards** (`stat-ui` for Amount/Date). For drawers
|
|
639
|
+
that ALSO have **editable string/numeric fields** (CRUD detail panels —
|
|
640
|
+
edit claim, edit project, edit invoice — the most common admin-shell
|
|
641
|
+
drawer shape), wrap each control in `<field-ui>`:
|
|
642
|
+
|
|
643
|
+
```html
|
|
644
|
+
<section>
|
|
645
|
+
<col-ui gap="3">
|
|
646
|
+
<text-ui strong>Claim Details</text-ui>
|
|
647
|
+
<grid-ui columns="2" gap="3">
|
|
648
|
+
<field-ui label="Amount (USD)">
|
|
649
|
+
<input-ui id="drawer-amount" data-tx-amount></input-ui>
|
|
650
|
+
</field-ui>
|
|
651
|
+
<field-ui label="Status">
|
|
652
|
+
<select-ui id="drawer-status" data-tx-status>
|
|
653
|
+
<option value="submitted">Submitted</option>
|
|
654
|
+
<option value="approved">Approved</option>
|
|
655
|
+
<option value="denied">Denied</option>
|
|
656
|
+
</select-ui>
|
|
657
|
+
</field-ui>
|
|
658
|
+
</grid-ui>
|
|
659
|
+
<field-ui label="Diagnosis codes (ICD-10)" hint="Comma-separated">
|
|
660
|
+
<input-ui id="drawer-diagnosis" data-tx-diagnosis></input-ui>
|
|
661
|
+
</field-ui>
|
|
662
|
+
<field-ui label="Notes">
|
|
663
|
+
<textarea-ui id="drawer-notes" data-tx-notes rows="3"></textarea-ui>
|
|
664
|
+
</field-ui>
|
|
665
|
+
</col-ui>
|
|
666
|
+
</section>
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
**Why `<field-ui>` is the canonical wrapper (not `input-ui label=…` bare):**
|
|
670
|
+
|
|
671
|
+
`input-ui`'s `label=` attribute is **inline mode** — the label renders
|
|
672
|
+
as a leading caption *inside* the input chrome, between any prefix and
|
|
673
|
+
the value (per `input.yaml` prop description). When a value is set, the
|
|
674
|
+
inline label concatenates visually with the value — e.g.
|
|
675
|
+
`<input-ui label="Amount (USD)">` with `value="5475.59"` renders as
|
|
676
|
+
"Amount (USD)5475.59" inside the input chrome with no visual separator.
|
|
677
|
+
This is correct for compact single-line UI (filter bars, toolbar
|
|
678
|
+
inputs), wrong for drawer detail panels where each field needs a
|
|
679
|
+
visible stacked label.
|
|
680
|
+
|
|
681
|
+
`<field-ui label="…">` is the **stacked label wrapper**: composes a
|
|
682
|
+
`<label for="…">` above the control (auto-minting an `id` on the
|
|
683
|
+
slotted control when missing, so clicking the label focuses the
|
|
684
|
+
control — better accessibility than bare `input-ui label=…`).
|
|
685
|
+
|
|
686
|
+
**`<select-ui>`'s `label=` IS already stacked** (renders above the
|
|
687
|
+
trigger per `select.yaml`), but the canonical
|
|
688
|
+
`packages/web-components/patterns/form-drawer/form-drawer.examples.html`
|
|
689
|
+
still wraps `<select-ui>` in `<field-ui>` for visual + accessibility
|
|
690
|
+
consistency with input/textarea siblings — the `<label for=…>` binding
|
|
691
|
+
that field-ui provides is a strict accessibility upgrade. Use
|
|
692
|
+
`<field-ui>` uniformly for all three (`input-ui`, `select-ui`,
|
|
693
|
+
`textarea-ui`); don't mix bare `label=…` on some and `<field-ui>`
|
|
694
|
+
wrap on others.
|
|
695
|
+
|
|
696
|
+
**Authoritative exemplar**: `packages/web-components/patterns/form-drawer/form-drawer.examples.html`
|
|
697
|
+
in the substrate — uses `<field-ui>` 17 times across "simple create
|
|
698
|
+
form" + "edit form with destructive action" + multi-step variants.
|
|
699
|
+
That's the canonical reference if you need more permutations.
|
|
700
|
+
|
|
701
|
+
### ⚠️ Do NOT substitute `<stat-ui>` for `<input-ui readonly>` on string fields
|
|
702
|
+
|
|
703
|
+
When the user complains "too many inputs" about a drawer's read-only
|
|
704
|
+
display fields, the wrong fix is to replace `<input-ui readonly>` with
|
|
705
|
+
`<stat-ui label="…" value="…">`. `<stat-ui>` is a **KPI display
|
|
706
|
+
primitive** — value + label rendered as a stacked block with prominent
|
|
707
|
+
value in title-weight CSS (per `stat.yaml`). This is correct for
|
|
708
|
+
numeric metrics like "766 claims" or "$48,200 revenue" displayed in
|
|
709
|
+
dashboard cards (the §Row-Click recipe above uses `<stat-ui>` for
|
|
710
|
+
"Amount" + "Date" cards, which IS a legitimate KPI use). It is
|
|
711
|
+
**visually catastrophic for string values** (names, IDs, statuses,
|
|
712
|
+
diagnosis codes) at prose scale inside a drawer panel — the name
|
|
713
|
+
renders as headline-weight text and the drawer reads as a KPI dashboard
|
|
714
|
+
tile instead of a detail panel.
|
|
715
|
+
|
|
716
|
+
**The right alternatives**:
|
|
717
|
+
|
|
718
|
+
| Use case | Right primitive |
|
|
719
|
+
|----------|-----------------|
|
|
720
|
+
| Numeric metric in a dashboard card / drawer summary tile | `<stat-ui label value>` (KPI weight) |
|
|
721
|
+
| Editable form field with stacked label | `<field-ui label><input-ui></input-ui></field-ui>` |
|
|
722
|
+
| Read-only mirror of a form field (intentional "looks like an input, isn't editable") | `<field-ui label><input-ui readonly></input-ui></field-ui>` or bare `<input-ui readonly label>` |
|
|
723
|
+
| Lightweight key-value pair (label / value at prose scale, no input chrome) | `<col-ui gap="0"><text-ui size="sm" color="subtle">Label</text-ui><text-ui>Value</text-ui></col-ui>` |
|
|
724
|
+
| Definition list (multiple compact key-value pairs) | `<dl>` + `<dt>` / `<dd>` (native HTML) OR a `<grid-ui columns="2">` with `<text-ui>` pairs |
|
|
725
|
+
|
|
726
|
+
The "too many inputs" complaint usually means the visual density is too
|
|
727
|
+
high (form-chrome on every read-only field). The fix is **either** (a)
|
|
728
|
+
accept the form-chrome as intentional design-system style (the panel is
|
|
729
|
+
an editor surface; readonly inputs read as "you could edit this but
|
|
730
|
+
the row is locked"), **or** (b) drop to the lightweight `<col-ui>` +
|
|
731
|
+
`<text-ui>` pair shown in the table above for fields that genuinely
|
|
732
|
+
don't belong in form chrome. Do NOT reach for `<stat-ui>` — the
|
|
733
|
+
title-weight is wrong at prose scale.
|
|
734
|
+
|
|
735
|
+
## Shared Detail Drawer — Per-Row Hydration (no table)
|
|
736
|
+
|
|
737
|
+
When a list of cards (not a table) needs a "drill-down" drawer, mount ONE
|
|
738
|
+
drawer and re-populate it from each row's click. Use a CustomEvent for the
|
|
739
|
+
hydrate step so the drawer doesn't need to know which row triggered it.
|
|
740
|
+
|
|
741
|
+
```html
|
|
742
|
+
<!-- Row cards — each carries its own payload in the onclick -->
|
|
743
|
+
<card-ui>
|
|
744
|
+
<header>
|
|
745
|
+
<span slot="icon"><avatar-ui icon="envelope"></avatar-ui></span>
|
|
746
|
+
<span slot="heading">
|
|
747
|
+
<text-ui strong>james@acme.com</text-ui>
|
|
748
|
+
<badge-ui text="Member" variant="info"></badge-ui>
|
|
749
|
+
</span>
|
|
750
|
+
<span slot="action">
|
|
751
|
+
<button-ui
|
|
752
|
+
text="Manage"
|
|
753
|
+
variant="outline"
|
|
754
|
+
size="sm"
|
|
755
|
+
onclick="
|
|
756
|
+
const d = document.getElementById('drawer-invite');
|
|
757
|
+
d.dataset.email = 'james@acme.com';
|
|
758
|
+
d.dataset.role = 'Member';
|
|
759
|
+
d.dataset.invitedBy = 'Alex Chen';
|
|
760
|
+
d.dispatchEvent(new CustomEvent('hydrate'));
|
|
761
|
+
d.open = true;
|
|
762
|
+
"
|
|
763
|
+
></button-ui>
|
|
764
|
+
</span>
|
|
765
|
+
</header>
|
|
766
|
+
</card-ui>
|
|
767
|
+
|
|
768
|
+
<!-- Shared drawer — same layout for every row -->
|
|
769
|
+
<drawer-ui id="drawer-invite" side="right" size="md">
|
|
770
|
+
<header>
|
|
771
|
+
<span slot="icon"><avatar-ui icon="envelope"></avatar-ui></span>
|
|
772
|
+
<span slot="heading">
|
|
773
|
+
<text-ui strong data-invite-email>Invite</text-ui>
|
|
774
|
+
<badge-ui data-invite-role text="Member" variant="info"></badge-ui>
|
|
775
|
+
</span>
|
|
776
|
+
<span slot="action"></span>
|
|
777
|
+
</header>
|
|
778
|
+
<section>
|
|
779
|
+
<col-ui gap="3">
|
|
780
|
+
<input-ui label="Email" data-invite-email readonly></input-ui>
|
|
781
|
+
<select-ui label="Role" data-invite-role value="Member">…</select-ui>
|
|
782
|
+
<input-ui label="Invited by" data-invite-inviter readonly></input-ui>
|
|
783
|
+
</col-ui>
|
|
784
|
+
</section>
|
|
785
|
+
<footer slot="footer">
|
|
786
|
+
<grid-ui>
|
|
787
|
+
<button-ui text="Revoke" variant="danger"></button-ui>
|
|
788
|
+
<button-ui text="Cancel" variant="ghost"></button-ui>
|
|
789
|
+
<button-ui text="Resend" variant="primary"></button-ui>
|
|
790
|
+
</grid-ui>
|
|
791
|
+
</footer>
|
|
792
|
+
</drawer-ui>
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
```javascript
|
|
796
|
+
// setup.js — wire the hydrate handler once.
|
|
797
|
+
const drawer = router.querySelector('#drawer-invite');
|
|
798
|
+
|
|
799
|
+
// querySelectorAll (not querySelector) because the same data-* hook may
|
|
800
|
+
// appear on BOTH a [data-…] text-ui heading AND a [data-…] input-ui field.
|
|
801
|
+
// Missing the second occurrence is the most common fill-helper bug.
|
|
802
|
+
const fill = (attr, val) => {
|
|
803
|
+
for (const el of drawer.querySelectorAll(`[${attr}]`)) {
|
|
804
|
+
const tag = el.localName;
|
|
805
|
+
if (tag === 'input-ui' || tag === 'textarea-ui') {
|
|
806
|
+
// input-ui/textarea-ui sync [slot="text"] + data-empty from `value` (0.6.44+);
|
|
807
|
+
// mirroring textContent defeats update()'s change check and leaves data-empty
|
|
808
|
+
// stale → placeholder over the value (FB-87).
|
|
809
|
+
el.value = val;
|
|
810
|
+
} else if (tag === 'stat-ui' || tag === 'select-ui') {
|
|
811
|
+
el.setAttribute('value', val);
|
|
812
|
+
} else if (tag === 'badge-ui') {
|
|
813
|
+
el.setAttribute('text', val);
|
|
814
|
+
} else {
|
|
815
|
+
el.textContent = val;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
drawer.addEventListener('hydrate', () => {
|
|
821
|
+
const { email, role, invitedBy } = drawer.dataset;
|
|
822
|
+
fill('data-invite-email', email);
|
|
823
|
+
fill('data-invite-role', role);
|
|
824
|
+
fill('data-invite-inviter', invitedBy);
|
|
825
|
+
});
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
**When to pick this over the `cell-click` variant:**
|
|
829
|
+
- Row data lives in HTML (static list) — no table columns / data binding.
|
|
830
|
+
- You want each button to carry explicit payload rather than derive it
|
|
831
|
+
from a shared row object.
|
|
832
|
+
- The same drawer is reused across multiple discrete list surfaces on the
|
|
833
|
+
page (e.g. pending invites + revoked invites both reusing `#drawer-invite`).
|
|
834
|
+
|
|
835
|
+
For tables where a row object already exists, prefer the `cell-click` +
|
|
836
|
+
`e.detail.row` pattern from "Row-Click → Detail Drawer".
|
|
837
|
+
|
|
838
|
+
## State-Coverage Menu (Default / Loading / Empty / Error)
|
|
839
|
+
|
|
840
|
+
To demonstrate all async states on one card, add a `menu-ui` "View as"
|
|
841
|
+
trigger in the card's action slot. `action` event drives an `applyState()`
|
|
842
|
+
that swaps the card's section children.
|
|
843
|
+
|
|
844
|
+
```html
|
|
845
|
+
<card-ui>
|
|
846
|
+
<header>
|
|
847
|
+
<h3>Recent Transactions</h3>
|
|
848
|
+
<text-ui slot="description" color="subtle">Manage and track recent orders</text-ui>
|
|
849
|
+
<span slot="action">
|
|
850
|
+
<menu-ui id="tx-state-menu">
|
|
851
|
+
<button-ui slot="trigger" text="View as" icon="caret-down" variant="ghost" size="sm"></button-ui>
|
|
852
|
+
<menu-item-ui text="Default" value="default"></menu-item-ui>
|
|
853
|
+
<menu-item-ui text="Loading" value="loading" icon="spinner"></menu-item-ui>
|
|
854
|
+
<menu-item-ui text="Empty" value="empty" icon="tray"></menu-item-ui>
|
|
855
|
+
<menu-item-ui text="Error" value="error" icon="warning"></menu-item-ui>
|
|
856
|
+
</menu-ui>
|
|
857
|
+
</span>
|
|
858
|
+
</header>
|
|
859
|
+
<section bleed data-tx-surface>
|
|
860
|
+
<table-ui id="dash-transactions" sortable striped raw paginate="5"></table-ui>
|
|
861
|
+
</section>
|
|
862
|
+
</card-ui>
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
```javascript
|
|
866
|
+
const table = router.querySelector('#dash-transactions');
|
|
867
|
+
const surface = router.querySelector('[data-tx-surface]');
|
|
868
|
+
const menu = router.querySelector('#tx-state-menu');
|
|
869
|
+
|
|
870
|
+
const applyState = (state) => {
|
|
871
|
+
// Reset — re-mount the table, clear any overlays, drop loading.
|
|
872
|
+
if (!surface.contains(table)) surface.replaceChildren(table);
|
|
873
|
+
for (const o of surface.querySelectorAll('[data-tx-overlay]')) o.remove();
|
|
874
|
+
table.removeAttribute('loading');
|
|
875
|
+
|
|
876
|
+
if (state === 'loading') {
|
|
877
|
+
table.setAttribute('loading', '');
|
|
878
|
+
} else if (state === 'empty') {
|
|
879
|
+
table.data = [];
|
|
880
|
+
const empty = document.createElement('empty-state-ui');
|
|
881
|
+
empty.setAttribute('icon', 'receipt');
|
|
882
|
+
empty.setAttribute('heading', 'No transactions yet');
|
|
883
|
+
empty.setAttribute('description', 'Transactions appear here once customers check out.');
|
|
884
|
+
empty.setAttribute('data-tx-overlay', '');
|
|
885
|
+
empty.innerHTML = '<button-ui slot="action" text="Set up payments" variant="primary"></button-ui>';
|
|
886
|
+
surface.replaceChildren(empty);
|
|
887
|
+
} else if (state === 'error') {
|
|
888
|
+
const alert = document.createElement('alert-ui');
|
|
889
|
+
alert.setAttribute('variant', 'danger');
|
|
890
|
+
alert.setAttribute('icon', 'warning');
|
|
891
|
+
alert.setAttribute('heading', 'Could not load transactions');
|
|
892
|
+
alert.setAttribute('text', 'The payments API returned a 502.');
|
|
893
|
+
alert.setAttribute('data-tx-overlay', '');
|
|
894
|
+
alert.innerHTML = '<row-ui slot="action" gap="2"><button-ui text="Retry" variant="primary" size="sm"></button-ui></row-ui>';
|
|
895
|
+
surface.replaceChildren(alert);
|
|
896
|
+
} else {
|
|
897
|
+
table.data = populatedData;
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
applyState('default');
|
|
901
|
+
menu?.addEventListener('action', (e) => applyState(e.detail.value));
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
Pattern notes:
|
|
905
|
+
- `empty-state-ui` carries its own icon/heading/description API; the action
|
|
906
|
+
slot is author-owned (a primary CTA).
|
|
907
|
+
- `alert-ui variant="danger"` with `slot="action"` holds retry + status
|
|
908
|
+
buttons for the error state.
|
|
909
|
+
- `loading` is a built-in attribute on `table-ui`; no overlay needed.
|
|
910
|
+
|
|
911
|
+
## Interactive Filter Bar
|
|
912
|
+
|
|
913
|
+
Filter chips above a data view. Handle add/remove/clear; let consumers
|
|
914
|
+
listen for `filter-change` to persist to URL or query.
|
|
915
|
+
|
|
916
|
+
```html
|
|
917
|
+
<row-ui gap="2" align="center" wrap data-filter-bar>
|
|
918
|
+
<text-ui color="subtle" weight="semibold">Filters:</text-ui>
|
|
919
|
+
<tag-ui text="Country: United States" removable></tag-ui>
|
|
920
|
+
<tag-ui text="Device: Desktop" removable></tag-ui>
|
|
921
|
+
<button-ui data-filter-add text="+ Add filter" variant="ghost" size="sm"></button-ui>
|
|
922
|
+
<span data-spacer></span>
|
|
923
|
+
<button-ui data-filter-clear text="Clear all" variant="ghost" size="sm"></button-ui>
|
|
924
|
+
</row-ui>
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
```javascript
|
|
928
|
+
const filterRow = router.querySelector('[data-filter-bar]');
|
|
929
|
+
const addBtn = filterRow.querySelector('[data-filter-add]');
|
|
930
|
+
const clearBtn = filterRow.querySelector('[data-filter-clear]');
|
|
931
|
+
const presets = ['Country: Germany', 'Device: Mobile', 'Source: Organic', /* … */];
|
|
932
|
+
let idx = 0;
|
|
933
|
+
|
|
934
|
+
const addFilter = (text) => {
|
|
935
|
+
const tag = document.createElement('tag-ui');
|
|
936
|
+
tag.setAttribute('text', text);
|
|
937
|
+
tag.setAttribute('removable', '');
|
|
938
|
+
filterRow.insertBefore(tag, addBtn);
|
|
939
|
+
tag.addEventListener('remove', (e) => {
|
|
940
|
+
router.dispatchEvent(new CustomEvent('filter-change', { detail: { removed: e.detail.text } }));
|
|
941
|
+
});
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
addBtn.addEventListener('press', () => addFilter(presets[idx++ % presets.length]));
|
|
945
|
+
clearBtn.addEventListener('press', () => {
|
|
946
|
+
for (const t of filterRow.querySelectorAll('tag-ui[removable]')) t.remove();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// Make authored chips participate in the same event channel.
|
|
950
|
+
for (const t of filterRow.querySelectorAll('tag-ui[removable]')) {
|
|
951
|
+
t.addEventListener('remove', (e) => {
|
|
952
|
+
router.dispatchEvent(new CustomEvent('filter-change', { detail: { removed: e.detail.text } }));
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
Key: `tag-ui[removable]` dispatches a `remove` CustomEvent and then removes
|
|
958
|
+
itself, so the listener is a signal-only hook. `button-ui` uses a `press`
|
|
959
|
+
event (not `click`) — fires on both click and keyboard activation.
|
|
960
|
+
|
|
961
|
+
## Conversion Funnel (composition recipe)
|
|
962
|
+
|
|
963
|
+
A funnel without a `funnel-ui` component. Top step is a plain row showing
|
|
964
|
+
100% as the anchor; remaining steps are `progress-row-ui`.
|
|
965
|
+
|
|
966
|
+
```html
|
|
967
|
+
<card-ui>
|
|
968
|
+
<header>
|
|
969
|
+
<h3>Conversion funnel</h3>
|
|
970
|
+
<text-ui slot="description" color="subtle">Visit → Sign up → Activate → Convert</text-ui>
|
|
971
|
+
</header>
|
|
972
|
+
<section>
|
|
973
|
+
<col-ui gap="3">
|
|
974
|
+
<row-ui align="center" gap="3">
|
|
975
|
+
<col-ui gap="0" grow>
|
|
976
|
+
<text-ui strong>Visited landing page</text-ui>
|
|
977
|
+
<text-ui color="subtle">28,432 visitors</text-ui>
|
|
978
|
+
</col-ui>
|
|
979
|
+
<text-ui strong>100%</text-ui>
|
|
980
|
+
</row-ui>
|
|
981
|
+
<progress-row-ui label="Signed up" value="42" meta="11,942 · 42%"></progress-row-ui>
|
|
982
|
+
<progress-row-ui label="Activated" value="28" meta="7,961 · 28%"></progress-row-ui>
|
|
983
|
+
<progress-row-ui label="Converted to paid" value="8" meta="2,274 · 8.0%"></progress-row-ui>
|
|
984
|
+
</col-ui>
|
|
985
|
+
</section>
|
|
986
|
+
</card-ui>
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
Meta format: `"{count} · {percent}"`. Count first reads as data, percent
|
|
990
|
+
second reads as insight. Step-over-step delta is an optional badge in a
|
|
991
|
+
sibling column: `warning` for 20–40% drop, `danger` above 40%.
|
|
992
|
+
|
|
993
|
+
## Retention Cohort (composition recipe)
|
|
994
|
+
|
|
995
|
+
A retention "heatmap" as a regular `table-ui` with a badge-cell renderer.
|
|
996
|
+
Each percentage wraps in a `badge-ui` variant keyed to three buckets.
|
|
997
|
+
|
|
998
|
+
```javascript
|
|
999
|
+
const formatPct = (v) => {
|
|
1000
|
+
if (v == null) return ''; // partial cohort → blank, never zero
|
|
1001
|
+
const n = Math.round(v);
|
|
1002
|
+
const variant = n >= 60 ? 'success' : n >= 30 ? 'warning' : 'muted';
|
|
1003
|
+
return `<badge-ui text="${n}%" variant="${variant}"></badge-ui>`;
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const columns = [
|
|
1007
|
+
{ key: 'week', label: 'Signup week' },
|
|
1008
|
+
{ key: 'size', label: 'Users' },
|
|
1009
|
+
{ key: 'w1', label: 'W1', render: formatPct },
|
|
1010
|
+
{ key: 'w2', label: 'W2', render: formatPct },
|
|
1011
|
+
{ key: 'w4', label: 'W4', render: formatPct },
|
|
1012
|
+
{ key: 'w8', label: 'W8', render: formatPct },
|
|
1013
|
+
{ key: 'w12', label: 'W12', render: formatPct },
|
|
1014
|
+
];
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
Bucket thresholds: 60/30 for SaaS; 40/20 for transactional products.
|
|
1018
|
+
Canonical column set: `Signup week · Users · W1 · W2 · W3 · W4 · W8 · W12`.
|
|
1019
|
+
|
|
1020
|
+
## Radio Cards (rich radio-group)
|
|
1021
|
+
|
|
1022
|
+
For "choose a plan"-style selection with rich labels + descriptions, wrap
|
|
1023
|
+
each option in `card-ui` with a `<radio-ui slot="icon">`, then wrap the whole
|
|
1024
|
+
list in `role="radiogroup"` so `radio-ui`'s sibling lookup works across
|
|
1025
|
+
containers.
|
|
1026
|
+
|
|
1027
|
+
```html
|
|
1028
|
+
<div role="radiogroup" aria-label="Plan" data-plan-picker
|
|
1029
|
+
onclick="const r = event.target.closest('card-ui')?.querySelector('radio-ui'); r && r.click()">
|
|
1030
|
+
<list-ui divider>
|
|
1031
|
+
<list-item-ui>
|
|
1032
|
+
<card-ui>
|
|
1033
|
+
<header>
|
|
1034
|
+
<radio-ui slot="icon" name="plan" value="pro" checked></radio-ui>
|
|
1035
|
+
<span slot="heading">
|
|
1036
|
+
<text-ui strong>Pro · $49/mo</text-ui>
|
|
1037
|
+
<badge-ui text="Current" variant="accent"></badge-ui>
|
|
1038
|
+
</span>
|
|
1039
|
+
<span slot="description">
|
|
1040
|
+
<text-ui color="subtle" size="sm">Unlimited conversations, advanced charts, 100 GB storage</text-ui>
|
|
1041
|
+
</span>
|
|
1042
|
+
</header>
|
|
1043
|
+
</card-ui>
|
|
1044
|
+
</list-item-ui>
|
|
1045
|
+
<!-- additional options -->
|
|
1046
|
+
</list-ui>
|
|
1047
|
+
</div>
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
The inline delegate forwards any card-click to the card's inner radio;
|
|
1051
|
+
radio-ui's own click handler no-ops on an already-checked radio, so
|
|
1052
|
+
click-through is safe.
|
|
1053
|
+
|
|
1054
|
+
## Alerts & Badges
|
|
1055
|
+
|
|
1056
|
+
```html
|
|
1057
|
+
<alert-ui variant="success" icon="check-circle" text="Changes saved"></alert-ui>
|
|
1058
|
+
<alert-ui variant="danger" icon="warning" text="Failed to load"></alert-ui>
|
|
1059
|
+
<alert-ui variant="warning" icon="info" text="Trial expires in 3 days"></alert-ui>
|
|
1060
|
+
|
|
1061
|
+
<badge-ui text="Active" variant="success"></badge-ui>
|
|
1062
|
+
<badge-ui text="Pending" variant="warning"></badge-ui>
|
|
1063
|
+
<badge-ui text="Failed" variant="danger"></badge-ui>
|
|
1064
|
+
<badge-ui status="New"></badge-ui> <!-- auto-maps to info variant -->
|
|
1065
|
+
<badge-ui variant="info" size="xs"></badge-ui>
|
|
1066
|
+
```
|