@adia-ai/web-modules 0.8.35 → 0.8.37

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 (89) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/agent-admin/admin-roster/admin-roster.js +1 -1
  4. package/agent-admin/admin-settings/admin-settings.js +1 -1
  5. package/agent-admin/agent-admin/agent-admin.js +1 -1
  6. package/billing/billing-overview/billing-overview.a2ui.json +281 -0
  7. package/billing/billing-overview/billing-overview.class.js +838 -0
  8. package/billing/billing-overview/billing-overview.css +223 -0
  9. package/billing/billing-overview/billing-overview.js +16 -0
  10. package/billing/billing-overview/billing-overview.yaml +441 -0
  11. package/billing/index.js +21 -0
  12. package/billing/invoice-detail/invoice-detail.a2ui.json +276 -0
  13. package/billing/invoice-detail/invoice-detail.class.js +711 -0
  14. package/billing/invoice-detail/invoice-detail.css +305 -0
  15. package/billing/invoice-detail/invoice-detail.js +16 -0
  16. package/billing/invoice-detail/invoice-detail.yaml +411 -0
  17. package/billing/invoice-history/invoice-history.a2ui.json +315 -0
  18. package/billing/invoice-history/invoice-history.class.js +672 -0
  19. package/billing/invoice-history/invoice-history.css +130 -0
  20. package/billing/invoice-history/invoice-history.js +16 -0
  21. package/billing/invoice-history/invoice-history.yaml +398 -0
  22. package/billing/payment-method-form/payment-method-form.a2ui.json +310 -0
  23. package/billing/payment-method-form/payment-method-form.class.js +886 -0
  24. package/billing/payment-method-form/payment-method-form.css +149 -0
  25. package/billing/payment-method-form/payment-method-form.js +16 -0
  26. package/billing/payment-method-form/payment-method-form.yaml +430 -0
  27. package/billing/payment-method-list/payment-method-list.a2ui.json +283 -0
  28. package/billing/payment-method-list/payment-method-list.class.js +705 -0
  29. package/billing/payment-method-list/payment-method-list.css +128 -0
  30. package/billing/payment-method-list/payment-method-list.js +16 -0
  31. package/billing/payment-method-list/payment-method-list.yaml +355 -0
  32. package/billing/plan-picker/plan-picker.a2ui.json +349 -0
  33. package/billing/plan-picker/plan-picker.class.js +635 -0
  34. package/billing/plan-picker/plan-picker.css +227 -0
  35. package/billing/plan-picker/plan-picker.js +16 -0
  36. package/billing/plan-picker/plan-picker.yaml +453 -0
  37. package/chat/README.md +1 -1
  38. package/chat/chat-composer/chat-composer.js +2 -2
  39. package/chat/chat-empty/chat-empty.js +2 -2
  40. package/chat/chat-shell/chat-shell.js +4 -4
  41. package/chat/chat-sidebar/chat-sidebar.js +2 -2
  42. package/chat/chat-surfaces/chat-surfaces.js +2 -2
  43. package/chat/chat-thread/chat-thread.js +2 -2
  44. package/dashboard/dashboard-layout/dashboard-layout.a2ui.json +254 -0
  45. package/dashboard/dashboard-layout/dashboard-layout.css +190 -0
  46. package/dashboard/dashboard-layout/dashboard-layout.js +289 -0
  47. package/dashboard/dashboard-layout/dashboard-layout.yaml +374 -0
  48. package/dashboard/date-range-selector/date-range-selector.a2ui.json +236 -0
  49. package/dashboard/date-range-selector/date-range-selector.css +84 -0
  50. package/dashboard/date-range-selector/date-range-selector.js +686 -0
  51. package/dashboard/date-range-selector/date-range-selector.yaml +358 -0
  52. package/dashboard/index.js +14 -0
  53. package/dist/everything.min.js +1 -1
  54. package/editor/editor-canvas/editor-canvas.js +2 -2
  55. package/editor/editor-shell/editor-shell.js +2 -2
  56. package/editor/editor-sidebar/editor-sidebar.js +2 -2
  57. package/editor/editor-toolbar/editor-toolbar.js +2 -2
  58. package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
  59. package/feedback/confirm-dialog/confirm-dialog.css +157 -0
  60. package/feedback/confirm-dialog/confirm-dialog.js +383 -0
  61. package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
  62. package/feedback/index.js +10 -0
  63. package/form/form-popover/form-popover.js +1 -1
  64. package/generative/index.d.ts +1 -1
  65. package/generative/index.js +2 -2
  66. package/onboarding/index.js +1 -0
  67. package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
  68. package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
  69. package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
  70. package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
  71. package/package.json +99 -6
  72. package/runtime/a2ui-root/a2ui-root.js +3 -3
  73. package/runtime/gen-root/gen-root.js +5 -5
  74. package/settings/index.js +12 -0
  75. package/settings/integrations-page/integrations-page.a2ui.json +274 -0
  76. package/settings/integrations-page/integrations-page.css +177 -0
  77. package/settings/integrations-page/integrations-page.d.ts +77 -0
  78. package/settings/integrations-page/integrations-page.js +413 -0
  79. package/settings/integrations-page/integrations-page.yaml +317 -0
  80. package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
  81. package/settings/notification-preferences/notification-preferences.css +204 -0
  82. package/settings/notification-preferences/notification-preferences.js +622 -0
  83. package/settings/notification-preferences/notification-preferences.yaml +358 -0
  84. package/shell/admin-command/admin-command.js +2 -2
  85. package/shell/admin-shell/admin-shell.js +2 -2
  86. package/shell/admin-sidebar/admin-sidebar.js +2 -2
  87. package/shell/embed-shell/embed-shell.js +3 -3
  88. package/simple/simple-shell/simple-shell.js +1 -1
  89. package/theme/theme-panel/theme-panel.js +2 -2
@@ -0,0 +1,705 @@
1
+ /**
2
+ * <payment-method-list-ui> — Form-participating list of saved payment methods.
3
+ *
4
+ * Renders the user's saved cards, bank accounts, and wallets as a
5
+ * vertical list with one row designated as the default. Each row is a
6
+ * <card-ui size="sm"> with a brand mark, last-4 / identifier, expiry,
7
+ * a "Default" tag (on the default row), and per-row actions (set-
8
+ * default / edit / remove). A trailing "Add payment method" CTA emits
9
+ * `add` so the consumer can route the consumer-driven add flow.
10
+ *
11
+ * SPEC: .claude/docs/specs/implementation-ready/SPEC-010-payment-method-list.md
12
+ * Sibling: <plan-picker-ui> (same id-keyed-diff pattern)
13
+ *
14
+ * Architecture (light-DOM, ADR-0033):
15
+ * This composite stamps a three-region skeleton once on first
16
+ * connect:
17
+ * [data-rows] · [data-empty] · [data-add]
18
+ * Then mutates per-row DOM by id-keyed in-place diff on every
19
+ * `methods` / `value` write. The diff preserves identity across
20
+ * re-renders (id-keyed map) so focus survives unrelated row updates.
21
+ *
22
+ * Form participation:
23
+ * Extends UIFormElement. The current default id serializes under
24
+ * `name` via ElementInternals. Default is a strict 1-of-N — exactly
25
+ * one row carries the default state when methods is non-empty.
26
+ *
27
+ * Mutations are events, not props:
28
+ * - `change` — default method changed
29
+ * - `add` — user clicked the add CTA
30
+ * - `remove` — user clicked a row's remove button
31
+ * - `select` — user clicked a row's edit button (no default change)
32
+ *
33
+ * The primitive does NOT write to the data source; the consumer
34
+ * applies the side effect (network round-trip) and either passes the
35
+ * new `methods` array back or calls .add() / .remove() / .setDefault().
36
+ *
37
+ * Per ADR-0027 — the composed primitives (card-ui / button-ui /
38
+ * tag-ui / icon-ui / empty-state-ui) are NOT auto-imported. The
39
+ * consumer (or the demo .html shell) must import them explicitly.
40
+ */
41
+
42
+ import { UIFormElement } from '@adia-ai/web-components/core/form';
43
+ import { untracked } from '@adia-ai/web-components/core/signals';
44
+
45
+ const KNOWN_BRANDS = new Set([
46
+ 'visa', 'mastercard', 'amex', 'discover', 'jcb', 'unionpay', 'diners',
47
+ 'paypal', 'apple-pay', 'google-pay', 'ach', 'sepa',
48
+ ]);
49
+
50
+ const KNOWN_TYPES = new Set(['card', 'bank', 'wallet']);
51
+
52
+ // Phosphor icons that have brand-adjacent renderings.
53
+ const BRAND_ICON = {
54
+ paypal: 'paypal-logo',
55
+ 'apple-pay': 'apple-logo',
56
+ ach: 'bank',
57
+ sepa: 'bank',
58
+ };
59
+
60
+ // Fallback icon by record type. Cards (including unknown brands) get
61
+ // the generic credit-card mark; bank gets the bank icon; wallets get
62
+ // the credit-card mark by default unless brand maps elsewhere.
63
+ const TYPE_FALLBACK_ICON = {
64
+ card: 'credit-card',
65
+ bank: 'bank',
66
+ wallet: 'credit-card',
67
+ };
68
+
69
+ /** Brand label for the row's accessible name. */
70
+ const BRAND_LABEL = {
71
+ visa: 'Visa',
72
+ mastercard: 'Mastercard',
73
+ amex: 'American Express',
74
+ discover: 'Discover',
75
+ jcb: 'JCB',
76
+ unionpay: 'UnionPay',
77
+ diners: 'Diners Club',
78
+ paypal: 'PayPal',
79
+ 'apple-pay': 'Apple Pay',
80
+ 'google-pay': 'Google Pay',
81
+ ach: 'Bank account',
82
+ sepa: 'SEPA',
83
+ };
84
+
85
+ function brandIconFor(method) {
86
+ const brand = (method?.brand || '').toLowerCase();
87
+ if (BRAND_ICON[brand]) return BRAND_ICON[brand];
88
+ const type = (method?.type || 'card').toLowerCase();
89
+ return TYPE_FALLBACK_ICON[type] || 'credit-card';
90
+ }
91
+
92
+ function brandLabelFor(method) {
93
+ const brand = (method?.brand || '').toLowerCase();
94
+ if (BRAND_LABEL[brand]) return BRAND_LABEL[brand];
95
+ const type = (method?.type || 'card').toLowerCase();
96
+ if (type === 'bank') return 'Bank account';
97
+ if (type === 'wallet') return 'Wallet';
98
+ return 'Card';
99
+ }
100
+
101
+ /**
102
+ * Render the user-visible primary line for a row.
103
+ * Cards: "Visa •• 4242"
104
+ * Wallets w/o last4: "PayPal"
105
+ * Banks w/ holder: "Chase ••6789"
106
+ */
107
+ function primaryLineFor(method) {
108
+ const last4 = (method?.last4 || '').trim();
109
+ const holder = (method?.holder || '').trim();
110
+ const brand = brandLabelFor(method);
111
+ const type = (method?.type || 'card').toLowerCase();
112
+
113
+ if (type === 'bank' && holder) return holder;
114
+ if (last4) return `${brand} ···· ${last4}`;
115
+ if (holder) return `${brand} — ${holder}`;
116
+ return brand;
117
+ }
118
+
119
+ /** "Expires 05/27" string for the secondary line; empty when no expiry. */
120
+ function expiryLineFor(method) {
121
+ const raw = (method?.expiry || '').trim();
122
+ if (!raw) return '';
123
+ // Accept "MM/YY", "MM/YYYY", or "YYYY-MM-DD".
124
+ let mm = '';
125
+ let yy = '';
126
+ const mmYy = raw.match(/^(\d{1,2})\/(\d{2}|\d{4})$/);
127
+ if (mmYy) {
128
+ mm = mmYy[1].padStart(2, '0');
129
+ yy = mmYy[2].length === 4 ? mmYy[2].slice(-2) : mmYy[2];
130
+ } else {
131
+ const iso = raw.match(/^(\d{4})-(\d{2})-\d{2}$/);
132
+ if (iso) {
133
+ yy = iso[1].slice(-2);
134
+ mm = iso[2];
135
+ }
136
+ }
137
+ if (!mm || !yy) return '';
138
+ return `Expires ${mm}/${yy}`;
139
+ }
140
+
141
+ export class PaymentMethodList extends UIFormElement {
142
+ static get properties() {
143
+ return {
144
+ ...UIFormElement.properties,
145
+ // `methods` is the source of truth — array via prop OR JSON child.
146
+ methods: { type: Array, default: [] },
147
+ // Current default id mirrors UIFormElement.value.
148
+ value: { type: String, default: '', reflect: true },
149
+ 'add-label': { type: String, default: 'Add payment method', attribute: 'add-label' },
150
+ 'add-icon': { type: String, default: 'plus', attribute: 'add-icon' },
151
+ 'no-add': { type: Boolean, default: false, reflect: true, attribute: 'no-add' },
152
+ 'no-remove': { type: Boolean, default: false, reflect: true, attribute: 'no-remove' },
153
+ disabled: { type: Boolean, default: false, reflect: true },
154
+ };
155
+ }
156
+
157
+ // No template — we stamp a fixed three-region skeleton once and
158
+ // mutate per-region children on render. Returning html`` would
159
+ // trigger stamp() → replaceChildren() and wipe authored slot kids.
160
+ static template = () => null;
161
+
162
+ // ── Field refs ────────────────────────────────────────────────────
163
+ #stamped = false;
164
+ #cleanups = [];
165
+ #rowsHost = null; // <div data-rows role=presentation>
166
+ #emptyHost = null; // <empty-state-ui data-empty>
167
+ #addHost = null; // <div data-add>
168
+ #addButton = null; // <button-ui> inside #addHost
169
+ #rowsById = new Map(); // id → <card-ui>
170
+ #internalUpdate = false;
171
+
172
+ // ── Public imperative API (SPEC §4 Methods) ───────────────────────
173
+
174
+ /** Mark a method id as default. Returns true on hit. */
175
+ setDefault(id) {
176
+ if (!id) return false;
177
+ return untracked(() => {
178
+ const found = (this.methods || []).find((m) => m.id === id);
179
+ if (!found) return false;
180
+ if (this.value === id) return true;
181
+ const previous = this.value || '';
182
+ this.#internalUpdate = true;
183
+ try { this.value = id; } finally { this.#internalUpdate = false; }
184
+ this.syncValue();
185
+ this.#emitChange(id, previous, found);
186
+ return true;
187
+ });
188
+ }
189
+
190
+ /** Remove a method by id from internal state and re-render. */
191
+ remove(id) {
192
+ if (!id) return false;
193
+ return untracked(() => {
194
+ const list = Array.isArray(this.methods) ? this.methods.slice() : [];
195
+ const idx = list.findIndex((m) => m.id === id);
196
+ if (idx < 0) return false;
197
+ list.splice(idx, 1);
198
+ this.methods = list;
199
+ // If we removed the current default, promote the new first row
200
+ // (preserves the 1-of-N invariant when the list is non-empty).
201
+ if (this.value === id) {
202
+ const next = list[0]?.id || '';
203
+ this.#internalUpdate = true;
204
+ try { this.value = next; } finally { this.#internalUpdate = false; }
205
+ this.syncValue();
206
+ }
207
+ return true;
208
+ });
209
+ }
210
+
211
+ /** Insert a new method record (typically called after add flow returns). */
212
+ add(method) {
213
+ if (!method || !method.id) return;
214
+ untracked(() => {
215
+ const list = Array.isArray(this.methods) ? this.methods.slice() : [];
216
+ // Replace existing record by id if already present.
217
+ const idx = list.findIndex((m) => m.id === method.id);
218
+ if (idx >= 0) list[idx] = method;
219
+ else list.push(method);
220
+ this.methods = list;
221
+ if (method.default) {
222
+ const previous = this.value || '';
223
+ this.#internalUpdate = true;
224
+ try { this.value = method.id; } finally { this.#internalUpdate = false; }
225
+ this.syncValue();
226
+ if (previous !== method.id) this.#emitChange(method.id, previous, method);
227
+ }
228
+ });
229
+ }
230
+
231
+ // ── Lifecycle ─────────────────────────────────────────────────────
232
+
233
+ connected() {
234
+ super.connected();
235
+ this.setAttribute('role', 'radiogroup');
236
+ if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
237
+ this.setAttribute('aria-label', 'Payment methods');
238
+ }
239
+
240
+ if (!this.#stamped) {
241
+ // Absorb a <script type="application/json" data-methods> child
242
+ // before stamping wipes the host's children.
243
+ this.#absorbInlineMethodsScript();
244
+ this.#stamp();
245
+ this.#wireListeners();
246
+ this.#stamped = true;
247
+ }
248
+
249
+ // Trigger first render after composed primitives upgrade.
250
+ untracked(() => this.render());
251
+ }
252
+
253
+ disconnected() {
254
+ super.disconnected?.();
255
+ for (const fn of this.#cleanups) {
256
+ try { fn(); } catch { /* ignore stale */ }
257
+ }
258
+ this.#cleanups.length = 0;
259
+ }
260
+
261
+ // ── Form participation ────────────────────────────────────────────
262
+
263
+ /** Selected default id is the form value. */
264
+ syncValue(_val) {
265
+ this.internals.setFormValue(this.value || '');
266
+ }
267
+
268
+ // ── Render — narrow; mutates per-region nodes only ────────────────
269
+
270
+ render() {
271
+ if (!this.#stamped) return;
272
+
273
+ const methods = Array.isArray(this.methods) ? this.methods : [];
274
+
275
+ // Reconcile default — value wins; otherwise first record with
276
+ // default:true; otherwise the first row.
277
+ const defaultId = this.#resolveDefaultId(methods);
278
+ if (defaultId && this.value !== defaultId) {
279
+ this.#internalUpdate = true;
280
+ try { this.value = defaultId; } finally { this.#internalUpdate = false; }
281
+ this.syncValue();
282
+ }
283
+
284
+ // Empty-state attribute reflection (CSS-gated reveal).
285
+ if (methods.length === 0) {
286
+ this.setAttribute('empty', '');
287
+ } else {
288
+ this.removeAttribute('empty');
289
+ }
290
+
291
+ // Add-CTA visibility.
292
+ if (this.#addHost) {
293
+ this.#addHost.hidden = !!this['no-add'];
294
+ }
295
+ if (this.#addButton) {
296
+ this.#addButton.setAttribute('text', this['add-label'] || 'Add payment method');
297
+ const icon = this['add-icon'] || 'plus';
298
+ if (icon) this.#addButton.setAttribute('icon', icon);
299
+ else this.#addButton.removeAttribute('icon');
300
+ if (this.disabled) this.#addButton.setAttribute('disabled', '');
301
+ else this.#addButton.removeAttribute('disabled');
302
+ }
303
+
304
+ this.#renderRows(methods);
305
+ }
306
+
307
+ // ── Internal — DOM stamping ───────────────────────────────────────
308
+
309
+ #resolveDefaultId(methods) {
310
+ if (!methods.length) return '';
311
+ // 1) explicit `value` wins if it matches a row
312
+ if (this.value && methods.some((m) => m.id === this.value)) return this.value;
313
+ // 2) first record carrying default:true
314
+ const flagged = methods.find((m) => m.default);
315
+ if (flagged) return flagged.id;
316
+ // 3) fall back to first row
317
+ return methods[0].id || '';
318
+ }
319
+
320
+ #absorbInlineMethodsScript() {
321
+ const script = this.querySelector(':scope > script[type="application/json"][data-methods]');
322
+ if (!script) return;
323
+ try {
324
+ const parsed = JSON.parse(script.textContent || '[]');
325
+ if (Array.isArray(parsed)) this.methods = parsed;
326
+ } catch (err) {
327
+ // eslint-disable-next-line no-console
328
+ console.warn('[payment-method-list-ui] failed to parse <script data-methods> JSON:', err?.message);
329
+ } finally {
330
+ script.remove();
331
+ }
332
+ }
333
+
334
+ #stamp() {
335
+ // Rows host — the radiogroup container for the method cards.
336
+ // role="presentation" because the outer host carries radiogroup.
337
+ const rows = document.createElement('div');
338
+ rows.setAttribute('data-rows', '');
339
+ rows.setAttribute('role', 'presentation');
340
+ this.appendChild(rows);
341
+ this.#rowsHost = rows;
342
+
343
+ // Default empty-state — overridable via slot="empty".
344
+ if (!this.querySelector(':scope > [slot="empty"]')) {
345
+ const empty = document.createElement('empty-state-ui');
346
+ empty.setAttribute('data-empty', '');
347
+ empty.setAttribute('icon', 'credit-card');
348
+ empty.setAttribute('heading', 'No payment methods');
349
+ empty.setAttribute('description', 'Add a method to get started.');
350
+ this.appendChild(empty);
351
+ this.#emptyHost = empty;
352
+ }
353
+
354
+ // Add CTA host — overridable via slot="add".
355
+ const addHost = document.createElement('div');
356
+ addHost.setAttribute('data-add', '');
357
+ this.appendChild(addHost);
358
+ this.#addHost = addHost;
359
+
360
+ if (!this.querySelector(':scope > [slot="add"]')) {
361
+ const btn = document.createElement('button-ui');
362
+ btn.setAttribute('data-add-cta', '');
363
+ btn.setAttribute('variant', 'outline');
364
+ btn.setAttribute('icon', this['add-icon'] || 'plus');
365
+ btn.setAttribute('text', this['add-label'] || 'Add payment method');
366
+ addHost.appendChild(btn);
367
+ this.#addButton = btn;
368
+ }
369
+ }
370
+
371
+ #renderRows(methods) {
372
+ if (!this.#rowsHost) return;
373
+
374
+ const next = new Map();
375
+
376
+ for (const method of methods) {
377
+ if (!method?.id) continue;
378
+ let row = this.#rowsById.get(method.id);
379
+ if (!row) {
380
+ row = this.#buildRow(method);
381
+ this.#rowsHost.appendChild(row);
382
+ }
383
+ this.#decorateRow(row, method);
384
+ next.set(method.id, row);
385
+ }
386
+
387
+ // Remove dropped rows.
388
+ for (const [id, row] of this.#rowsById) {
389
+ if (!next.has(id)) {
390
+ try { row.remove(); } catch { /* ignore */ }
391
+ }
392
+ }
393
+
394
+ // Reorder DOM to match the input array (in-place move).
395
+ for (const method of methods) {
396
+ const row = next.get(method.id);
397
+ if (row) this.#rowsHost.appendChild(row);
398
+ }
399
+
400
+ this.#rowsById = next;
401
+ }
402
+
403
+ #buildRow(method) {
404
+ // Canonical card-ui composition grammar (card.css §header):
405
+ // <card-ui data-row data-id=... role="radio">
406
+ // <header>
407
+ // <span slot="icon"><icon-ui aria-hidden="true"></icon-ui></span>
408
+ // <span slot="heading">
409
+ // <span data-primary>Visa ···· 4242</span>
410
+ // <tag-ui data-default-tag text="Default" variant="success" size="sm" hidden></tag-ui>
411
+ // </span>
412
+ // <p slot="description" data-secondary>Expires 05/27</p>
413
+ // <span slot="action">
414
+ // <button-ui data-action="set-default" variant="ghost" size="sm" text="Set default" hidden></button-ui>
415
+ // <button-ui data-action="edit" variant="ghost" size="sm" icon="pencil"></button-ui>
416
+ // <button-ui data-action="remove" variant="ghost" size="sm" icon="trash"></button-ui>
417
+ // </span>
418
+ // </header>
419
+ // </card-ui>
420
+ //
421
+ // card-ui's @scope rules own the 3-column grid (icon | heading | action),
422
+ // heading-slot inline-flex (badges), description placement, and action-slot
423
+ // flex layout. We do NOT re-implement any of that here.
424
+ const row = document.createElement('card-ui');
425
+ row.setAttribute('data-row', '');
426
+ row.setAttribute('role', 'radio');
427
+ row.setAttribute('tabindex', '0');
428
+
429
+ const header = document.createElement('header');
430
+
431
+ // Icon slot — first column, max-content; card-ui aligns vertically.
432
+ const iconSlot = document.createElement('span');
433
+ iconSlot.setAttribute('slot', 'icon');
434
+ iconSlot.setAttribute('data-pml-icon', ''); // internal hook — this composite's CSS centers it (gh#1079) without claiming card-ui's slot vocab in its own yaml (FB-62)
435
+ const icon = document.createElement('icon-ui');
436
+ icon.setAttribute('aria-hidden', 'true');
437
+ iconSlot.appendChild(icon);
438
+
439
+ // Heading slot — flex container (card.css L229-233); inline tag-ui fits naturally.
440
+ const headingSlot = document.createElement('span');
441
+ headingSlot.setAttribute('slot', 'heading');
442
+ const primary = document.createElement('span');
443
+ primary.setAttribute('data-primary', '');
444
+ const defaultTag = document.createElement('tag-ui');
445
+ defaultTag.setAttribute('data-default-tag', '');
446
+ defaultTag.setAttribute('text', 'Default');
447
+ defaultTag.setAttribute('variant', 'success');
448
+ defaultTag.setAttribute('size', 'sm');
449
+ defaultTag.setAttribute('hidden', '');
450
+ headingSlot.appendChild(primary);
451
+ headingSlot.appendChild(defaultTag);
452
+
453
+ // Description slot — row 2, spans heading + action columns (card.css L237-244).
454
+ const description = document.createElement('p');
455
+ description.setAttribute('slot', 'description');
456
+ description.setAttribute('data-secondary', '');
457
+
458
+ // Action slot — last column, flex row (card.css L246-257).
459
+ const actionSlot = document.createElement('span');
460
+ actionSlot.setAttribute('slot', 'action');
461
+ actionSlot.setAttribute('data-pml-action', ''); // internal hook, same as data-pml-icon
462
+
463
+ const setDefaultBtn = document.createElement('button-ui');
464
+ setDefaultBtn.setAttribute('data-action', 'set-default');
465
+ setDefaultBtn.setAttribute('variant', 'ghost');
466
+ setDefaultBtn.setAttribute('size', 'sm');
467
+ setDefaultBtn.setAttribute('text', 'Set default');
468
+
469
+ const editBtn = document.createElement('button-ui');
470
+ editBtn.setAttribute('data-action', 'edit');
471
+ editBtn.setAttribute('variant', 'ghost');
472
+ editBtn.setAttribute('size', 'sm');
473
+ editBtn.setAttribute('icon', 'pencil');
474
+ editBtn.setAttribute('aria-label', 'Edit payment method');
475
+
476
+ const removeBtn = document.createElement('button-ui');
477
+ removeBtn.setAttribute('data-action', 'remove');
478
+ removeBtn.setAttribute('variant', 'ghost');
479
+ removeBtn.setAttribute('size', 'sm');
480
+ removeBtn.setAttribute('icon', 'trash');
481
+ removeBtn.setAttribute('aria-label', 'Remove payment method');
482
+
483
+ actionSlot.appendChild(setDefaultBtn);
484
+ actionSlot.appendChild(editBtn);
485
+ actionSlot.appendChild(removeBtn);
486
+
487
+ header.appendChild(iconSlot);
488
+ header.appendChild(headingSlot);
489
+ header.appendChild(description);
490
+ header.appendChild(actionSlot);
491
+ row.appendChild(header);
492
+
493
+ return row;
494
+ }
495
+
496
+ #decorateRow(row, method) {
497
+ row.setAttribute('data-id', method.id);
498
+
499
+ const brand = (method?.brand || '').toLowerCase();
500
+ if (brand) row.setAttribute('data-brand-key', brand);
501
+ else row.removeAttribute('data-brand-key');
502
+
503
+ const type = (method?.type || 'card').toLowerCase();
504
+ if (KNOWN_TYPES.has(type)) row.setAttribute('data-type', type);
505
+
506
+ const isDefault = method.id === this.value;
507
+ if (isDefault) {
508
+ row.setAttribute('data-default', '');
509
+ row.setAttribute('aria-checked', 'true');
510
+ row.setAttribute('tabindex', '0');
511
+ } else {
512
+ row.removeAttribute('data-default');
513
+ row.setAttribute('aria-checked', 'false');
514
+ row.setAttribute('tabindex', '-1');
515
+ }
516
+
517
+ if (this.disabled) {
518
+ row.setAttribute('aria-disabled', 'true');
519
+ } else {
520
+ row.removeAttribute('aria-disabled');
521
+ }
522
+
523
+ // Brand-icon update (icon lives in slot=icon inside header).
524
+ const iconEl = row.querySelector(':scope > header > [slot="icon"] > icon-ui');
525
+ if (iconEl) {
526
+ const iconName = brandIconFor(method);
527
+ if (iconEl.getAttribute('name') !== iconName) {
528
+ iconEl.setAttribute('name', iconName);
529
+ }
530
+ }
531
+
532
+ // Primary text (inside slot=heading).
533
+ const primary = row.querySelector(':scope > header > [slot="heading"] > [data-primary]');
534
+ if (primary) primary.textContent = primaryLineFor(method);
535
+
536
+ // Secondary text (slot=description).
537
+ const secondary = row.querySelector(':scope > header > [data-secondary][slot="description"]');
538
+ if (secondary) {
539
+ const expiry = expiryLineFor(method);
540
+ secondary.textContent = expiry;
541
+ if (expiry) secondary.removeAttribute('hidden');
542
+ else secondary.setAttribute('hidden', '');
543
+ }
544
+
545
+ // Default tag — toggle visibility (inline inside slot=heading).
546
+ const defaultTag = row.querySelector(':scope > header > [slot="heading"] > [data-default-tag]');
547
+ if (defaultTag) {
548
+ if (isDefault) defaultTag.removeAttribute('hidden');
549
+ else defaultTag.setAttribute('hidden', '');
550
+ }
551
+
552
+ // Accessible row name (composed brand + identifier + state).
553
+ const expiryText = expiryLineFor(method);
554
+ const parts = [primaryLineFor(method)];
555
+ if (expiryText) parts.push(expiryText);
556
+ if (isDefault) parts.push('default');
557
+ row.setAttribute('aria-label', parts.join(', '));
558
+
559
+ // Per-row action visibility (action slot inside header).
560
+ const setDefaultBtn = row.querySelector(':scope > header > [slot="action"] > [data-action="set-default"]');
561
+ if (setDefaultBtn) {
562
+ // Hide "Set default" on the current default row.
563
+ if (isDefault) setDefaultBtn.setAttribute('hidden', '');
564
+ else setDefaultBtn.removeAttribute('hidden');
565
+ if (this.disabled) setDefaultBtn.setAttribute('disabled', '');
566
+ else setDefaultBtn.removeAttribute('disabled');
567
+ }
568
+
569
+ const editBtn = row.querySelector(':scope > header > [slot="action"] > [data-action="edit"]');
570
+ if (editBtn) {
571
+ if (this.disabled) editBtn.setAttribute('disabled', '');
572
+ else editBtn.removeAttribute('disabled');
573
+ }
574
+
575
+ const removeBtn = row.querySelector(':scope > header > [slot="action"] > [data-action="remove"]');
576
+ if (removeBtn) {
577
+ if (this['no-remove']) removeBtn.setAttribute('hidden', '');
578
+ else removeBtn.removeAttribute('hidden');
579
+ if (this.disabled) removeBtn.setAttribute('disabled', '');
580
+ else removeBtn.removeAttribute('disabled');
581
+ }
582
+ }
583
+
584
+ #wireListeners() {
585
+ // Delegate per-row action button presses (button-ui + happy-dom click).
586
+ const onAction = (e) => {
587
+ const btn = e.target?.closest?.('button-ui[data-action]');
588
+ if (btn && this.#rowsHost.contains(btn)) {
589
+ const row = btn.closest('[data-row]');
590
+ if (!row) return;
591
+ const id = row.getAttribute('data-id') || '';
592
+ if (!id) return;
593
+ if (this.disabled || btn.hasAttribute('disabled')) {
594
+ e.stopPropagation();
595
+ return;
596
+ }
597
+ const method = (this.methods || []).find((m) => m.id === id);
598
+ if (!method) return;
599
+ const action = btn.getAttribute('data-action');
600
+ e.stopPropagation();
601
+
602
+ if (action === 'set-default') {
603
+ this.setDefault(id);
604
+ } else if (action === 'remove') {
605
+ this.dispatchEvent(new CustomEvent('remove', {
606
+ bubbles: true,
607
+ detail: { id, method },
608
+ }));
609
+ } else if (action === 'edit') {
610
+ this.dispatchEvent(new CustomEvent('select', {
611
+ bubbles: true,
612
+ detail: { id, method },
613
+ }));
614
+ }
615
+ return;
616
+ }
617
+
618
+ // Add CTA.
619
+ const addBtn = e.target?.closest?.('button-ui[data-add-cta]');
620
+ if (addBtn && this.#addHost?.contains(addBtn)) {
621
+ if (this.disabled || addBtn.hasAttribute('disabled')) {
622
+ e.stopPropagation();
623
+ return;
624
+ }
625
+ e.stopPropagation();
626
+ this.dispatchEvent(new CustomEvent('add', {
627
+ bubbles: true,
628
+ detail: {},
629
+ }));
630
+ }
631
+ };
632
+ // Listen for both `press` (button-ui's custom event) and `click`
633
+ // (covers happy-dom paths in tests).
634
+ this.addEventListener('press', onAction);
635
+ this.addEventListener('click', onAction);
636
+ this.#cleanups.push(() => this.removeEventListener('press', onAction));
637
+ this.#cleanups.push(() => this.removeEventListener('click', onAction));
638
+
639
+ // Row activation — clicking the card body (outside an action
640
+ // button) sets that row as default.
641
+ const onRowActivate = (e) => {
642
+ // Bail if the press came from an action button (handled above).
643
+ if (e.target?.closest?.('button-ui[data-action]')) return;
644
+ if (e.target?.closest?.('button-ui[data-add-cta]')) return;
645
+ const row = e.target?.closest?.('[data-row]');
646
+ if (!row || !this.#rowsHost.contains(row)) return;
647
+ const id = row.getAttribute('data-id') || '';
648
+ if (!id) return;
649
+ if (this.disabled) return;
650
+ this.setDefault(id);
651
+ };
652
+ this.#rowsHost.addEventListener('click', onRowActivate);
653
+ this.#cleanups.push(() => this.#rowsHost.removeEventListener('click', onRowActivate));
654
+
655
+ // Radiogroup keyboard model — Arrow Down / Up / Home / End move
656
+ // focus within the group; Space / Enter set default on the focused row.
657
+ const onKey = (e) => {
658
+ const focused = document.activeElement;
659
+ const row = focused?.closest?.('[data-row]');
660
+ if (!row || !this.#rowsHost.contains(row)) return;
661
+ const rows = Array.from(this.#rowsHost.querySelectorAll(':scope > [data-row]'));
662
+ const idx = rows.indexOf(row);
663
+ if (idx < 0) return;
664
+
665
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
666
+ e.preventDefault();
667
+ const target = rows[(idx + 1) % rows.length];
668
+ target?.focus?.();
669
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
670
+ e.preventDefault();
671
+ const target = rows[(idx - 1 + rows.length) % rows.length];
672
+ target?.focus?.();
673
+ } else if (e.key === 'Home') {
674
+ e.preventDefault();
675
+ rows[0]?.focus?.();
676
+ } else if (e.key === 'End') {
677
+ e.preventDefault();
678
+ rows[rows.length - 1]?.focus?.();
679
+ } else if (e.key === ' ' || e.key === 'Enter') {
680
+ e.preventDefault();
681
+ const id = row.getAttribute('data-id') || '';
682
+ if (id && !this.disabled) this.setDefault(id);
683
+ }
684
+ };
685
+ this.#rowsHost.addEventListener('keydown', onKey);
686
+ this.#cleanups.push(() => this.#rowsHost.removeEventListener('keydown', onKey));
687
+ }
688
+
689
+ #emitChange(value, previous, method) {
690
+ this.dispatchEvent(new CustomEvent('change', {
691
+ bubbles: true,
692
+ detail: { value, previous, method },
693
+ }));
694
+ }
695
+ }
696
+
697
+ // Re-export helpers for tests.
698
+ export {
699
+ KNOWN_BRANDS,
700
+ KNOWN_TYPES,
701
+ brandIconFor,
702
+ brandLabelFor,
703
+ primaryLineFor,
704
+ expiryLineFor,
705
+ };