@adia-ai/web-modules 0.8.18 → 0.8.19
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/CHANGELOG.md +8 -0
- package/dist/everything.min.js +19 -19
- package/form/form-popover/form-popover.a2ui.json +75 -0
- package/form/form-popover/form-popover.css +48 -0
- package/form/form-popover/form-popover.js +140 -0
- package/form/form-popover/form-popover.test.js +95 -0
- package/form/form-popover/form-popover.yaml +123 -0
- package/form/index.js +1 -0
- package/package.json +5 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://adiaui.dev/a2ui/v0_9/components/FormPopover.json",
|
|
4
|
+
"title": "FormPopover",
|
|
5
|
+
"description": "Module-tier \"form fragment in a popover\" (operator mock, 2026-07-27): a\nselect-style summary trigger that opens an anchored panel holding ANY\nform primitives — check-ui groups, radio-ui groups, input-ui,\ndivider-ui, field-ui — slotted as ordinary light-DOM children. The\nmodule owns the trigger + popover packaging and a live selection\nsummary; the slotted controls keep their own name/value/event\ncontracts untouched.\n\nSummary contract: the trigger reads `{label} · N selected` while any\nslotted check-ui is checked, `{label} · N total` when none are (N =\ncheck-ui count), and just `{label}` when the fragment contains no\ncheck-ui at all. Radio groups, inputs, and other controls deliberately\ndo not count — checkboxes are the only \"N selected\" semantic that\nreads unambiguously in a summary.\n\nDecision rule vs adjacent surfaces (inherits popover-ui's): a pure\naction list is menu-ui; a single-value choice is select-ui (which owns\nits option list — do NOT rebuild select inside this module); an\nedge-anchored multi-field form is drawer-ui. form-popover-ui is for the\nin-between: a small anchored fragment mixing selection controls and\ninputs, e.g. a filter panel or a quick-save form.\n",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"allOf": [
|
|
8
|
+
{
|
|
9
|
+
"$ref": "common_types.json#/$defs/ComponentCommon"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"$ref": "common_types.json#/$defs/CatalogComponentCommon"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"component": {
|
|
17
|
+
"const": "FormPopover"
|
|
18
|
+
},
|
|
19
|
+
"heading": {
|
|
20
|
+
"description": "Optional panel heading rendered above the slotted fragment inside\nthe popover (\"Select options\"). Empty = no heading row.\n",
|
|
21
|
+
"type": "string",
|
|
22
|
+
"default": ""
|
|
23
|
+
},
|
|
24
|
+
"label": {
|
|
25
|
+
"description": "Summary-trigger prefix (\"Option items\"). The live count is\nappended after a middle dot; with no count the label renders\nalone. Required — with no label AND no check-ui children the\ntrigger would otherwise be an empty, nameless button; the runtime\nfalls back to \"Options\" in that state as a defensive accessible\nname, but authors must set a real label.\n",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"default": ""
|
|
28
|
+
},
|
|
29
|
+
"placement": {
|
|
30
|
+
"description": "Passed through to the internal popover-ui. `bottom-start` default\nper ADR-0034 — the panel is trigger-anchored and roughly\ntrigger-width, like a select listbox.\n",
|
|
31
|
+
"type": "string",
|
|
32
|
+
"default": "bottom-start"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"required": [
|
|
36
|
+
"component",
|
|
37
|
+
"label"
|
|
38
|
+
],
|
|
39
|
+
"unevaluatedProperties": false,
|
|
40
|
+
"x-adiaui": {
|
|
41
|
+
"anti_patterns": [],
|
|
42
|
+
"category": "input",
|
|
43
|
+
"composes": [
|
|
44
|
+
"popover-ui",
|
|
45
|
+
"button-ui"
|
|
46
|
+
],
|
|
47
|
+
"events": {
|
|
48
|
+
"summary-change": {
|
|
49
|
+
"description": "Fired (bubbling) whenever the selection summary recomputes —\ndetail: { selected, total }. The slotted controls' own change /\ninput events bubble through untouched; listen to those for\nvalues.\n"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"examples": [
|
|
53
|
+
{
|
|
54
|
+
"description": "Filter fragment — checkbox group + divider + radio pair + name input behind a summary trigger.",
|
|
55
|
+
"a2ui": "[\n {\"id\": \"root\", \"component\": \"FormPopover\", \"label\": \"Option items\", \"heading\": \"Select options\", \"children\": [\"c1\", \"c2\", \"c3\", \"d1\", \"r1\", \"r2\", \"d2\", \"name\"]},\n {\"id\": \"c1\", \"component\": \"CheckBox\", \"label\": \"Option item\", \"checked\": true},\n {\"id\": \"c2\", \"component\": \"CheckBox\", \"label\": \"Option item\"},\n {\"id\": \"c3\", \"component\": \"CheckBox\", \"label\": \"Option item\", \"checked\": true},\n {\"id\": \"d1\", \"component\": \"Divider\"},\n {\"id\": \"r1\", \"component\": \"Radio\", \"name\": \"mode\", \"label\": \"Save\", \"checked\": true},\n {\"id\": \"r2\", \"component\": \"Radio\", \"name\": \"mode\", \"label\": \"Delete\"},\n {\"id\": \"d2\", \"component\": \"Divider\"},\n {\"id\": \"name\", \"component\": \"Input\", \"placeholder\": \"Option name\"}\n]",
|
|
56
|
+
"name": "filter-popover"
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"keywords": [],
|
|
60
|
+
"name": "FormPopover",
|
|
61
|
+
"related": [],
|
|
62
|
+
"slots": {
|
|
63
|
+
"default": {
|
|
64
|
+
"description": "The form fragment. Any AdiaUI form primitives, in author order —\ncheck-ui, radio-ui, input-ui, divider-ui, field-ui, textarea-ui.\nAdopted into the popover panel on connect; DOM order is layout\norder (light-DOM contract).\n"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"states": [],
|
|
68
|
+
"status": "experimental",
|
|
69
|
+
"synonyms": {},
|
|
70
|
+
"tag": "form-popover-ui",
|
|
71
|
+
"tokens": {},
|
|
72
|
+
"traits": [],
|
|
73
|
+
"version": 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
2
|
+
<form-popover-ui> — summary trigger + form-fragment panel.
|
|
3
|
+
Two-block @scope pattern per component-token-contract.md.
|
|
4
|
+
The panel surface itself (bg/border/shadow) belongs to popover-ui —
|
|
5
|
+
this module styles only its own trigger chrome and body layout.
|
|
6
|
+
═══════════════════════════════════════════════════════════════ */
|
|
7
|
+
|
|
8
|
+
/* ── 1. Token declarations ── */
|
|
9
|
+
|
|
10
|
+
form-popover-ui {
|
|
11
|
+
--form-popover-min-width: 16rem;
|
|
12
|
+
--form-popover-gap: var(--a-space-2);
|
|
13
|
+
--form-popover-trigger-bg: var(--a-ui-bg);
|
|
14
|
+
--form-popover-trigger-fg: var(--a-ui-text);
|
|
15
|
+
--form-popover-trigger-border: var(--a-ui-border);
|
|
16
|
+
--form-popover-heading-fg: var(--a-fg-strong);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* ── 2. Scoped layout ── */
|
|
20
|
+
|
|
21
|
+
@scope (form-popover-ui) {
|
|
22
|
+
:scope {
|
|
23
|
+
display: block;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Field-look trigger: bordered, full-width, summary left / chevron
|
|
27
|
+
right — reads as a select trigger, not a button. Re-points
|
|
28
|
+
button-ui's own tokens (the sanctioned consumer override path). */
|
|
29
|
+
button-ui[data-form-popover-trigger] {
|
|
30
|
+
--button-bg: var(--form-popover-trigger-bg);
|
|
31
|
+
--button-fg: var(--form-popover-trigger-fg);
|
|
32
|
+
--button-border: var(--form-popover-trigger-border);
|
|
33
|
+
width: 100%;
|
|
34
|
+
justify-content: space-between;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[data-form-popover-body] {
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
gap: var(--form-popover-gap);
|
|
41
|
+
min-width: var(--form-popover-min-width);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
[data-form-popover-heading] {
|
|
45
|
+
font-weight: var(--a-weight-medium);
|
|
46
|
+
color: var(--form-popover-heading-fg);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <form-popover-ui> — a form fragment behind a select-style summary trigger.
|
|
3
|
+
*
|
|
4
|
+
* Anatomy (all light-DOM, stamped once in connected()):
|
|
5
|
+
* <form-popover-ui label="Option items" heading="Select options">
|
|
6
|
+
* <check-ui label="…"></check-ui> … ← author's fragment
|
|
7
|
+
* </form-popover>
|
|
8
|
+
* becomes
|
|
9
|
+
* <form-popover-ui …>
|
|
10
|
+
* <popover-ui placement="…">
|
|
11
|
+
* <button-ui slot="trigger" data-form-popover-trigger
|
|
12
|
+
* icon-trailing="caret-down" text="Option items · 3 selected">
|
|
13
|
+
* <div slot="content" data-form-popover-body>
|
|
14
|
+
* [data-form-popover-heading]? ← from [heading]
|
|
15
|
+
* …the author's fragment, moved, order preserved…
|
|
16
|
+
* </div>
|
|
17
|
+
* </popover-ui>
|
|
18
|
+
* </form-popover>
|
|
19
|
+
*
|
|
20
|
+
* The module owns packaging + summary only. Slotted controls keep their
|
|
21
|
+
* own name/value/event contracts; their change/input events bubble
|
|
22
|
+
* through untouched (we listen to the same bubbling events to recompute
|
|
23
|
+
* the summary — no state is duplicated here, the DOM is the source of
|
|
24
|
+
* truth, same rule as chart-legend-ui's peer contract).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { UIElement } from '../../../web-components/core/element.js';
|
|
28
|
+
|
|
29
|
+
const SUMMARY_EVENTS = ['change', 'input'];
|
|
30
|
+
|
|
31
|
+
class FormPopover extends UIElement {
|
|
32
|
+
static properties = {
|
|
33
|
+
label: { type: String, default: '', reflect: true },
|
|
34
|
+
heading: { type: String, default: '', reflect: true },
|
|
35
|
+
placement: { type: String, default: 'bottom-start', reflect: true },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
static template = () => null;
|
|
39
|
+
|
|
40
|
+
#stamped = false;
|
|
41
|
+
#triggerEl = null;
|
|
42
|
+
#bodyEl = null;
|
|
43
|
+
// Stable handler ref so disconnected() can remove what connected()
|
|
44
|
+
// added (lifecycle symmetry — inline arrows can't be removed).
|
|
45
|
+
#onFragmentChange = () => this.#updateSummary();
|
|
46
|
+
|
|
47
|
+
connected() {
|
|
48
|
+
if (!this.#stamped) {
|
|
49
|
+
this.#stamp();
|
|
50
|
+
this.#stamped = true;
|
|
51
|
+
}
|
|
52
|
+
for (const type of SUMMARY_EVENTS) this.addEventListener(type, this.#onFragmentChange);
|
|
53
|
+
this.#updateSummary();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
disconnected() {
|
|
57
|
+
for (const type of SUMMARY_EVENTS) this.removeEventListener(type, this.#onFragmentChange);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
updated(changed) {
|
|
61
|
+
if (!this.#stamped) return;
|
|
62
|
+
if (changed.has('label')) this.#updateSummary();
|
|
63
|
+
if (changed.has('heading')) this.#renderHeading();
|
|
64
|
+
if (changed.has('placement')) {
|
|
65
|
+
this.querySelector(':scope > popover-ui')?.setAttribute('placement', this.placement);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** { selected, total } over the slotted check-ui population. */
|
|
70
|
+
get summary() {
|
|
71
|
+
const boxes = this.#bodyEl ? this.#bodyEl.querySelectorAll('check-ui') : [];
|
|
72
|
+
let selected = 0;
|
|
73
|
+
for (const box of boxes) if (box.hasAttribute('checked') || box.checked) selected += 1;
|
|
74
|
+
return { selected, total: boxes.length };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#stamp() {
|
|
78
|
+
// Adopt the author's fragment BEFORE stamping our own skeleton so the
|
|
79
|
+
// move loop never eats the popover we create (gh#284 class of bug).
|
|
80
|
+
const fragment = document.createDocumentFragment();
|
|
81
|
+
while (this.firstChild) fragment.appendChild(this.firstChild);
|
|
82
|
+
|
|
83
|
+
const popover = document.createElement('popover-ui');
|
|
84
|
+
popover.setAttribute('placement', this.placement);
|
|
85
|
+
|
|
86
|
+
const trigger = document.createElement('button-ui');
|
|
87
|
+
trigger.setAttribute('slot', 'trigger');
|
|
88
|
+
trigger.setAttribute('data-form-popover-trigger', '');
|
|
89
|
+
trigger.setAttribute('icon-trailing', 'caret-down');
|
|
90
|
+
this.#triggerEl = trigger;
|
|
91
|
+
popover.appendChild(trigger);
|
|
92
|
+
|
|
93
|
+
const body = document.createElement('div');
|
|
94
|
+
body.setAttribute('slot', 'content');
|
|
95
|
+
body.setAttribute('data-form-popover-body', '');
|
|
96
|
+
body.appendChild(fragment);
|
|
97
|
+
this.#bodyEl = body;
|
|
98
|
+
popover.appendChild(body);
|
|
99
|
+
|
|
100
|
+
this.appendChild(popover);
|
|
101
|
+
this.#renderHeading();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#renderHeading() {
|
|
105
|
+
if (!this.#bodyEl) return;
|
|
106
|
+
let el = this.#bodyEl.querySelector(':scope > [data-form-popover-heading]');
|
|
107
|
+
if (!this.heading) { el?.remove(); return; }
|
|
108
|
+
if (!el) {
|
|
109
|
+
el = document.createElement('div');
|
|
110
|
+
el.setAttribute('data-form-popover-heading', '');
|
|
111
|
+
this.#bodyEl.prepend(el);
|
|
112
|
+
}
|
|
113
|
+
el.textContent = this.heading;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#updateSummary() {
|
|
117
|
+
if (!this.#triggerEl) return;
|
|
118
|
+
const { selected, total } = this.summary;
|
|
119
|
+
let text = this.label;
|
|
120
|
+
if (total > 0) {
|
|
121
|
+
const count = selected > 0 ? `${selected} selected` : `${total} total`;
|
|
122
|
+
text = text ? `${text} · ${count}` : count;
|
|
123
|
+
}
|
|
124
|
+
// [label] is required by contract; this fallback only guards the
|
|
125
|
+
// no-label + no-checkbox misuse so the trigger never renders as an
|
|
126
|
+
// empty, nameless button (a11y — CodeRabbit finding on #441).
|
|
127
|
+
if (!text) text = 'Options';
|
|
128
|
+
this.#triggerEl.setAttribute('text', text);
|
|
129
|
+
this.dispatchEvent(new CustomEvent('summary-change', {
|
|
130
|
+
bubbles: true,
|
|
131
|
+
detail: { selected, total },
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!customElements.get('form-popover-ui')) {
|
|
137
|
+
customElements.define('form-popover-ui', FormPopover);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { FormPopover };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import './form-popover.js';
|
|
3
|
+
|
|
4
|
+
const mount = (html) => {
|
|
5
|
+
const wrap = document.createElement('div');
|
|
6
|
+
wrap.innerHTML = html;
|
|
7
|
+
document.body.appendChild(wrap);
|
|
8
|
+
return wrap.firstElementChild;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('form-popover-ui', () => {
|
|
12
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
13
|
+
|
|
14
|
+
it('adopts the fragment into the popover body, order preserved', () => {
|
|
15
|
+
const el = mount(`<form-popover-ui label="Options">
|
|
16
|
+
<check-ui label="a"></check-ui>
|
|
17
|
+
<divider-ui></divider-ui>
|
|
18
|
+
<input-ui placeholder="name"></input-ui>
|
|
19
|
+
</form-popover>`);
|
|
20
|
+
const body = el.querySelector('[data-form-popover-body]');
|
|
21
|
+
expect(body).toBeTruthy();
|
|
22
|
+
const tags = [...body.children].map((c) => c.tagName.toLowerCase());
|
|
23
|
+
expect(tags).toEqual(['check-ui', 'divider-ui', 'input-ui']);
|
|
24
|
+
// the popover skeleton is NOT part of the adopted fragment
|
|
25
|
+
expect(el.querySelector(':scope > popover-ui')).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('summary counts checked check-ui: "N selected" / "N total"', () => {
|
|
29
|
+
const el = mount(`<form-popover-ui label="Option items">
|
|
30
|
+
<check-ui label="a" checked></check-ui>
|
|
31
|
+
<check-ui label="b"></check-ui>
|
|
32
|
+
<check-ui label="c" checked></check-ui>
|
|
33
|
+
</form-popover>`);
|
|
34
|
+
const trigger = el.querySelector('[data-form-popover-trigger]');
|
|
35
|
+
expect(trigger.getAttribute('text')).toBe('Option items · 2 selected');
|
|
36
|
+
expect(el.summary).toEqual({ selected: 2, total: 3 });
|
|
37
|
+
// uncheck everything → falls back to total
|
|
38
|
+
for (const box of el.querySelectorAll('check-ui')) box.removeAttribute('checked');
|
|
39
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
40
|
+
expect(trigger.getAttribute('text')).toBe('Option items · 3 total');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('renders the bare label when the fragment has no check-ui', () => {
|
|
44
|
+
const el = mount(`<form-popover-ui label="Rename">
|
|
45
|
+
<input-ui placeholder="name"></input-ui>
|
|
46
|
+
</form-popover>`);
|
|
47
|
+
expect(el.querySelector('[data-form-popover-trigger]').getAttribute('text')).toBe('Rename');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('[heading] renders and clears', async () => {
|
|
51
|
+
const el = mount(`<form-popover-ui label="x" heading="Select options"><check-ui label="a"></check-ui></form-popover>`);
|
|
52
|
+
expect(el.querySelector('[data-form-popover-heading]').textContent).toBe('Select options');
|
|
53
|
+
el.heading = '';
|
|
54
|
+
await Promise.resolve();
|
|
55
|
+
expect(el.querySelector('[data-form-popover-heading]')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('emits summary-change with detail', () => {
|
|
59
|
+
const el = mount(`<form-popover-ui label="x"><check-ui label="a"></check-ui></form-popover>`);
|
|
60
|
+
let detail = null;
|
|
61
|
+
el.addEventListener('summary-change', (e) => { detail = e.detail; });
|
|
62
|
+
el.querySelector('check-ui').setAttribute('checked', '');
|
|
63
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
64
|
+
expect(detail).toEqual({ selected: 1, total: 1 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('falls back to "Options" with no label and no checkboxes (a11y guard)', () => {
|
|
68
|
+
const el = mount(`<form-popover-ui><input-ui placeholder="name"></input-ui></form-popover-ui>`);
|
|
69
|
+
expect(el.querySelector('[data-form-popover-trigger]').getAttribute('text')).toBe('Options');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('passes [placement] through to the internal popover, including updates', async () => {
|
|
73
|
+
const el = mount(`<form-popover-ui label="x" placement="top"><check-ui label="a"></check-ui></form-popover-ui>`);
|
|
74
|
+
expect(el.querySelector(':scope > popover-ui').getAttribute('placement')).toBe('top');
|
|
75
|
+
el.placement = 'bottom-end';
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
expect(el.querySelector(':scope > popover-ui').getAttribute('placement')).toBe('bottom-end');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('[label] updates re-render the summary reactively', async () => {
|
|
81
|
+
const el = mount(`<form-popover-ui label="Old"><check-ui label="a" checked></check-ui></form-popover-ui>`);
|
|
82
|
+
el.label = 'New';
|
|
83
|
+
await Promise.resolve();
|
|
84
|
+
expect(el.querySelector('[data-form-popover-trigger]').getAttribute('text')).toBe('New · 1 selected');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('disconnect removes the summary listener (no stale updates after removal)', () => {
|
|
88
|
+
const el = mount(`<form-popover-ui label="x"><check-ui label="a"></check-ui></form-popover-ui>`);
|
|
89
|
+
const trigger = el.querySelector('[data-form-popover-trigger]');
|
|
90
|
+
el.remove();
|
|
91
|
+
el.querySelector('check-ui').setAttribute('checked', '');
|
|
92
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
93
|
+
expect(trigger.getAttribute('text')).toBe('x · 1 total');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Edit this file; run `npm run build:components` to regenerate a2ui.json.
|
|
2
|
+
$schema: ../../../../scripts/schemas/component.yaml.schema.json
|
|
3
|
+
name: FormPopover
|
|
4
|
+
tag: form-popover-ui
|
|
5
|
+
status: experimental
|
|
6
|
+
component: FormPopover
|
|
7
|
+
category: input
|
|
8
|
+
version: 1
|
|
9
|
+
description: |
|
|
10
|
+
Module-tier "form fragment in a popover" (operator mock, 2026-07-27): a
|
|
11
|
+
select-style summary trigger that opens an anchored panel holding ANY
|
|
12
|
+
form primitives — check-ui groups, radio-ui groups, input-ui,
|
|
13
|
+
divider-ui, field-ui — slotted as ordinary light-DOM children. The
|
|
14
|
+
module owns the trigger + popover packaging and a live selection
|
|
15
|
+
summary; the slotted controls keep their own name/value/event
|
|
16
|
+
contracts untouched.
|
|
17
|
+
|
|
18
|
+
Summary contract: the trigger reads `{label} · N selected` while any
|
|
19
|
+
slotted check-ui is checked, `{label} · N total` when none are (N =
|
|
20
|
+
check-ui count), and just `{label}` when the fragment contains no
|
|
21
|
+
check-ui at all. Radio groups, inputs, and other controls deliberately
|
|
22
|
+
do not count — checkboxes are the only "N selected" semantic that
|
|
23
|
+
reads unambiguously in a summary.
|
|
24
|
+
|
|
25
|
+
Decision rule vs adjacent surfaces (inherits popover-ui's): a pure
|
|
26
|
+
action list is menu-ui; a single-value choice is select-ui (which owns
|
|
27
|
+
its option list — do NOT rebuild select inside this module); an
|
|
28
|
+
edge-anchored multi-field form is drawer-ui. form-popover-ui is for the
|
|
29
|
+
in-between: a small anchored fragment mixing selection controls and
|
|
30
|
+
inputs, e.g. a filter panel or a quick-save form.
|
|
31
|
+
|
|
32
|
+
# Per ADR-0027 — primitives that programmatically create other primitives
|
|
33
|
+
# do NOT auto-import them. Consumer (or demo shell) must explicitly import.
|
|
34
|
+
composes:
|
|
35
|
+
- popover-ui
|
|
36
|
+
- button-ui
|
|
37
|
+
props:
|
|
38
|
+
label:
|
|
39
|
+
description: |
|
|
40
|
+
Summary-trigger prefix ("Option items"). The live count is
|
|
41
|
+
appended after a middle dot; with no count the label renders
|
|
42
|
+
alone. Required — with no label AND no check-ui children the
|
|
43
|
+
trigger would otherwise be an empty, nameless button; the runtime
|
|
44
|
+
falls back to "Options" in that state as a defensive accessible
|
|
45
|
+
name, but authors must set a real label.
|
|
46
|
+
type: string
|
|
47
|
+
required: true
|
|
48
|
+
default: ""
|
|
49
|
+
reflect: true
|
|
50
|
+
|
|
51
|
+
heading:
|
|
52
|
+
description: |
|
|
53
|
+
Optional panel heading rendered above the slotted fragment inside
|
|
54
|
+
the popover ("Select options"). Empty = no heading row.
|
|
55
|
+
type: string
|
|
56
|
+
default: ""
|
|
57
|
+
reflect: true
|
|
58
|
+
|
|
59
|
+
placement:
|
|
60
|
+
description: |
|
|
61
|
+
Passed through to the internal popover-ui. `bottom-start` default
|
|
62
|
+
per ADR-0034 — the panel is trigger-anchored and roughly
|
|
63
|
+
trigger-width, like a select listbox.
|
|
64
|
+
type: string
|
|
65
|
+
default: bottom-start
|
|
66
|
+
reflect: true
|
|
67
|
+
|
|
68
|
+
events:
|
|
69
|
+
summary-change:
|
|
70
|
+
description: |
|
|
71
|
+
Fired (bubbling) whenever the selection summary recomputes —
|
|
72
|
+
detail: { selected, total }. The slotted controls' own change /
|
|
73
|
+
input events bubble through untouched; listen to those for
|
|
74
|
+
values.
|
|
75
|
+
slots:
|
|
76
|
+
default:
|
|
77
|
+
description: |
|
|
78
|
+
The form fragment. Any AdiaUI form primitives, in author order —
|
|
79
|
+
check-ui, radio-ui, input-ui, divider-ui, field-ui, textarea-ui.
|
|
80
|
+
Adopted into the popover panel on connect; DOM order is layout
|
|
81
|
+
order (light-DOM contract).
|
|
82
|
+
icons:
|
|
83
|
+
- caret-down # summary-trigger chevron (button-ui[icon-trailing])
|
|
84
|
+
a2ui:
|
|
85
|
+
category: input
|
|
86
|
+
description: Summary-trigger popover holding an arbitrary form fragment (checkbox groups, radio groups, inputs).
|
|
87
|
+
rules:
|
|
88
|
+
- >-
|
|
89
|
+
Children are the form fragment, in author order: CheckBox, Radio,
|
|
90
|
+
Input, Textarea, Divider, Field. Do NOT put Option children here —
|
|
91
|
+
a single-value option list is Select's contract; FormPopover never
|
|
92
|
+
rebuilds it.
|
|
93
|
+
- >-
|
|
94
|
+
Decision rule vs siblings: single-value choice → Select (with
|
|
95
|
+
multiple:true for multi-select chips); pure action list → Menu;
|
|
96
|
+
edge-anchored multi-field form → Drawer. FormPopover is only for a
|
|
97
|
+
small MIXED fragment (checks + radios + an input) behind a summary
|
|
98
|
+
trigger.
|
|
99
|
+
- >-
|
|
100
|
+
The trigger summary counts CheckBox children only ("N selected" /
|
|
101
|
+
"N total"); Radio and Input children never count. Give every Radio
|
|
102
|
+
child one shared name per group or the browser treats them as
|
|
103
|
+
independent.
|
|
104
|
+
props:
|
|
105
|
+
label: { type: string, description: "Summary-trigger prefix text" }
|
|
106
|
+
heading: { type: string, description: "Optional panel heading" }
|
|
107
|
+
placement: { type: string, description: "Popover placement (default bottom-start)" }
|
|
108
|
+
children: form primitives (CheckBox, Radio, Input, Divider, Field)
|
|
109
|
+
examples:
|
|
110
|
+
- name: filter-popover
|
|
111
|
+
description: Filter fragment — checkbox group + divider + radio pair + name input behind a summary trigger.
|
|
112
|
+
a2ui: >-
|
|
113
|
+
[
|
|
114
|
+
{"id": "root", "component": "FormPopover", "label": "Option items", "heading": "Select options", "children": ["c1", "c2", "c3", "d1", "r1", "r2", "d2", "name"]},
|
|
115
|
+
{"id": "c1", "component": "CheckBox", "label": "Option item", "checked": true},
|
|
116
|
+
{"id": "c2", "component": "CheckBox", "label": "Option item"},
|
|
117
|
+
{"id": "c3", "component": "CheckBox", "label": "Option item", "checked": true},
|
|
118
|
+
{"id": "d1", "component": "Divider"},
|
|
119
|
+
{"id": "r1", "component": "Radio", "name": "mode", "label": "Save", "checked": true},
|
|
120
|
+
{"id": "r2", "component": "Radio", "name": "mode", "label": "Delete"},
|
|
121
|
+
{"id": "d2", "component": "Divider"},
|
|
122
|
+
{"id": "name", "component": "Input", "placeholder": "Option name"}
|
|
123
|
+
]
|
package/form/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FormPopover } from './form-popover/form-popover.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-modules",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.19",
|
|
4
4
|
"description": "AdiaUI composite custom elements \u2014 shell, chat, editor, runtime clusters built from @adia-ai/web-components primitives. Subpath exports per cluster.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -235,6 +235,10 @@
|
|
|
235
235
|
"!generative/**/*.examples.html",
|
|
236
236
|
"!generative/**/*.examples.js",
|
|
237
237
|
"!generative/**/*.html",
|
|
238
|
+
"form/",
|
|
239
|
+
"!form/**/*.examples.html",
|
|
240
|
+
"!form/**/*.examples.js",
|
|
241
|
+
"!form/**/*.html",
|
|
238
242
|
"index.js",
|
|
239
243
|
"css-module.d.ts",
|
|
240
244
|
"web-modules.sheet.d.ts",
|