@adia-ai/web-modules 0.8.36 → 0.8.38

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 (90) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +4 -4
  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 +2 -2
  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 +6 -6
  41. package/chat/chat-sidebar/chat-sidebar.js +2 -2
  42. package/chat/chat-surfaces/chat-surfaces.js +3 -3
  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/chat/chat-shell.min.js +1 -1
  54. package/dist/everything.min.js +121 -102
  55. package/editor/editor-canvas/editor-canvas.js +2 -2
  56. package/editor/editor-shell/editor-shell.js +2 -2
  57. package/editor/editor-sidebar/editor-sidebar.js +2 -2
  58. package/editor/editor-toolbar/editor-toolbar.js +2 -2
  59. package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
  60. package/feedback/confirm-dialog/confirm-dialog.css +157 -0
  61. package/feedback/confirm-dialog/confirm-dialog.js +383 -0
  62. package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
  63. package/feedback/index.js +10 -0
  64. package/form/form-popover/form-popover.js +1 -1
  65. package/generative/index.d.ts +1 -1
  66. package/generative/index.js +2 -2
  67. package/onboarding/index.js +1 -0
  68. package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
  69. package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
  70. package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
  71. package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
  72. package/package.json +99 -6
  73. package/runtime/a2ui-root/a2ui-root.js +3 -3
  74. package/runtime/gen-root/gen-root.js +5 -5
  75. package/settings/index.js +12 -0
  76. package/settings/integrations-page/integrations-page.a2ui.json +274 -0
  77. package/settings/integrations-page/integrations-page.css +177 -0
  78. package/settings/integrations-page/integrations-page.d.ts +77 -0
  79. package/settings/integrations-page/integrations-page.js +413 -0
  80. package/settings/integrations-page/integrations-page.yaml +317 -0
  81. package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
  82. package/settings/notification-preferences/notification-preferences.css +204 -0
  83. package/settings/notification-preferences/notification-preferences.js +622 -0
  84. package/settings/notification-preferences/notification-preferences.yaml +358 -0
  85. package/shell/admin-command/admin-command.js +2 -2
  86. package/shell/admin-shell/admin-shell.js +2 -2
  87. package/shell/admin-sidebar/admin-sidebar.js +2 -2
  88. package/shell/embed-shell/embed-shell.js +3 -3
  89. package/simple/simple-shell/simple-shell.js +1 -1
  90. package/theme/theme-panel/theme-panel.js +2 -2
@@ -0,0 +1,711 @@
1
+ /**
2
+ * <invoice-detail-ui> — Page-level invoice composite (display only).
3
+ *
4
+ * Renders a single invoice — header (number + status badge + dates),
5
+ * customer + remit-to blocks, line-items table, right-aligned totals
6
+ * stack, and a slot-driven action toolbar. Composite, not primitive:
7
+ * the constituent primitives (card-ui, table-ui, stat-ui, tag-ui,
8
+ * button-ui) already exist; this module owns the canonical layout,
9
+ * status-badge variant mapping, totals math, and data-stream binding
10
+ * so billing-enabled products do not re-derive them.
11
+ *
12
+ * SPEC: .claude/docs/specs/implementation-ready/SPEC-007-invoice-detail-page.md
13
+ * WAI-APG: https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/
14
+ *
15
+ * Architecture (light-DOM, ADR-0033):
16
+ * The composite stamps a six-region skeleton once on first connect:
17
+ * [data-header] · [data-parties] · [data-lines] · [data-totals]
18
+ * · [data-footer] · [data-empty]
19
+ * Then mutates per-region nodes via id-keyed in-place diff on
20
+ * every `invoice` / `status` / `currency` / `loading` change.
21
+ *
22
+ * Form participation: none. Extends UIElement, not UIFormElement —
23
+ * invoices are display surfaces (per SPEC-007 §8 Base class).
24
+ *
25
+ * Per ADR-0027 composed primitives are NOT auto-imported. The
26
+ * consumer (or this composite's .html demo shell) must side-effect
27
+ * import each one explicitly.
28
+ */
29
+
30
+ import { UIElement } from '@adia-ai/web-components/core/element';
31
+ import { untracked } from '@adia-ai/web-components/core/signals';
32
+
33
+ const KNOWN_STATUSES = new Set(['draft', 'open', 'paid', 'past-due', 'void']);
34
+
35
+ /**
36
+ * Map invoice.status to the tag-ui [variant] enum.
37
+ * Unknown statuses fall back to `default` and trigger a one-shot warn.
38
+ */
39
+ const STATUS_TO_VARIANT = {
40
+ draft: 'default',
41
+ open: 'info',
42
+ paid: 'success',
43
+ 'past-due': 'warning',
44
+ void: 'default',
45
+ };
46
+
47
+ const STATUS_LABEL = {
48
+ draft: 'Draft',
49
+ open: 'Open',
50
+ paid: 'Paid',
51
+ 'past-due': 'Past due',
52
+ void: 'Void',
53
+ };
54
+
55
+ /**
56
+ * Format an amount with Intl.NumberFormat for the given currency + locale.
57
+ * Returns a string like "$24.50"; falls back to "{currency} {value}" on
58
+ * failure.
59
+ */
60
+ function formatAmount(amount, currency, locale) {
61
+ const value = Number.isFinite(amount) ? amount : 0;
62
+ try {
63
+ return new Intl.NumberFormat(locale || undefined, {
64
+ style: 'currency',
65
+ currency: currency || 'USD',
66
+ minimumFractionDigits: 2,
67
+ maximumFractionDigits: 2,
68
+ }).format(value);
69
+ } catch {
70
+ return `${currency || '$'}${value.toFixed(2)}`;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Format an ISO date string with Intl.DateTimeFormat.
76
+ * Returns the input verbatim on failure (the consumer's authored
77
+ * string is better than no string).
78
+ */
79
+ function formatDate(iso, locale) {
80
+ if (!iso) return '';
81
+ try {
82
+ const d = new Date(iso);
83
+ if (Number.isNaN(d.getTime())) return String(iso);
84
+ return new Intl.DateTimeFormat(locale || undefined, {
85
+ year: 'numeric',
86
+ month: 'short',
87
+ day: 'numeric',
88
+ }).format(d);
89
+ } catch {
90
+ return String(iso);
91
+ }
92
+ }
93
+
94
+ export class UIInvoiceDetail extends UIElement {
95
+ static get properties() {
96
+ return {
97
+ // The invoice record — source of truth for the rendered page.
98
+ invoice: { type: Object, default: null },
99
+ status: { type: String, default: 'draft', reflect: true },
100
+ currency: { type: String, default: 'USD', reflect: true },
101
+ density: { type: String, default: 'standard', reflect: true },
102
+ loading: { type: Boolean, default: false, reflect: true },
103
+ printable: { type: Boolean, default: true, reflect: true },
104
+ locale: { type: String, default: '' },
105
+ };
106
+ }
107
+
108
+ // No template — the composite stamps a fixed region skeleton once
109
+ // and mutates per-region nodes across renders. Returning html``
110
+ // would trigger stamp() → replaceChildren() and wipe authored
111
+ // slotted children + lose focus inside the lines table.
112
+ static template = () => null;
113
+
114
+ // ── Field refs ────────────────────────────────────────────────────
115
+ #stamped = false;
116
+ #cleanups = [];
117
+ #headerHost = null; // [data-header] — number + status + dates + actions
118
+ #headerNumber = null; // <h2> invoice number heading
119
+ #headerStatus = null; // <tag-ui> status badge
120
+ #headerMeta = null; // dates row
121
+ #headerActions = null; // slot region for header-actions
122
+ #partiesHost = null; // [data-parties] — customer + remit-to
123
+ #customerBlock = null; // default customer content
124
+ #remitToBlock = null; // default remit-to content
125
+ #linesHost = null; // [data-lines] — line-items table card
126
+ #linesTable = null; // <table-ui>
127
+ #totalsHost = null; // [data-totals] — totals stack
128
+ #footerHost = null; // [data-footer] — slot region for terms / notes
129
+ #emptyHost = null; // [data-empty] — empty-state-ui
130
+ #headingId = '';
131
+ #internalUpdate = false;
132
+ #warnedStatus = false;
133
+ #loadCount = 0;
134
+ #lastEmittedInvoiceNumber = '';
135
+
136
+ // ── Public imperative API (SPEC §4 Methods) ───────────────────────
137
+
138
+ /**
139
+ * Invoke window.print() with the invoice page as the print target.
140
+ * If window.print is unavailable, dispatches `error` with reason=
141
+ * print-unavailable.
142
+ */
143
+ print() {
144
+ if (typeof globalThis.window?.print === 'function') {
145
+ try {
146
+ globalThis.window.print();
147
+ } catch (err) {
148
+ this.dispatchEvent(new CustomEvent('error', {
149
+ bubbles: true,
150
+ detail: { reason: 'print-unavailable', message: String(err?.message || err) },
151
+ }));
152
+ }
153
+ return;
154
+ }
155
+ this.dispatchEvent(new CustomEvent('error', {
156
+ bubbles: true,
157
+ detail: { reason: 'print-unavailable' },
158
+ }));
159
+ }
160
+
161
+ /** Re-fetch the invoice from `data-stream-src`. */
162
+ async refresh() {
163
+ const src = this.getAttribute('data-stream-src');
164
+ if (!src) return;
165
+ const tag = ++this.#loadCount;
166
+ this.loading = true;
167
+ this.removeAttribute('error');
168
+ try {
169
+ const res = await fetch(src);
170
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
171
+ const data = await res.json();
172
+ if (tag !== this.#loadCount) return; // stale
173
+ this.loading = false;
174
+ // Allow either {invoice: {...}} envelope or the raw record.
175
+ const record = data && typeof data === 'object' && data.invoice
176
+ ? data.invoice
177
+ : data;
178
+ this.invoice = record;
179
+ // Mirror invoice.status into the reflected host status when present.
180
+ if (record?.status) {
181
+ this.#internalUpdate = true;
182
+ try { this.status = String(record.status); } finally { this.#internalUpdate = false; }
183
+ }
184
+ const number = record?.number || '';
185
+ this.dispatchEvent(new CustomEvent('load', {
186
+ bubbles: true,
187
+ detail: { invoiceNumber: number },
188
+ }));
189
+ this.#lastEmittedInvoiceNumber = number;
190
+ } catch (err) {
191
+ if (tag !== this.#loadCount) return;
192
+ this.loading = false;
193
+ this.setAttribute('error', '');
194
+ this.dispatchEvent(new CustomEvent('error', {
195
+ bubbles: true,
196
+ detail: { reason: 'fetch-failed', message: String(err?.message || err) },
197
+ }));
198
+ }
199
+ }
200
+
201
+ // ── Lifecycle ─────────────────────────────────────────────────────
202
+
203
+ connected() {
204
+ this.setAttribute('role', 'region');
205
+
206
+ if (!this.#stamped) {
207
+ // Absorb a <script type="application/json" data-invoice> child
208
+ // before stamping wipes anything. Authors can declare the
209
+ // invoice declaratively this way (matches the in-band JSON
210
+ // pattern shared with table-ui / plan-picker-ui).
211
+ this.#absorbInlineInvoiceScript();
212
+ this.#stamp();
213
+ this.#wireListeners();
214
+ this.#stamped = true;
215
+ }
216
+
217
+ // Trigger first render after the registry has had a chance to
218
+ // upgrade the composed primitives.
219
+ untracked(() => this.render());
220
+
221
+ // Fetch on connect when configured.
222
+ if (this.getAttribute('data-stream-src')) {
223
+ this.refresh();
224
+ }
225
+ }
226
+
227
+ disconnected() {
228
+ for (const fn of this.#cleanups) {
229
+ try { fn(); } catch { /* ignore stale */ }
230
+ }
231
+ this.#cleanups.length = 0;
232
+ }
233
+
234
+ attributeChangedCallback(name, oldVal, newVal) {
235
+ super.attributeChangedCallback?.(name, oldVal, newVal);
236
+ if (!this.#stamped || this.#internalUpdate) return;
237
+ if (name === 'data-stream-src' && oldVal !== newVal && newVal) {
238
+ this.refresh();
239
+ }
240
+ }
241
+
242
+ // ── Render — narrow; mutates per-region nodes only ────────────────
243
+
244
+ render() {
245
+ if (!this.#stamped) return;
246
+
247
+ const inv = this.invoice;
248
+ const isEmpty = !inv && !this.getAttribute('data-stream-src');
249
+ if (isEmpty) this.setAttribute('empty', ''); else this.removeAttribute('empty');
250
+
251
+ // aria-busy mirrors [loading].
252
+ if (this.loading) this.setAttribute('aria-busy', 'true');
253
+ else this.removeAttribute('aria-busy');
254
+
255
+ // Status resolution — invoice.status wins unless the host has been
256
+ // EXPLICITLY set to a different status (i.e. the host's `status`
257
+ // attribute was authored, overriding the data). We can't simply
258
+ // read `this.status` first because the reflected default 'draft'
259
+ // would mask any invoice.status. Use the attribute presence as
260
+ // the signal of an explicit host-side override.
261
+ const hostStatusOverride = this.hasAttribute('status')
262
+ ? this.getAttribute('status')
263
+ : '';
264
+ const status = hostStatusOverride || inv?.status || this.status || 'draft';
265
+ if (!KNOWN_STATUSES.has(status)) {
266
+ if (!this.#warnedStatus) {
267
+ this.#warnedStatus = true;
268
+ // eslint-disable-next-line no-console
269
+ console.warn(
270
+ `[invoice-detail-ui] unknown status "${status}" — rendering default badge.`,
271
+ );
272
+ }
273
+ }
274
+
275
+ if (isEmpty) {
276
+ // Empty state is rendered (CSS-gated reveal); we still render
277
+ // the header chrome with an empty title so the host has a
278
+ // labelable region in screen-reader output.
279
+ this.#renderHeader(null, status);
280
+ // aria-label fallback for empty.
281
+ if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
282
+ this.setAttribute('aria-label', 'No invoice loaded');
283
+ }
284
+ return;
285
+ }
286
+
287
+ if (!inv) {
288
+ // loading without an invoice yet — show the empty placeholder
289
+ // (no header content to render).
290
+ this.#renderHeader(null, status);
291
+ return;
292
+ }
293
+
294
+ this.#renderHeader(inv, status);
295
+ this.#renderParties(inv);
296
+ this.#renderLines(inv);
297
+ this.#renderTotals(inv);
298
+ this.#renderAccessibleLabel(inv);
299
+ }
300
+
301
+ // ── Internal — DOM stamping ───────────────────────────────────────
302
+
303
+ #absorbInlineInvoiceScript() {
304
+ const script = this.querySelector(
305
+ ':scope > script[type="application/json"][data-invoice]',
306
+ );
307
+ if (!script) return;
308
+ try {
309
+ const parsed = JSON.parse(script.textContent || '{}');
310
+ if (parsed && typeof parsed === 'object') {
311
+ const record = parsed.invoice && typeof parsed.invoice === 'object'
312
+ ? parsed.invoice
313
+ : parsed;
314
+ this.invoice = record;
315
+ if (record?.status) {
316
+ this.#internalUpdate = true;
317
+ try { this.status = String(record.status); } finally { this.#internalUpdate = false; }
318
+ }
319
+ }
320
+ } catch (err) {
321
+ // eslint-disable-next-line no-console
322
+ console.warn(
323
+ '[invoice-detail-ui] failed to parse <script data-invoice> JSON:',
324
+ err?.message,
325
+ );
326
+ } finally {
327
+ script.remove();
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Move consumer-authored `[slot=...]` direct children into their
333
+ * target regions. Light-DOM substrate (ADR-0033) — `slot=` is
334
+ * decorative metadata; <slot> elements do NOT reparent children
335
+ * the way they do in shadow DOM. The composite physically moves
336
+ * the children into their target containers during stamping.
337
+ */
338
+ #absorbSlottedChildren(slotName, target) {
339
+ const nodes = this.querySelectorAll(`:scope > [slot="${slotName}"]`);
340
+ for (const n of nodes) target.appendChild(n);
341
+ }
342
+
343
+ #stamp() {
344
+ // Unique id for the invoice-number heading (drives aria-labelledby).
345
+ this.#headingId = `inv-h-${Math.random().toString(36).slice(2, 8)}`;
346
+
347
+ // ── Header region ── number + status + dates + slotted actions.
348
+ const header = document.createElement('header');
349
+ header.setAttribute('data-header', '');
350
+
351
+ const titleWrap = document.createElement('div');
352
+ titleWrap.setAttribute('data-header-title', '');
353
+
354
+ const number = document.createElement('h2');
355
+ number.id = this.#headingId;
356
+ number.setAttribute('data-invoice-number', '');
357
+ titleWrap.appendChild(number);
358
+
359
+ const status = document.createElement('tag-ui');
360
+ status.setAttribute('data-invoice-status', '');
361
+ status.setAttribute('size', 'md');
362
+ titleWrap.appendChild(status);
363
+
364
+ const meta = document.createElement('div');
365
+ meta.setAttribute('data-header-meta', '');
366
+
367
+ const actions = document.createElement('div');
368
+ actions.setAttribute('data-header-actions', '');
369
+ actions.setAttribute('data-invoice-actions', '');
370
+
371
+ header.append(titleWrap, meta, actions);
372
+ this.appendChild(header);
373
+ this.#headerHost = header;
374
+ this.#headerNumber = number;
375
+ this.#headerStatus = status;
376
+ this.#headerMeta = meta;
377
+ this.#headerActions = actions;
378
+
379
+ // Absorb consumer-authored [slot="header-actions"] children. Per
380
+ // SPEC-007 §13 OD-002 the composite never stamps a default
381
+ // toolbar; the actions container stays empty until consumers
382
+ // slot one or more buttons.
383
+ this.#absorbSlottedChildren('header-actions', actions);
384
+
385
+ // ── Parties region ── customer + remit-to blocks.
386
+ const parties = document.createElement('section');
387
+ parties.setAttribute('data-parties', '');
388
+
389
+ const customer = document.createElement('div');
390
+ customer.setAttribute('data-customer', '');
391
+ const customerDefault = document.createElement('div');
392
+ customerDefault.setAttribute('data-customer-default', '');
393
+ customer.appendChild(customerDefault);
394
+ this.#customerBlock = customerDefault;
395
+
396
+ const remitTo = document.createElement('div');
397
+ remitTo.setAttribute('data-remit-to', '');
398
+ const remitToDefault = document.createElement('div');
399
+ remitToDefault.setAttribute('data-remit-to-default', '');
400
+ remitTo.appendChild(remitToDefault);
401
+ this.#remitToBlock = remitToDefault;
402
+
403
+ parties.append(customer, remitTo);
404
+ this.appendChild(parties);
405
+ this.#partiesHost = parties;
406
+
407
+ // Absorb consumer overrides for the customer + remit-to blocks.
408
+ // When present, the default block stays empty and the CSS
409
+ // collapses it.
410
+ this.#absorbSlottedChildren('customer-block', customer);
411
+ this.#absorbSlottedChildren('remit-to-block', remitTo);
412
+
413
+ // ── Lines region ── card wrapping a table-ui of line items.
414
+ const lines = document.createElement('section');
415
+ lines.setAttribute('data-lines', '');
416
+
417
+ const linesCard = document.createElement('card-ui');
418
+ linesCard.setAttribute('data-lines-card', '');
419
+ linesCard.setAttribute('padding', 'none');
420
+ const linesBody = document.createElement('section');
421
+ linesBody.setAttribute('bleed', '');
422
+ linesCard.appendChild(linesBody);
423
+
424
+ const table = document.createElement('table-ui');
425
+ table.setAttribute('data-lines-table', '');
426
+ table.setAttribute('density', this.density || 'standard');
427
+ linesBody.appendChild(table);
428
+
429
+ lines.appendChild(linesCard);
430
+ this.appendChild(lines);
431
+ this.#linesHost = lines;
432
+ this.#linesTable = table;
433
+
434
+ // ── Totals region ── stat-ui rows for subtotal · tax · discount · total.
435
+ const totals = document.createElement('section');
436
+ totals.setAttribute('data-totals', '');
437
+ this.appendChild(totals);
438
+ this.#totalsHost = totals;
439
+
440
+ // ── Footer region ── slotted notes / terms.
441
+ const footer = document.createElement('footer');
442
+ footer.setAttribute('data-footer', '');
443
+ const footerNotes = document.createElement('div');
444
+ footerNotes.setAttribute('data-footer-notes', '');
445
+ footer.appendChild(footerNotes);
446
+ this.appendChild(footer);
447
+ this.#footerHost = footer;
448
+
449
+ // Absorb consumer-authored [slot="footer"] children. When the
450
+ // slot is empty AND footerNotes is empty, CSS collapses the
451
+ // footer.
452
+ this.#absorbSlottedChildren('footer', footer);
453
+
454
+ // ── Empty state ── overridable via the host wrapping its own
455
+ // empty-state, but we ship a default for the zero-config path.
456
+ const empty = document.createElement('empty-state-ui');
457
+ empty.setAttribute('data-empty', '');
458
+ empty.setAttribute('icon', 'receipt');
459
+ empty.setAttribute('heading', 'No invoice loaded');
460
+ empty.setAttribute(
461
+ 'description',
462
+ 'Pass an `invoice` record or `data-stream-src` to render an invoice.',
463
+ );
464
+ this.appendChild(empty);
465
+ this.#emptyHost = empty;
466
+ }
467
+
468
+ // ── Internal — region renderers (mutate existing nodes) ───────────
469
+
470
+ #renderHeader(inv, status) {
471
+ if (!this.#headerNumber || !this.#headerStatus || !this.#headerMeta) return;
472
+
473
+ // Invoice number heading.
474
+ const number = inv?.number ? String(inv.number) : '';
475
+ if (this.#headerNumber.textContent !== number) {
476
+ this.#headerNumber.textContent = number;
477
+ }
478
+
479
+ // Status badge — variant + text.
480
+ const variant = STATUS_TO_VARIANT[status] || 'default';
481
+ const label = STATUS_LABEL[status] || (status ? String(status) : 'Draft');
482
+ if (this.#headerStatus.getAttribute('variant') !== variant) {
483
+ this.#headerStatus.setAttribute('variant', variant);
484
+ }
485
+ if (this.#headerStatus.getAttribute('text') !== label) {
486
+ this.#headerStatus.setAttribute('text', label);
487
+ }
488
+ // aria-label for screen readers — match the variant intent.
489
+ this.#headerStatus.setAttribute('aria-label', `Status: ${label}`);
490
+
491
+ // Hide the badge entirely on the empty / pending state.
492
+ if (!inv) this.#headerStatus.setAttribute('hidden', '');
493
+ else this.#headerStatus.removeAttribute('hidden');
494
+
495
+ // Header meta — issued + due dates.
496
+ this.#headerMeta.replaceChildren();
497
+ if (!inv) return;
498
+ const locale = this.locale || (typeof document !== 'undefined'
499
+ ? document.documentElement.lang : '');
500
+ if (inv.issuedAt) {
501
+ const issued = document.createElement('span');
502
+ issued.setAttribute('data-issued', '');
503
+ issued.textContent = `Issued ${formatDate(inv.issuedAt, locale)}`;
504
+ this.#headerMeta.appendChild(issued);
505
+ }
506
+ if (inv.dueAt) {
507
+ const due = document.createElement('span');
508
+ due.setAttribute('data-due', '');
509
+ due.textContent = `Due ${formatDate(inv.dueAt, locale)}`;
510
+ this.#headerMeta.appendChild(due);
511
+ }
512
+ }
513
+
514
+ #renderParties(inv) {
515
+ if (!this.#customerBlock || !this.#remitToBlock) return;
516
+
517
+ // Customer block.
518
+ this.#customerBlock.replaceChildren();
519
+ if (inv?.customer) {
520
+ const heading = document.createElement('div');
521
+ heading.setAttribute('data-party-heading', '');
522
+ heading.textContent = 'Billed to';
523
+ this.#customerBlock.appendChild(heading);
524
+
525
+ if (inv.customer.name) {
526
+ const name = document.createElement('div');
527
+ name.setAttribute('data-party-name', '');
528
+ name.textContent = String(inv.customer.name);
529
+ this.#customerBlock.appendChild(name);
530
+ }
531
+ if (inv.customer.email) {
532
+ const email = document.createElement('div');
533
+ email.setAttribute('data-party-email', '');
534
+ email.textContent = String(inv.customer.email);
535
+ this.#customerBlock.appendChild(email);
536
+ }
537
+ if (inv.customer.address) {
538
+ const address = document.createElement('div');
539
+ address.setAttribute('data-party-address', '');
540
+ address.textContent = String(inv.customer.address);
541
+ this.#customerBlock.appendChild(address);
542
+ }
543
+ }
544
+
545
+ // Remit-to block (optional).
546
+ this.#remitToBlock.replaceChildren();
547
+ if (inv?.remitTo) {
548
+ const heading = document.createElement('div');
549
+ heading.setAttribute('data-party-heading', '');
550
+ heading.textContent = 'Remit to';
551
+ this.#remitToBlock.appendChild(heading);
552
+
553
+ if (inv.remitTo.name) {
554
+ const name = document.createElement('div');
555
+ name.setAttribute('data-party-name', '');
556
+ name.textContent = String(inv.remitTo.name);
557
+ this.#remitToBlock.appendChild(name);
558
+ }
559
+ if (inv.remitTo.address) {
560
+ const address = document.createElement('div');
561
+ address.setAttribute('data-party-address', '');
562
+ address.textContent = String(inv.remitTo.address);
563
+ this.#remitToBlock.appendChild(address);
564
+ }
565
+ }
566
+ }
567
+
568
+ #renderLines(inv) {
569
+ if (!this.#linesTable) return;
570
+
571
+ const currency = inv?.currency || this.currency || 'USD';
572
+ const locale = this.locale || (typeof document !== 'undefined'
573
+ ? document.documentElement.lang : '');
574
+
575
+ // Columns + data — handed to table-ui via JS props (the data
576
+ // lifecycle path documented in table.yaml).
577
+ this.#linesTable.columns = [
578
+ { key: 'description', label: 'Description', flex: 1 },
579
+ { key: 'qty', label: 'Qty', width: 80, format: (v) => String(v ?? '') },
580
+ {
581
+ key: 'unitAmount',
582
+ label: 'Unit',
583
+ width: 120,
584
+ format: (v) => formatAmount(v, currency, locale),
585
+ },
586
+ {
587
+ key: 'amount',
588
+ label: 'Amount',
589
+ width: 140,
590
+ format: (v) => formatAmount(v, currency, locale),
591
+ },
592
+ ];
593
+
594
+ const rows = Array.isArray(inv?.lines) ? inv.lines : [];
595
+ this.#linesTable.data = rows.map((line, i) => ({
596
+ __row: i,
597
+ description: line?.description ?? '',
598
+ qty: line?.qty ?? '',
599
+ unitAmount: line?.unitAmount ?? 0,
600
+ amount: line?.amount ?? 0,
601
+ }));
602
+ // Mirror density.
603
+ if (this.#linesTable.getAttribute('density') !== this.density) {
604
+ this.#linesTable.setAttribute('density', this.density || 'standard');
605
+ }
606
+ }
607
+
608
+ #renderTotals(inv) {
609
+ if (!this.#totalsHost) return;
610
+
611
+ const currency = inv?.currency || this.currency || 'USD';
612
+ const locale = this.locale || (typeof document !== 'undefined'
613
+ ? document.documentElement.lang : '');
614
+
615
+ this.#totalsHost.replaceChildren();
616
+ if (!inv) return;
617
+
618
+ const rows = [
619
+ { key: 'subtotal', label: 'Subtotal', value: inv.subtotal },
620
+ { key: 'tax', label: 'Tax', value: inv.tax },
621
+ ];
622
+ if (Number.isFinite(inv.discount) && inv.discount > 0) {
623
+ rows.push({ key: 'discount', label: 'Discount', value: -Math.abs(inv.discount) });
624
+ }
625
+ for (const row of rows) {
626
+ if (!Number.isFinite(row.value)) continue;
627
+ const line = document.createElement('div');
628
+ line.setAttribute('data-totals-row', '');
629
+ line.setAttribute('data-totals-key', row.key);
630
+ const labelEl = document.createElement('span');
631
+ labelEl.setAttribute('data-totals-label', '');
632
+ labelEl.textContent = row.label;
633
+ const valueEl = document.createElement('span');
634
+ valueEl.setAttribute('data-totals-value', '');
635
+ valueEl.textContent = formatAmount(row.value, currency, locale);
636
+ line.append(labelEl, valueEl);
637
+ this.#totalsHost.appendChild(line);
638
+ }
639
+
640
+ // Grand total — rendered prominently.
641
+ if (Number.isFinite(inv.total)) {
642
+ const line = document.createElement('div');
643
+ line.setAttribute('data-totals-row', '');
644
+ line.setAttribute('data-totals-key', 'total');
645
+ line.setAttribute('data-grand-total', '');
646
+ const labelEl = document.createElement('span');
647
+ labelEl.setAttribute('data-totals-label', '');
648
+ labelEl.textContent = 'Total';
649
+ const valueEl = document.createElement('span');
650
+ valueEl.setAttribute('data-totals-value', '');
651
+ valueEl.textContent = formatAmount(inv.total, currency, locale);
652
+ line.append(labelEl, valueEl);
653
+ this.#totalsHost.appendChild(line);
654
+ }
655
+ }
656
+
657
+ #renderAccessibleLabel(inv) {
658
+ if (!inv) return;
659
+ // aria-labelledby points at the invoice-number heading so screen
660
+ // readers announce the landmark with the invoice number as its
661
+ // name.
662
+ if (this.#headingId) {
663
+ this.setAttribute('aria-labelledby', this.#headingId);
664
+ }
665
+ }
666
+
667
+ // ── Event delegation ──────────────────────────────────────────────
668
+
669
+ #wireListeners() {
670
+ // Header-action activation — bubble `press` (button-ui) or `click`
671
+ // up from any [data-invoice-action] descendant slotted into the
672
+ // header-actions slot.
673
+ const onAction = (e) => {
674
+ const target = e.target?.closest?.('[data-invoice-action]');
675
+ if (!target || !this.contains(target)) return;
676
+ const action = target.getAttribute('data-invoice-action') || '';
677
+ if (!action) return;
678
+ const invoiceNumber = this.invoice?.number || '';
679
+ e.stopPropagation();
680
+ this.dispatchEvent(new CustomEvent('invoice-action', {
681
+ bubbles: true,
682
+ detail: { action, invoiceNumber },
683
+ }));
684
+ };
685
+ this.addEventListener('press', onAction);
686
+ this.addEventListener('click', onAction);
687
+ this.#cleanups.push(() => this.removeEventListener('press', onAction));
688
+ this.#cleanups.push(() => this.removeEventListener('click', onAction));
689
+
690
+ // Line-item click → emit `line-item-click`. Listens at the lines
691
+ // host and routes via the data-row attribute on the table-ui's
692
+ // row element (per table-ui's data lifecycle).
693
+ const onLineClick = (e) => {
694
+ if (!this.#linesHost) return;
695
+ const path = e.composedPath ? e.composedPath() : [];
696
+ const row = path.find((n) => n?.getAttribute && n.getAttribute('data-row') != null);
697
+ if (!row || !this.#linesHost.contains(row)) return;
698
+ const index = Number(row.getAttribute('data-row'));
699
+ const lineItem = Array.isArray(this.invoice?.lines)
700
+ ? this.invoice.lines[index]
701
+ : null;
702
+ if (!Number.isFinite(index) || lineItem == null) return;
703
+ this.dispatchEvent(new CustomEvent('line-item-click', {
704
+ bubbles: true,
705
+ detail: { index, lineItem },
706
+ }));
707
+ };
708
+ this.addEventListener('click', onLineClick);
709
+ this.#cleanups.push(() => this.removeEventListener('click', onLineClick));
710
+ }
711
+ }