@adia-ai/web-modules 0.8.20 → 0.8.22

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/FormPopover.json",
4
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",
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 (gh#474 identical to select-ui[summary-label], so the\ntwo multi-select triggers speak one language): none checked reads just\n`{label}` (which doubles as the placeholder, since [label] is required\nhere), exactly one reads THAT check-ui's own label, and more than one\nreads `{label} (N)`. Radio groups, inputs, and other controls\ndeliberately do not count — checkboxes are the only selection semantic\nthat reads 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
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -22,7 +22,7 @@
22
22
  "default": ""
23
23
  },
24
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",
25
+ "description": "Summary-trigger text (gh#474 — same contract as\nselect-ui[summary-label]). With nothing checked it renders alone,\ndoubling as the placeholder; with exactly one check-ui checked the\ntrigger shows that control's own label instead; beyond one it\nrenders as \"{label} (N)\". Required — with no label AND no check-ui\nchildren the trigger would otherwise be an empty, nameless button;\nthe runtime falls back to \"Options\" in that state as a defensive\naccessible name, but authors must set a real label.\n",
26
26
  "type": "string",
27
27
  "default": ""
28
28
  },
@@ -9,7 +9,7 @@
9
9
  * <form-popover-ui …>
10
10
  * <popover-ui placement="…">
11
11
  * <button-ui slot="trigger" data-form-popover-trigger
12
- * icon-trailing="caret-down" text="Option items · 3 selected">
12
+ * icon-trailing="caret-down" text="Option items (3)">
13
13
  * <div slot="content" data-form-popover-body>
14
14
  * [data-form-popover-heading]? ← from [heading]
15
15
  * …the author's fragment, moved, order preserved…
@@ -43,6 +43,14 @@ class FormPopover extends UIElement {
43
43
  // Stable handler ref so disconnected() can remove what connected()
44
44
  // added (lifecycle symmetry — inline arrows can't be removed).
45
45
  #onFragmentChange = () => this.#updateSummary();
46
+ // Programmatic mutations fire no change/input event — `el.checked = true`
47
+ // reflects to the [checked] attribute (check-ui) but bubbles nothing, so
48
+ // an event-only summary goes stale, contradicting the module's own
49
+ // "DOM is the source of truth" rule (reviewer finding, 2026-07-29). The
50
+ // observer covers the non-event paths: reflected [checked] flips and
51
+ // check-ui population changes. Scoped to the BODY element so trigger
52
+ // attribute writes (our own summary text) can't feed back.
53
+ #summaryObserver = null;
46
54
 
47
55
  connected() {
48
56
  if (!this.#stamped) {
@@ -50,11 +58,22 @@ class FormPopover extends UIElement {
50
58
  this.#stamped = true;
51
59
  }
52
60
  for (const type of SUMMARY_EVENTS) this.addEventListener(type, this.#onFragmentChange);
61
+ if (this.#bodyEl && !this.#summaryObserver) {
62
+ this.#summaryObserver = new MutationObserver(this.#onFragmentChange);
63
+ this.#summaryObserver.observe(this.#bodyEl, {
64
+ subtree: true,
65
+ childList: true,
66
+ attributes: true,
67
+ attributeFilter: ['checked'],
68
+ });
69
+ }
53
70
  this.#updateSummary();
54
71
  }
55
72
 
56
73
  disconnected() {
57
74
  for (const type of SUMMARY_EVENTS) this.removeEventListener(type, this.#onFragmentChange);
75
+ this.#summaryObserver?.disconnect();
76
+ this.#summaryObserver = null;
58
77
  }
59
78
 
60
79
  updated(changed) {
@@ -66,12 +85,16 @@ class FormPopover extends UIElement {
66
85
  }
67
86
  }
68
87
 
69
- /** { selected, total } over the slotted check-ui population. */
88
+ /**
89
+ * { selected, total, checked } over the slotted check-ui population.
90
+ * `checked` is the checked elements themselves — the summary needs the
91
+ * single-selection element to read its own label (gh#474).
92
+ */
70
93
  get summary() {
71
94
  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 };
95
+ const checked = [];
96
+ for (const box of boxes) if (box.hasAttribute('checked') || box.checked) checked.push(box);
97
+ return { selected: checked.length, total: boxes.length, checked };
75
98
  }
76
99
 
77
100
  #stamp() {
@@ -113,14 +136,23 @@ class FormPopover extends UIElement {
113
136
  el.textContent = this.heading;
114
137
  }
115
138
 
139
+ /** The own label of a slotted check-ui — [label] prop, else its text. */
140
+ #labelOf(box) {
141
+ return (box.getAttribute('label') || box.label || box.textContent || '').trim();
142
+ }
143
+
116
144
  #updateSummary() {
117
145
  if (!this.#triggerEl) return;
118
- const { selected, total } = this.summary;
146
+ const { selected, total, checked } = this.summary;
147
+ // gh#474 — ONE summary contract across the two multi-select triggers:
148
+ // none → the label alone (it doubles as select-ui's placeholder, since
149
+ // [label] is required here), one → that control's own label, more →
150
+ // "Label (N)". Was "Label · N selected", which contradicted
151
+ // select-ui[summary-label]'s ruled format (gh#442) and blocked
152
+ // consumers from swapping a hand-rolled composition for this module.
119
153
  let text = this.label;
120
- if (total > 0) {
121
- const count = selected > 0 ? `${selected} selected` : `${total} total`;
122
- text = text ? `${text} · ${count}` : count;
123
- }
154
+ if (selected === 1 && checked[0]) text = this.#labelOf(checked[0]) || text;
155
+ else if (selected > 1) text = text ? `${text} (${selected})` : `${selected}`;
124
156
  // [label] is required by contract; this fallback only guards the
125
157
  // no-label + no-checkbox misuse so the trigger never renders as an
126
158
  // empty, nameless button (a11y — CodeRabbit finding on #441).
@@ -15,12 +15,13 @@ description: |
15
15
  summary; the slotted controls keep their own name/value/event
16
16
  contracts untouched.
17
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.
18
+ Summary contract (gh#474 identical to select-ui[summary-label], so the
19
+ two multi-select triggers speak one language): none checked reads just
20
+ `{label}` (which doubles as the placeholder, since [label] is required
21
+ here), exactly one reads THAT check-ui's own label, and more than one
22
+ reads `{label} (N)`. Radio groups, inputs, and other controls
23
+ deliberately do not count — checkboxes are the only selection semantic
24
+ that reads unambiguously in a summary.
24
25
 
25
26
  Decision rule vs adjacent surfaces (inherits popover-ui's): a pure
26
27
  action list is menu-ui; a single-value choice is select-ui (which owns
@@ -37,12 +38,14 @@ composes:
37
38
  props:
38
39
  label:
39
40
  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.
41
+ Summary-trigger text (gh#474 same contract as
42
+ select-ui[summary-label]). With nothing checked it renders alone,
43
+ doubling as the placeholder; with exactly one check-ui checked the
44
+ trigger shows that control's own label instead; beyond one it
45
+ renders as "{label} (N)". Required with no label AND no check-ui
46
+ children the trigger would otherwise be an empty, nameless button;
47
+ the runtime falls back to "Options" in that state as a defensive
48
+ accessible name, but authors must set a real label.
46
49
  type: string
47
50
  required: true
48
51
  default: ""
@@ -97,12 +100,13 @@ a2ui:
97
100
  small MIXED fragment (checks + radios + an input) behind a summary
98
101
  trigger.
99
102
  - >-
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.
103
+ The trigger summary counts CheckBox children only one checked
104
+ shows its own label, more show "Label (N)", none shows the bare
105
+ label (same contract as Select[summary-label]); Radio and Input
106
+ children never count. Give every Radio child one shared name per
107
+ group or the browser treats them as independent.
104
108
  props:
105
- label: { type: string, description: "Summary-trigger prefix text" }
109
+ label: { type: string, description: "Summary-trigger text — shown alone when nothing is checked (its placeholder role) and as the \"Label (N)\" prefix when more than one is" }
106
110
  heading: { type: string, description: "Optional panel heading" }
107
111
  placement: { type: string, description: "Popover placement (default bottom-start)" }
108
112
  children: form primitives (CheckBox, Radio, Input, Divider, Field)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-modules",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
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": {