@adia-ai/web-components 0.8.4 → 0.8.5

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/USAGE.md +60 -25
  3. package/components/adia-mark/adia-mark.a2ui.json +123 -0
  4. package/components/adia-mark/adia-mark.class.js +64 -0
  5. package/components/adia-mark/adia-mark.css +48 -0
  6. package/components/adia-mark/adia-mark.d.ts +20 -0
  7. package/components/adia-mark/adia-mark.examples.md +26 -0
  8. package/components/adia-mark/adia-mark.js +17 -0
  9. package/components/adia-mark/adia-mark.test.js +69 -0
  10. package/components/adia-mark/adia-mark.yaml +121 -0
  11. package/components/agent-questions/agent-questions.a2ui.json +2 -1
  12. package/components/agent-questions/agent-questions.class.js +96 -53
  13. package/components/agent-questions/agent-questions.css +27 -106
  14. package/components/agent-questions/agent-questions.yaml +1 -0
  15. package/components/button/button.class.js +5 -1
  16. package/components/calendar-grid/calendar-grid.a2ui.json +3 -1
  17. package/components/calendar-grid/calendar-grid.class.js +11 -6
  18. package/components/calendar-grid/calendar-grid.css +42 -20
  19. package/components/calendar-grid/calendar-grid.yaml +5 -0
  20. package/components/calendar-picker/calendar-picker.a2ui.json +3 -1
  21. package/components/calendar-picker/calendar-picker.class.js +11 -6
  22. package/components/calendar-picker/calendar-picker.css +39 -19
  23. package/components/calendar-picker/calendar-picker.yaml +5 -0
  24. package/components/chart/chart.class.js +17 -14
  25. package/components/color-input/color-input.class.js +8 -5
  26. package/components/color-picker/color-picker.class.js +6 -3
  27. package/components/field/field.class.js +10 -7
  28. package/components/fields/fields.class.js +15 -12
  29. package/components/icon/icon.class.js +9 -5
  30. package/components/index.js +1 -0
  31. package/components/nav/nav.class.js +2 -1
  32. package/components/noodles/noodles.class.js +14 -10
  33. package/components/page/page.class.js +3 -2
  34. package/components/select/select.class.js +3 -2
  35. package/components/swatch/swatch.class.js +27 -24
  36. package/components/swiper/swiper.class.js +4 -1
  37. package/components/swiper/swiper.css +13 -4
  38. package/components/toolbar/toolbar.class.js +11 -6
  39. package/components/tree/tree.class.js +5 -2
  40. package/core/data-stream.js +21 -15
  41. package/core/element.js +36 -1
  42. package/core/element.test.js +67 -0
  43. package/core/icons-phosphor.js +86 -22
  44. package/core/icons.js +22 -8
  45. package/dist/theme-provider.min.js +2 -2
  46. package/dist/web-components.min.css +1 -1
  47. package/dist/web-components.min.js +107 -102
  48. package/dist/web-components.sheet.js +1 -1
  49. package/package.json +1 -5
  50. package/styles/components.css +1 -0
  51. package/traits/error-shake/error-shake.js +23 -17
  52. package/traits/fade-presence/fade-presence.js +19 -15
  53. package/traits/success-checkmark/success-checkmark.js +21 -16
@@ -41,20 +41,11 @@
41
41
 
42
42
  import { UIElement } from '../../core/element.js';
43
43
 
44
- function escapeText(s) {
45
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
46
- }
47
-
48
- function escapeAttr(s) {
49
- return String(s).replace(/"/g, '&quot;');
50
- }
51
-
52
44
  export class UIAgentQuestions extends UIElement {
53
- // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
54
- // consumer markup). Aggregated by installIconLoadersForRegistered()
55
- // across all defined elements. Audited by check-required-icons.mjs
56
- // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
57
- static requiredIcons = ['check'];
45
+ // gh issue 276 wave 4: options now render as <option-card-ui> (which
46
+ // composes icon-ui itself for the leading `icon` prop). This primitive
47
+ // no longer stamps any icon directly, so no requiredIcons entry is
48
+ // needed here see check-required-icons.mjs (slot 11).
58
49
 
59
50
  static properties = {
60
51
  multi: { type: Boolean, default: false, reflect: true },
@@ -87,6 +78,9 @@ export class UIAgentQuestions extends UIElement {
87
78
 
88
79
  disconnected() {
89
80
  this.#submitEl?.removeEventListener('press', this.#onSubmit);
81
+ this.#optionsEl?.removeEventListener('change', this.#onCardChange);
82
+ this.#optionsEl?.removeEventListener('click', this.#onOptionsClickCapture, true);
83
+ this.#optionsEl?.removeEventListener('keydown', this.#onOptionsKeydownCapture, true);
90
84
  this.#questionEl = this.#helperEl = this.#optionsEl = this.#submitEl = null;
91
85
  }
92
86
 
@@ -121,6 +115,20 @@ export class UIAgentQuestions extends UIElement {
121
115
 
122
116
  this.#optionsEl = document.createElement('div');
123
117
  this.#optionsEl.setAttribute('data-questions-options', '');
118
+ // Single-select: option-card-ui's own radiogroup machinery handles
119
+ // click + keyboard natively and fires `change` when a card becomes
120
+ // selected — we just listen for it.
121
+ this.#optionsEl.addEventListener('change', this.#onCardChange);
122
+ // Multi-select: option-card-ui is radio-only (its #select() no-ops
123
+ // once checked, and Enter/Space call that private method directly
124
+ // rather than dispatching a click), so there is no toggle-off hook
125
+ // to attach to. Intercept at the container's CAPTURE phase — before
126
+ // the card's own bubble-phase listeners ever run — and drive
127
+ // checked/aria state ourselves. Single mode never reaches these
128
+ // (guarded by `if (!this.multi) return`), so its native behavior is
129
+ // untouched.
130
+ this.#optionsEl.addEventListener('click', this.#onOptionsClickCapture, true);
131
+ this.#optionsEl.addEventListener('keydown', this.#onOptionsKeydownCapture, true);
124
132
 
125
133
  this.#submitEl = document.createElement('button-ui');
126
134
  this.#submitEl.setAttribute('text', this.submitLabel || 'Continue');
@@ -139,33 +147,28 @@ export class UIAgentQuestions extends UIElement {
139
147
  this.#optionsEl.innerHTML = '';
140
148
 
141
149
  for (const opt of this.#options) {
142
- const card = document.createElement('button');
143
- card.type = 'button';
150
+ const card = document.createElement('option-card-ui');
144
151
  card.setAttribute('data-questions-option', '');
145
- card.setAttribute('role', this.multi ? 'checkbox' : 'radio');
146
- card.setAttribute('aria-checked', this.#selected.has(opt.id) ? 'true' : 'false');
147
- if (this.#selected.has(opt.id)) card.setAttribute('data-selected', '');
148
- if (this.#answered) card.setAttribute('disabled', '');
149
-
150
- const iconHTML = opt.icon
151
- ? `<icon-ui name="${escapeAttr(opt.icon)}" color="muted" data-questions-option-icon></icon-ui>`
152
- : '';
153
- const checkHTML = `
154
- <span data-questions-option-check aria-hidden="true">
155
- <icon-ui name="check" size="sm" color="accent"></icon-ui>
156
- </span>`;
157
-
158
- card.innerHTML = `
159
- ${iconHTML}
160
- <span data-questions-option-body>
161
- <span data-questions-option-label>${escapeText(opt.label || '')}</span>
162
- ${opt.description ? `<span data-questions-option-desc>${escapeText(opt.description)}</span>` : ''}
163
- </span>
164
- ${checkHTML}
165
- `;
166
-
167
- card.addEventListener('click', () => this.#pick(opt));
152
+ card.setAttribute('data-questions-option-id', opt.id);
153
+ // Shared name → siblings form option-card-ui's own radiogroup
154
+ // (single mode). Inert in multi mode: #select() never runs there
155
+ // (see the capture-phase override below), so the shared name has
156
+ // no sibling-uncheck effect to guard against.
157
+ card.name = 'questions-option';
158
+ card.value = opt.id;
159
+ card.heading = opt.label || '';
160
+ card.description = opt.description || '';
161
+ card.icon = opt.icon || '';
162
+
168
163
  this.#optionsEl.appendChild(card);
164
+
165
+ // Multi mode: override the radio role option-card-ui sets on
166
+ // connect — this is a checkbox group, and aria-checked (which the
167
+ // primitive keeps in sync with `.checked` regardless of role) is
168
+ // valid for either.
169
+ if (this.multi) card.setAttribute('role', 'checkbox');
170
+ card.checked = this.#selected.has(opt.id);
171
+ card.disabled = this.#answered;
169
172
  }
170
173
 
171
174
  if (this.#submitEl) {
@@ -173,22 +176,62 @@ export class UIAgentQuestions extends UIElement {
173
176
  }
174
177
  }
175
178
 
176
- #pick(opt) {
177
- if (this.#answered) return;
178
- if (this.multi) {
179
- if (this.#selected.has(opt.id)) this.#selected.delete(opt.id);
180
- else this.#selected.add(opt.id);
181
- this.#render();
182
- } else {
183
- this.#selected.clear();
184
- this.#selected.add(opt.id);
185
- this.#answered = true;
186
- this.#render();
187
- this.dispatchEvent(new CustomEvent('questions-answer', {
188
- bubbles: true,
189
- detail: { selected: [opt.id], option: opt },
190
- }));
179
+ /** Single mode — option-card-ui's own radiogroup fired `change` because
180
+ * a card just became selected (it never fires on becoming unselected,
181
+ * so this is always a genuine new pick). */
182
+ #onCardChange = (e) => {
183
+ if (this.multi || this.#answered) return;
184
+ const id = e.detail?.value;
185
+ const opt = this.#options.find((o) => o.id === id);
186
+ if (!opt) return;
187
+ this.#selected.clear();
188
+ this.#selected.add(opt.id);
189
+ this.#answered = true;
190
+ this.#render();
191
+ this.dispatchEvent(new CustomEvent('questions-answer', {
192
+ bubbles: true,
193
+ detail: { selected: [opt.id], option: opt },
194
+ }));
195
+ };
196
+
197
+ /** Multi mode — capture-phase click override (see #build). Stops the
198
+ * event before option-card-ui's own bubble-phase click handler runs,
199
+ * since that handler can only select, never deselect. */
200
+ #onOptionsClickCapture = (e) => {
201
+ if (!this.multi) return;
202
+ const card = e.target.closest?.('option-card-ui[data-questions-option]');
203
+ if (!card || !this.#optionsEl.contains(card)) return;
204
+ e.stopPropagation();
205
+ this.#toggleMulti(card);
206
+ };
207
+
208
+ /** Multi mode — capture-phase keydown override. Space/Enter toggle
209
+ * (mirroring click); arrow keys are swallowed rather than forwarded
210
+ * to option-card-ui's own forced-select navigation, since checkbox
211
+ * groups here never had arrow-key nav (Tab-only, as before). */
212
+ #onOptionsKeydownCapture = (e) => {
213
+ if (!this.multi) return;
214
+ const card = e.target.closest?.('option-card-ui[data-questions-option]');
215
+ if (!card || !this.#optionsEl.contains(card)) return;
216
+ if (e.key === ' ' || e.key === 'Enter') {
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ this.#toggleMulti(card);
220
+ return;
191
221
  }
222
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
223
+ e.stopPropagation();
224
+ }
225
+ };
226
+
227
+ #toggleMulti(card) {
228
+ if (this.#answered) return;
229
+ const id = card.dataset.questionsOptionId;
230
+ const opt = this.#options.find((o) => o.id === id);
231
+ if (!opt) return;
232
+ if (this.#selected.has(opt.id)) this.#selected.delete(opt.id);
233
+ else this.#selected.add(opt.id);
234
+ this.#render();
192
235
  }
193
236
 
194
237
  #onSubmit = () => {
@@ -18,8 +18,6 @@
18
18
  --agent-questions-option-bg-sel: var(--md-sys-color-primary-container);
19
19
  --agent-questions-option-border: var(--md-sys-color-neutral-outline-variant);
20
20
  --agent-questions-option-border-sel: var(--a-primary-bg);
21
- --agent-questions-check-bg: var(--a-primary-bg);
22
- --agent-questions-check-fg: var(--md-sys-color-primary-on-primary);
23
21
 
24
22
  /* ── Layout ── */
25
23
  --agent-questions-gap: var(--a-space-2);
@@ -27,9 +25,6 @@
27
25
  --agent-questions-option-padding: var(--a-space-3);
28
26
  --agent-questions-option-radius: var(--a-radius);
29
27
  --agent-questions-q-padding: var(--a-space-1);
30
- --agent-questions-option-body-gap: var(--a-space-0-5);
31
- /* Component-intrinsic measurement; no --a-space-* equivalent */
32
- --agent-questions-check-size: 20px;
33
28
 
34
29
  /* ── Motion ── */
35
30
  --agent-questions-duration: var(--a-duration-fast);
@@ -61,108 +56,34 @@
61
56
  gap: var(--agent-questions-gap);
62
57
  }
63
58
 
59
+ /* gh issue 276 wave 4: options render as <option-card-ui> now — it owns
60
+ its own box model, borders, hover/checked/disabled states, focus ring,
61
+ and heading/description/icon layout via its own @scope'd CSS. This
62
+ re-points its token surface to agent-questions' existing tokens so the
63
+ migration doesn't shift the look; it does not restyle the card itself.
64
+ One deliberate visual difference: the selected indicator is now
65
+ option-card-ui's authentic filled-radio-dot recipe (shared with
66
+ plan-picker-ui) rather than the bespoke check-glyph badge the native
67
+ buttons drew — that badge doesn't exist as an option-card-ui slot. */
64
68
  [data-questions-option] {
65
- display: flex;
66
- /* Top-align so the leading icon stays with the label's first line
67
- when a description wraps below; we restore visual centering on
68
- the icon and check by nudging them to the label's first-line
69
- center via margin-top. Centering the whole row dragged the icon
70
- down to the body's vertical midpoint, which sat between label
71
- and description rather than on the label. */
72
- align-items: start;
73
- gap: var(--agent-questions-option-gap);
74
- padding: var(--agent-questions-option-padding);
75
- border: 1px solid var(--agent-questions-option-border);
76
- border-radius: var(--agent-questions-option-radius);
77
- background: var(--agent-questions-option-bg);
78
- color: inherit;
79
- cursor: pointer;
80
- text-align: start;
81
- font: inherit;
82
- /* font-family floor — see segment.css rationale (inline default). */
83
- font-family: var(--agent-questions-font-family, var(--a-font-family-ui));
84
- transition:
85
- background var(--agent-questions-duration) var(--agent-questions-easing),
86
- border-color var(--agent-questions-duration) var(--agent-questions-easing);
87
- min-width: 0;
69
+ --option-card-padding-block: var(--agent-questions-option-padding);
70
+ --option-card-padding-inline: var(--agent-questions-option-padding);
71
+ --option-card-radius: var(--agent-questions-option-radius);
72
+ --option-card-gap-x: var(--agent-questions-option-gap);
73
+ --option-card-bg: var(--agent-questions-option-bg);
74
+ --option-card-border: var(--agent-questions-option-border);
75
+ --option-card-bg-hover: var(--agent-questions-option-bg-hover);
76
+ /* Match prior behavior: hover changed background only, never border. */
77
+ --option-card-border-hover: var(--agent-questions-option-border);
78
+ --option-card-bg-checked: var(--agent-questions-option-bg-sel);
79
+ --option-card-border-checked: var(--agent-questions-option-border-sel);
80
+ --option-card-heading-color: var(--agent-questions-label-fg);
81
+ --option-card-heading-weight: var(--agent-questions-label-weight);
82
+ --option-card-heading-size: var(--agent-questions-label-size);
83
+ --option-card-desc-color: var(--agent-questions-desc-fg);
84
+ --option-card-desc-size: var(--agent-questions-desc-size);
85
+ --option-card-duration: var(--agent-questions-duration);
86
+ --option-card-easing: var(--agent-questions-easing);
88
87
  width: 100%;
89
88
  }
90
-
91
- [data-questions-option]:hover {
92
- background: var(--agent-questions-option-bg-hover);
93
- }
94
-
95
- [data-questions-option][data-selected] {
96
- background: var(--agent-questions-option-bg-sel);
97
- border-color: var(--agent-questions-option-border-sel);
98
- }
99
-
100
- [data-questions-option][disabled] {
101
- cursor: default;
102
- opacity: 0.7;
103
- }
104
-
105
- [data-questions-option]:focus-visible {
106
- outline: 2px solid var(--agent-questions-option-border-sel);
107
- outline-offset: 2px;
108
- }
109
-
110
- [data-questions-option-icon] {
111
- flex-shrink: 0;
112
- /* Align the leading icon's center with the label's first-line
113
- center. `1.5em` matches the label's effective line-height
114
- (label is `font-size:` only — line-height inherits as 1.5).
115
- `var(--a-icon-size)` is icon-ui's rendered height. Centering
116
- the icon inside one line-box of the label's height puts its
117
- midpoint on the label's first-line midpoint. We use em on the
118
- icon's own font-size (which icon-ui keeps at the option's
119
- font-size) so the offset scales with whatever the consumer
120
- sets `--agent-questions-label-size` to. */
121
- margin-top: calc((1.5em - var(--a-icon-size, 1em)) / 2);
122
- }
123
-
124
- [data-questions-option-body] {
125
- flex: 1;
126
- min-width: 0;
127
- display: flex;
128
- flex-direction: column;
129
- gap: var(--agent-questions-option-body-gap);
130
- }
131
-
132
- [data-questions-option-label] {
133
- font-size: var(--agent-questions-label-size);
134
- font-weight: var(--agent-questions-label-weight);
135
- color: var(--agent-questions-label-fg);
136
- }
137
-
138
- [data-questions-option-desc] {
139
- font-size: var(--agent-questions-desc-size);
140
- color: var(--agent-questions-desc-fg);
141
- line-height: 1.4;
142
- }
143
-
144
- [data-questions-option-check] {
145
- width: var(--agent-questions-check-size);
146
- height: var(--agent-questions-check-size);
147
- border-radius: 50%;
148
- flex-shrink: 0;
149
- display: flex;
150
- align-items: center;
151
- justify-content: center;
152
- background: transparent;
153
- opacity: 0;
154
- transition: opacity var(--agent-questions-duration) var(--agent-questions-easing);
155
- /* Match the leading-icon offset so a row of [icon · label · check]
156
- reads as one horizontal line aligned to the label's first line. */
157
- margin-top: calc((1.5em - var(--agent-questions-check-size)) / 2);
158
- }
159
-
160
- [data-questions-option][data-selected] [data-questions-option-check] {
161
- background: var(--agent-questions-check-bg);
162
- opacity: 1;
163
- }
164
-
165
- [data-questions-option][data-selected] [data-questions-option-check] icon-ui {
166
- color: var(--agent-questions-check-fg);
167
- }
168
89
  }
@@ -19,6 +19,7 @@ description: |
19
19
  # do NOT auto-import them. Consumer (or demo shell) must explicitly import.
20
20
  composes:
21
21
  - button-ui
22
+ - option-card-ui # gh issue 276 wave 4
22
23
  props:
23
24
  helper:
24
25
  description: Helper text.
@@ -50,7 +50,11 @@ export class UIButton extends UIElement {
50
50
  static template = () => null;
51
51
 
52
52
  connected() {
53
- this.setAttribute('role', 'button');
53
+ // Respect consumer-authored ARIA — a composite may stamp button-ui as
54
+ // role="tab" (swiper dots), role="gridcell"-hosted day buttons
55
+ // (calendar), etc. Same contract as tabindex below: default only when
56
+ // the author said nothing (operator ruling 2026-07-17, gh#276 wave 4).
57
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'button');
54
58
  // Respect a consumer-authored tabindex (e.g. a mouse-only tabindex="-1"
55
59
  // affordance like combobox's clear x, or a roving-tabindex composite) —
56
60
  // only default the focusable state when the author said nothing.
@@ -70,7 +70,9 @@
70
70
  }
71
71
  ],
72
72
  "category": "input",
73
- "composes": [],
73
+ "composes": [
74
+ "button-ui"
75
+ ],
74
76
  "events": {
75
77
  "change": {
76
78
  "description": "User selected a date. detail.value is the ISO string."
@@ -164,10 +164,13 @@ export class UICalendarGrid extends UIElement {
164
164
  const rangeTo = (rs && re && rs <= re) ? re : null;
165
165
 
166
166
  // Header — Prev / Title / Next
167
+ // <button-ui variant="ghost" icon="…"> (gh issue 276 wave 4) — button-ui
168
+ // respects the consumer-authored tabindex="-1", so the nav carets stay
169
+ // out of the roving-tabindex day-grid's tab sequence exactly as before.
167
170
  let h = `<div data-cal-header>
168
- <button data-cal-prev type="button" aria-label="Previous month" tabindex="-1"><icon-ui name="caret-left"></icon-ui></button>
171
+ <button-ui variant="ghost" size="xs" icon="caret-left" data-cal-prev aria-label="Previous month" tabindex="-1"></button-ui>
169
172
  <span data-cal-title>${MONTHS_LONG[month]} ${year}</span>
170
- <button data-cal-next type="button" aria-label="Next month" tabindex="-1"><icon-ui name="caret-right"></icon-ui></button>
173
+ <button-ui variant="ghost" size="xs" icon="caret-right" data-cal-next aria-label="Next month" tabindex="-1"></button-ui>
171
174
  </div>`;
172
175
 
173
176
  // Day-of-week header
@@ -183,7 +186,7 @@ export class UICalendarGrid extends UIElement {
183
186
  // Previous-month trailing days (outside, disabled)
184
187
  for (let i = firstDay - 1; i >= 0; i--) {
185
188
  const day = daysInPrev - i;
186
- h += `<button type="button" data-cal-day data-outside disabled tabindex="-1">${day}</button>`;
189
+ h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${day}"></button-ui>`;
187
190
  }
188
191
 
189
192
  // Current-month days
@@ -206,7 +209,6 @@ export class UICalendarGrid extends UIElement {
206
209
  const isRangeEnd = !!(rangeTo && sameDay(date, rangeTo));
207
210
 
208
211
  const attrs = [
209
- 'type="button"',
210
212
  'data-cal-day',
211
213
  `data-date="${iso}"`,
212
214
  isToday ? 'data-today' : '',
@@ -219,14 +221,17 @@ export class UICalendarGrid extends UIElement {
219
221
  `tabindex="${isFocused ? '0' : '-1'}"`,
220
222
  ].filter(Boolean).join(' ');
221
223
 
222
- h += `<button ${attrs}>${d}</button>`;
224
+ // <button-ui variant="ghost" text="…"> (gh issue 276 wave 4) — the day
225
+ // number moves from element text content to the `text` attribute,
226
+ // which button-ui renders via CSS attr(text) on its ::after.
227
+ h += `<button-ui variant="ghost" ${attrs} text="${d}"></button-ui>`;
223
228
  }
224
229
 
225
230
  // Next-month leading days
226
231
  const totalCells = firstDay + daysInMonth;
227
232
  const remaining = (7 - (totalCells % 7)) % 7;
228
233
  for (let d = 1; d <= remaining; d++) {
229
- h += `<button type="button" data-cal-day data-outside disabled tabindex="-1">${d}</button>`;
234
+ h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${d}"></button-ui>`;
230
235
  }
231
236
 
232
237
  h += '</div>';
@@ -115,14 +115,23 @@
115
115
  color: var(--calendar-grid-title-fg);
116
116
  }
117
117
 
118
- [data-cal-prev],
119
- [data-cal-next] {
118
+ /* Stamped <button-ui variant="ghost" size="xs" icon="caret-…"> (gh issue
119
+ 276 wave 4) — icon-only (no [text], no child text) so button-ui's own
120
+ [data-icon-only] rule already squares it to --button-height; pointing
121
+ --button-height at the nav size (not just literal width/height) avoids
122
+ the min-width/min-height floor trap. Literal background/color below
123
+ out-specify button.css's own (via the `button-ui` type-selector bump —
124
+ same technique as swiper's dots), so the button-ui type-selector prefix
125
+ is load-bearing, not decorative. */
126
+ button-ui[data-cal-prev],
127
+ button-ui[data-cal-next] {
128
+ --button-height: var(--calendar-grid-nav-size);
129
+ --a-icon-size: var(--calendar-grid-nav-icon-size);
130
+
120
131
  display: inline-flex;
121
132
  align-items: center;
122
133
  justify-content: center;
123
- width: var(--calendar-grid-nav-size);
124
134
  height: var(--calendar-grid-nav-size);
125
- padding: 0;
126
135
  border: none;
127
136
  border-radius: var(--calendar-grid-nav-radius);
128
137
  background: var(--calendar-grid-nav-bg);
@@ -131,10 +140,9 @@
131
140
  transition:
132
141
  background var(--calendar-grid-duration) var(--calendar-grid-easing),
133
142
  color var(--calendar-grid-duration) var(--calendar-grid-easing);
134
- --a-icon-size: var(--calendar-grid-nav-icon-size);
135
143
  }
136
- [data-cal-prev]:hover,
137
- [data-cal-next]:hover {
144
+ button-ui[data-cal-prev]:hover,
145
+ button-ui[data-cal-next]:hover {
138
146
  background: var(--calendar-grid-nav-bg-hover);
139
147
  color: var(--calendar-grid-nav-fg-hover);
140
148
  }
@@ -164,7 +172,18 @@
164
172
  gap: var(--calendar-grid-day-grid-gap);
165
173
  }
166
174
 
167
- [data-cal-day] {
175
+ /* Stamped <button-ui variant="ghost" text="…"> (gh issue 276 wave 4).
176
+ button-ui floors min-width/min-height at --button-height — point it at
177
+ the day-cell size (not just the literal width/height below) so the
178
+ floor never clamps a smaller [size] tier. The literal geometry/color
179
+ declarations below out-specify button-ui's own equivalents via the
180
+ `button-ui` type-selector bump (same technique as swiper's dots).
181
+ button-ui renders its label via `::after { content: attr(text) }` — the
182
+ today-indicator dot below therefore uses `::before` (kept free), not
183
+ `::after`, to avoid the two fighting over the same pseudo-element. */
184
+ button-ui[data-cal-day] {
185
+ --button-height: var(--calendar-grid-day-size);
186
+
168
187
  display: flex;
169
188
  align-items: center;
170
189
  justify-content: center;
@@ -188,25 +207,25 @@
188
207
  }
189
208
 
190
209
  /* ── State: hover ── */
191
- [data-cal-day]:not([disabled]):not([data-outside]):hover {
210
+ button-ui[data-cal-day]:not([disabled]):not([data-outside]):hover {
192
211
  background: var(--calendar-grid-day-bg-hover);
193
212
  color: var(--calendar-grid-day-fg-hover);
194
213
  }
195
214
 
196
215
  /* ── State: focus ── */
197
- [data-cal-day]:not([disabled]):not([data-outside]):focus-visible,
198
- [data-cal-day][data-focused] {
216
+ button-ui[data-cal-day]:not([disabled]):not([data-outside]):focus-visible,
217
+ button-ui[data-cal-day][data-focused] {
199
218
  outline: none;
200
219
  box-shadow: var(--calendar-grid-day-focus-ring);
201
220
  }
202
221
 
203
222
  /* ── State: selected ── */
204
- [data-cal-day][data-selected] {
223
+ button-ui[data-cal-day][data-selected] {
205
224
  background: var(--calendar-grid-day-bg-selected);
206
225
  color: var(--calendar-grid-day-fg-selected);
207
226
  font-weight: var(--calendar-grid-selected-weight);
208
227
  }
209
- [data-cal-day][data-selected]:not([disabled]):hover {
228
+ button-ui[data-cal-day][data-selected]:not([disabled]):hover {
210
229
  background: var(--calendar-grid-day-bg-selected);
211
230
  color: var(--calendar-grid-day-fg-selected);
212
231
  }
@@ -217,21 +236,21 @@
217
236
  fill); the in-range middle is visually subordinate. `:not([data-selected])`
218
237
  guard prevents the muted fill from overriding the endpoint cells in
219
238
  consumer code that sets both `value` AND `range-start`/`range-end`. */
220
- [data-cal-day][data-in-range]:not([data-selected]) {
239
+ button-ui[data-cal-day][data-in-range]:not([data-selected]) {
221
240
  background: var(--calendar-grid-day-bg-in-range);
222
241
  color: var(--calendar-grid-day-fg-in-range);
223
242
  }
224
- [data-cal-day][data-in-range]:not([data-selected]):not([disabled]):hover {
243
+ button-ui[data-cal-day][data-in-range]:not([data-selected]):not([disabled]):hover {
225
244
  background: var(--calendar-grid-day-bg-in-range);
226
245
  }
227
246
 
228
247
  /* ── State: today (unselected) ── */
229
- [data-cal-day][data-today]:not([data-selected]) {
248
+ button-ui[data-cal-day][data-today]:not([data-selected]) {
230
249
  position: relative;
231
250
  color: var(--calendar-grid-day-today-color);
232
251
  font-weight: var(--calendar-grid-today-weight);
233
252
  }
234
- [data-cal-day][data-today]:not([data-selected])::after {
253
+ button-ui[data-cal-day][data-today]:not([data-selected])::before {
235
254
  content: "";
236
255
  position: absolute;
237
256
  left: 50%;
@@ -245,13 +264,16 @@
245
264
  }
246
265
 
247
266
  /* ── State: outside month ── */
248
- [data-cal-day][data-outside] {
267
+ button-ui[data-cal-day][data-outside] {
249
268
  color: var(--calendar-grid-day-fg-outside);
250
269
  cursor: default;
251
270
  }
252
271
 
253
- /* ── State: disabled (excluding outside) ── */
254
- [data-cal-day]:disabled:not([data-outside]) {
272
+ /* ── State: disabled (excluding outside) ──
273
+ `[disabled]` — NOT the `:disabled` pseudo-class, which only matches
274
+ native form controls; button-ui is a custom element, so `:disabled`
275
+ silently never matches it. */
276
+ button-ui[data-cal-day][disabled]:not([data-outside]) {
255
277
  color: var(--calendar-grid-day-fg-disabled);
256
278
  cursor: not-allowed;
257
279
  }
@@ -16,6 +16,11 @@ description: >-
16
16
  (two side-by-side grids), and datetime-picker-ui (one grid + time pane).
17
17
  For a complete date-picker affordance (trigger + popover + form
18
18
  participation), use calendar-picker-ui.
19
+ # Per ADR-0027 — primitives that programmatically create other primitives
20
+ # in their render() do NOT auto-import them. Consumer (or demo shell) must
21
+ # explicitly import each composed primitive.
22
+ composes:
23
+ - button-ui # day cells + month nav (gh issue 276 wave 4)
19
24
  props:
20
25
  value:
21
26
  description: Selected date in ISO format (YYYY-MM-DD).
@@ -94,7 +94,9 @@
94
94
  "x-adiaui": {
95
95
  "anti_patterns": [],
96
96
  "category": "input",
97
- "composes": [],
97
+ "composes": [
98
+ "button-ui"
99
+ ],
98
100
  "events": {
99
101
  "change": {
100
102
  "description": "Fired when a date is selected"
@@ -191,10 +191,14 @@ export class UICalendarPicker extends UIFormElement {
191
191
  const maxDate = parseISO(this.max);
192
192
 
193
193
  // Header
194
+ // <button-ui variant="ghost" icon="…"> (gh issue 276 wave 4) — no
195
+ // tabindex here, matching the native version: these nav buttons stay in
196
+ // the default tab order (unlike calendar-grid-ui's, which are pulled out
197
+ // via tabindex="-1" for its inline roving-tabindex day grid).
194
198
  let h = `<div data-cal-header>
195
- <button data-cal-prev type="button" aria-label="Previous month"><icon-ui name="caret-left"></icon-ui></button>
199
+ <button-ui variant="ghost" size="xs" icon="caret-left" data-cal-prev aria-label="Previous month"></button-ui>
196
200
  <span data-cal-title>${MONTHS_LONG[month]} ${year}</span>
197
- <button data-cal-next type="button" aria-label="Next month"><icon-ui name="caret-right"></icon-ui></button>
201
+ <button-ui variant="ghost" size="xs" icon="caret-right" data-cal-next aria-label="Next month"></button-ui>
198
202
  </div>`;
199
203
 
200
204
  // Day-of-week header
@@ -210,7 +214,7 @@ export class UICalendarPicker extends UIFormElement {
210
214
  // Previous month trailing days
211
215
  for (let i = firstDay - 1; i >= 0; i--) {
212
216
  const day = daysInPrev - i;
213
- h += `<button type="button" data-cal-day data-outside disabled tabindex="-1">${day}</button>`;
217
+ h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${day}"></button-ui>`;
214
218
  }
215
219
 
216
220
  // Current month days
@@ -223,7 +227,6 @@ export class UICalendarPicker extends UIFormElement {
223
227
  const isFocused = this.#focusedDay === d;
224
228
 
225
229
  const attrs = [
226
- 'type="button"',
227
230
  'data-cal-day',
228
231
  `data-date="${iso}"`,
229
232
  isToday ? 'data-today' : '',
@@ -233,14 +236,16 @@ export class UICalendarPicker extends UIFormElement {
233
236
  `tabindex="${isFocused ? '0' : '-1'}"`,
234
237
  ].filter(Boolean).join(' ');
235
238
 
236
- h += `<button ${attrs}>${d}</button>`;
239
+ // <button-ui variant="ghost" text="…"> (gh issue 276 wave 4) — the day
240
+ // number moves from element text content to the `text` attribute.
241
+ h += `<button-ui variant="ghost" ${attrs} text="${d}"></button-ui>`;
237
242
  }
238
243
 
239
244
  // Next month leading days
240
245
  const totalCells = firstDay + daysInMonth;
241
246
  const remaining = (7 - (totalCells % 7)) % 7;
242
247
  for (let d = 1; d <= remaining; d++) {
243
- h += `<button type="button" data-cal-day data-outside disabled tabindex="-1">${d}</button>`;
248
+ h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${d}"></button-ui>`;
244
249
  }
245
250
 
246
251
  h += '</div>';