@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.
- package/CHANGELOG.md +25 -0
- package/README.md +4 -4
- package/agent-admin/admin-roster/admin-roster.js +1 -1
- package/agent-admin/admin-settings/admin-settings.js +1 -1
- package/agent-admin/agent-admin/agent-admin.js +1 -1
- package/billing/billing-overview/billing-overview.a2ui.json +281 -0
- package/billing/billing-overview/billing-overview.class.js +838 -0
- package/billing/billing-overview/billing-overview.css +223 -0
- package/billing/billing-overview/billing-overview.js +16 -0
- package/billing/billing-overview/billing-overview.yaml +441 -0
- package/billing/index.js +21 -0
- package/billing/invoice-detail/invoice-detail.a2ui.json +276 -0
- package/billing/invoice-detail/invoice-detail.class.js +711 -0
- package/billing/invoice-detail/invoice-detail.css +305 -0
- package/billing/invoice-detail/invoice-detail.js +16 -0
- package/billing/invoice-detail/invoice-detail.yaml +411 -0
- package/billing/invoice-history/invoice-history.a2ui.json +315 -0
- package/billing/invoice-history/invoice-history.class.js +672 -0
- package/billing/invoice-history/invoice-history.css +130 -0
- package/billing/invoice-history/invoice-history.js +16 -0
- package/billing/invoice-history/invoice-history.yaml +398 -0
- package/billing/payment-method-form/payment-method-form.a2ui.json +310 -0
- package/billing/payment-method-form/payment-method-form.class.js +886 -0
- package/billing/payment-method-form/payment-method-form.css +149 -0
- package/billing/payment-method-form/payment-method-form.js +16 -0
- package/billing/payment-method-form/payment-method-form.yaml +430 -0
- package/billing/payment-method-list/payment-method-list.a2ui.json +283 -0
- package/billing/payment-method-list/payment-method-list.class.js +705 -0
- package/billing/payment-method-list/payment-method-list.css +128 -0
- package/billing/payment-method-list/payment-method-list.js +16 -0
- package/billing/payment-method-list/payment-method-list.yaml +355 -0
- package/billing/plan-picker/plan-picker.a2ui.json +349 -0
- package/billing/plan-picker/plan-picker.class.js +635 -0
- package/billing/plan-picker/plan-picker.css +227 -0
- package/billing/plan-picker/plan-picker.js +16 -0
- package/billing/plan-picker/plan-picker.yaml +453 -0
- package/chat/README.md +2 -2
- package/chat/chat-composer/chat-composer.js +2 -2
- package/chat/chat-empty/chat-empty.js +2 -2
- package/chat/chat-shell/chat-shell.js +6 -6
- package/chat/chat-sidebar/chat-sidebar.js +2 -2
- package/chat/chat-surfaces/chat-surfaces.js +3 -3
- package/chat/chat-thread/chat-thread.js +2 -2
- package/dashboard/dashboard-layout/dashboard-layout.a2ui.json +254 -0
- package/dashboard/dashboard-layout/dashboard-layout.css +190 -0
- package/dashboard/dashboard-layout/dashboard-layout.js +289 -0
- package/dashboard/dashboard-layout/dashboard-layout.yaml +374 -0
- package/dashboard/date-range-selector/date-range-selector.a2ui.json +236 -0
- package/dashboard/date-range-selector/date-range-selector.css +84 -0
- package/dashboard/date-range-selector/date-range-selector.js +686 -0
- package/dashboard/date-range-selector/date-range-selector.yaml +358 -0
- package/dashboard/index.js +14 -0
- package/dist/chat/chat-shell.min.js +1 -1
- package/dist/everything.min.js +121 -102
- package/editor/editor-canvas/editor-canvas.js +2 -2
- package/editor/editor-shell/editor-shell.js +2 -2
- package/editor/editor-sidebar/editor-sidebar.js +2 -2
- package/editor/editor-toolbar/editor-toolbar.js +2 -2
- package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
- package/feedback/confirm-dialog/confirm-dialog.css +157 -0
- package/feedback/confirm-dialog/confirm-dialog.js +383 -0
- package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
- package/feedback/index.js +10 -0
- package/form/form-popover/form-popover.js +1 -1
- package/generative/index.d.ts +1 -1
- package/generative/index.js +2 -2
- package/onboarding/index.js +1 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
- package/package.json +99 -6
- package/runtime/a2ui-root/a2ui-root.js +3 -3
- package/runtime/gen-root/gen-root.js +5 -5
- package/settings/index.js +12 -0
- package/settings/integrations-page/integrations-page.a2ui.json +274 -0
- package/settings/integrations-page/integrations-page.css +177 -0
- package/settings/integrations-page/integrations-page.d.ts +77 -0
- package/settings/integrations-page/integrations-page.js +413 -0
- package/settings/integrations-page/integrations-page.yaml +317 -0
- package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
- package/settings/notification-preferences/notification-preferences.css +204 -0
- package/settings/notification-preferences/notification-preferences.js +622 -0
- package/settings/notification-preferences/notification-preferences.yaml +358 -0
- package/shell/admin-command/admin-command.js +2 -2
- package/shell/admin-shell/admin-shell.js +2 -2
- package/shell/admin-sidebar/admin-sidebar.js +2 -2
- package/shell/embed-shell/embed-shell.js +3 -3
- package/simple/simple-shell/simple-shell.js +1 -1
- 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
|
+
}
|