@adia-ai/web-modules 0.8.17 → 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.
@@ -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.17",
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",