@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,672 @@
1
+ /**
2
+ * <invoice-history-ui> — Canonical billing-list composite.
3
+ *
4
+ * Pre-configures <table-ui> with invoice-shaped columns + status-badge
5
+ * cell renderer + currency formatter + row-click navigation, alongside
6
+ * an optional <table-toolbar-ui> for search/filter and a default
7
+ * "Export CSV" button. Wraps, does not fork, the underlying primitive.
8
+ *
9
+ * SPEC: .claude/docs/specs/implementation-ready/SPEC-008-invoice-history-table.md
10
+ * WAI-APG: https://www.w3.org/WAI/ARIA/apg/patterns/grid/ (inherited
11
+ * from <table-ui>)
12
+ *
13
+ * Architecture (light-DOM, ADR-0033):
14
+ * This composite stamps a three-region skeleton once on first connect:
15
+ * [data-toolbar] (slot toolbar / default toolbar)
16
+ * [data-table] (the <table-ui>)
17
+ * [data-empty] (default <empty-state-ui> branch)
18
+ * Then mutates the table's `.data` / `.columns` on every render. The
19
+ * table primitive owns row stamping; this composite only wires its
20
+ * inputs and converts cell renderers.
21
+ *
22
+ * Public API:
23
+ * .invoices — array of records (re-render on assign)
24
+ * .currency — default ISO 4217 code
25
+ * .filter — active status filter ('' = all)
26
+ * .setFilter(s) — set the filter programmatically; returns true on hit
27
+ * .exportCsv() — Blob of currently-visible rows
28
+ * .getSelected() — array of selected invoice records
29
+ * .refresh() — re-fetch from data-stream-src when configured
30
+ */
31
+
32
+ import { UIElement } from '@adia-ai/web-components/core/element';
33
+ import { untracked } from '@adia-ai/web-components/core/signals';
34
+
35
+ const KNOWN_STATUSES = new Set(['draft', 'open', 'paid', 'past-due', 'void']);
36
+
37
+ const STATUS_TO_VARIANT = {
38
+ paid: 'success',
39
+ open: 'info',
40
+ 'past-due':'danger',
41
+ };
42
+ // `void` and `draft` are family-less badges (quiet chrome) — `variant="muted"`/
43
+ // `"neutral"` retired (variant/color/tone convention cut, gh#873/874); the
44
+ // status render() below sets `tone="muted"` for any status absent here.
45
+
46
+ const STATUS_LABEL = {
47
+ paid: 'Paid',
48
+ open: 'Open',
49
+ 'past-due':'Past due',
50
+ void: 'Void',
51
+ draft: 'Draft',
52
+ };
53
+
54
+ const FILTER_CHIPS = [
55
+ { value: '', label: 'All' },
56
+ { value: 'paid', label: 'Paid' },
57
+ { value: 'open', label: 'Open' },
58
+ { value: 'past-due', label: 'Past due' },
59
+ { value: 'void', label: 'Void' },
60
+ { value: 'draft', label: 'Draft' },
61
+ ];
62
+
63
+ const CSV_HEADERS = ['Number', 'Issued', 'Due', 'Amount', 'Currency', 'Status'];
64
+
65
+ /**
66
+ * Format an amount via Intl.NumberFormat. Falls back to `${currency}${value}`
67
+ * on engine failure.
68
+ */
69
+ function formatAmount(amount, currency, locale) {
70
+ const value = Number.isFinite(amount) ? amount : 0;
71
+ try {
72
+ return new Intl.NumberFormat(locale || undefined, {
73
+ style: 'currency',
74
+ currency: currency || 'USD',
75
+ maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
76
+ }).format(value);
77
+ } catch {
78
+ return `${currency || '$'}${value}`;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Format a date string ("YYYY-MM-DD" or ISO) via Intl.DateTimeFormat
84
+ * with the medium style. Returns the raw string on failure.
85
+ */
86
+ function formatDate(raw, locale) {
87
+ if (!raw) return '';
88
+ try {
89
+ const d = raw instanceof Date ? raw : new Date(raw);
90
+ if (Number.isNaN(d.getTime())) return String(raw);
91
+ return new Intl.DateTimeFormat(locale || undefined, {
92
+ year: 'numeric',
93
+ month: 'short',
94
+ day: '2-digit',
95
+ }).format(d);
96
+ } catch {
97
+ return String(raw);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Resolve hrefPattern with invoice data — replaces `{number}` and
103
+ * `{id}` tokens. Other tokens fall through unchanged.
104
+ */
105
+ function resolveHref(pattern, invoice) {
106
+ const tpl = pattern || '/invoices/{number}';
107
+ return tpl
108
+ .replace('{number}', encodeURIComponent(invoice?.number ?? ''))
109
+ .replace('{id}', encodeURIComponent(invoice?.id ?? ''));
110
+ }
111
+
112
+ /**
113
+ * Escape a value for CSV output (Excel-friendly).
114
+ */
115
+ function csvEscape(val) {
116
+ const str = String(val ?? '');
117
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
118
+ return '"' + str.replace(/"/g, '""') + '"';
119
+ }
120
+ return str;
121
+ }
122
+
123
+ export class InvoiceHistory extends UIElement {
124
+ static properties = {
125
+ invoices: { type: Array, default: [] },
126
+ currency: { type: String, default: 'USD', reflect: true },
127
+ density: { type: String, default: 'standard', reflect: true },
128
+ loading: { type: Boolean, default: false, reflect: true },
129
+ selectable: { type: Boolean, default: false, reflect: true },
130
+ hrefPattern: { type: String, default: '/invoices/{number}', reflect: true, attribute: 'href-pattern' },
131
+ paginate: { type: Number, default: 25, reflect: true },
132
+ exportable: { type: Boolean, default: true, reflect: true },
133
+ locale: { type: String, default: '' },
134
+ filter: { type: String, default: '', reflect: true },
135
+ };
136
+
137
+ // The composite stamps its own DOM (toolbar · table · empty) once on
138
+ // connect and mutates table inputs across renders — no html template.
139
+ static template = () => null;
140
+
141
+ static requiredIcons = ['download-simple', 'receipt', 'magnifying-glass'];
142
+
143
+ // ── Field refs ────────────────────────────────────────────────────
144
+ #stamped = false;
145
+ #cleanups = [];
146
+ #toolbarHost = null; // <div data-toolbar>
147
+ #defaultToolbar = null; // <table-toolbar-ui> (when no slot=toolbar provided)
148
+ #filterChipsHost = null; // <div data-invoice-filter>
149
+ #exportBtn = null; // <button-ui data-export>
150
+ #tableHost = null; // <div data-table-host>
151
+ #table = null; // <table-ui>
152
+ #emptyHost = null; // <empty-state-ui data-empty>
153
+ #internalUpdate = false;
154
+ #loadCount = 0; // monotonic guard against stale fetches
155
+ #warnedColumns = false;
156
+
157
+ // ── Public API ────────────────────────────────────────────────────
158
+
159
+ /** Set the active status filter. Returns true on hit. */
160
+ setFilter(value) {
161
+ const next = value || '';
162
+ if (next && !KNOWN_STATUSES.has(next)) return false;
163
+ return untracked(() => {
164
+ if (this.filter === next) return true;
165
+ const previous = this.filter;
166
+ this.filter = next;
167
+ this.dispatchEvent(new CustomEvent('invoice-filter-change', {
168
+ bubbles: true,
169
+ detail: { filter: next, previous },
170
+ }));
171
+ return true;
172
+ });
173
+ }
174
+
175
+ /** CSV blob of the currently-visible (filtered) rows. */
176
+ exportCsv() {
177
+ const rows = this.#visibleInvoices();
178
+ const lines = [CSV_HEADERS.join(',')];
179
+ for (const inv of rows) {
180
+ const cells = [
181
+ csvEscape(inv.number ?? ''),
182
+ csvEscape(inv.issuedAt ?? ''),
183
+ csvEscape(inv.dueAt ?? ''),
184
+ csvEscape(Number.isFinite(inv.amount) ? inv.amount : ''),
185
+ csvEscape(inv.currency || this.currency || 'USD'),
186
+ csvEscape(inv.status ?? ''),
187
+ ];
188
+ lines.push(cells.join(','));
189
+ }
190
+ const csv = lines.join('\r\n');
191
+ return new Blob([csv], { type: 'text/csv;charset=utf-8;' });
192
+ }
193
+
194
+ /** Array of selected invoice records (when `selectable` is set). */
195
+ getSelected() {
196
+ if (!this.#table) return [];
197
+ const indices = this.#table.selected || [];
198
+ const rows = this.#visibleInvoices();
199
+ return indices.map((i) => rows[i]).filter(Boolean);
200
+ }
201
+
202
+ /** Re-fetch invoices from `data-stream-src`. */
203
+ async refresh() {
204
+ const src = this.getAttribute('data-stream-src');
205
+ if (!src) return;
206
+ const tag = ++this.#loadCount;
207
+ this.setAttribute('loading', '');
208
+ this.removeAttribute('error');
209
+ try {
210
+ const res = await fetch(src);
211
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
212
+ const data = await res.json();
213
+ if (tag !== this.#loadCount) return; // stale
214
+ this.removeAttribute('loading');
215
+ const list = Array.isArray(data)
216
+ ? data
217
+ : (Array.isArray(data?.invoices) ? data.invoices : []);
218
+ this.invoices = list;
219
+ this.dispatchEvent(new CustomEvent('load', {
220
+ bubbles: true,
221
+ detail: { count: list.length },
222
+ }));
223
+ } catch (err) {
224
+ if (tag !== this.#loadCount) return;
225
+ this.removeAttribute('loading');
226
+ this.setAttribute('error', '');
227
+ this.dispatchEvent(new CustomEvent('error', {
228
+ bubbles: true,
229
+ detail: { reason: 'fetch-failed', message: String(err?.message || err) },
230
+ }));
231
+ }
232
+ }
233
+
234
+ // ── Lifecycle ─────────────────────────────────────────────────────
235
+
236
+ connected() {
237
+ super.connected?.();
238
+ this.setAttribute('role', 'region');
239
+ if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
240
+ this.setAttribute('aria-label', 'Invoice history');
241
+ }
242
+
243
+ if (!this.#stamped) {
244
+ this.#absorbInlineInvoicesScript();
245
+ this.#stamp();
246
+ this.#wireListeners();
247
+ this.#stamped = true;
248
+ }
249
+
250
+ untracked(() => this.render());
251
+
252
+ if (this.getAttribute('data-stream-src')) {
253
+ this.refresh();
254
+ }
255
+ }
256
+
257
+ disconnected() {
258
+ super.disconnected?.();
259
+ for (const fn of this.#cleanups) {
260
+ try { fn(); } catch { /* ignore stale */ }
261
+ }
262
+ this.#cleanups.length = 0;
263
+ }
264
+
265
+ attributeChangedCallback(name, oldVal, newVal) {
266
+ super.attributeChangedCallback?.(name, oldVal, newVal);
267
+ if (!this.#stamped || this.#internalUpdate) return;
268
+ if (name === 'data-stream-src' && oldVal !== newVal && newVal) {
269
+ this.refresh();
270
+ }
271
+ }
272
+
273
+ // ── Render — narrow; mutates per-region state ─────────────────────
274
+
275
+ render() {
276
+ if (!this.#stamped) return;
277
+
278
+ const list = Array.isArray(this.invoices) ? this.invoices : [];
279
+
280
+ // Empty / non-empty state attribute (CSS-gated reveal).
281
+ if (list.length === 0 && !this.loading) {
282
+ this.setAttribute('empty', '');
283
+ } else {
284
+ this.removeAttribute('empty');
285
+ }
286
+
287
+ // Refresh filter chip selection.
288
+ this.#renderFilterChips();
289
+
290
+ // Push columns + filtered data to the underlying table.
291
+ if (this.#table) {
292
+ this.#table.columns = this.#deriveColumns();
293
+ this.#table.data = this.#visibleInvoices();
294
+ // Forward forwarding-only props. Reading a signal-backed property on
295
+ // the CHILD (table-ui's own `density`/`loading`/`paginate`/`selectable`)
296
+ // while THIS element's own render effect is tracking would silently
297
+ // subscribe invoice-history's effect to table-ui's internal signals —
298
+ // this composite is a one-way source of truth for these forwarded
299
+ // props and must never react to the table's copies (gh#661). untracked()
300
+ // keeps the compare-before-write idempotent without leaking that
301
+ // cross-element subscription; invoice-detail.class.js avoids the same
302
+ // hazard by comparing via getAttribute() instead.
303
+ untracked(() => {
304
+ if (this.#table.density !== this.density) this.#table.density = this.density;
305
+ if (!!this.#table.loading !== !!this.loading) this.#table.loading = !!this.loading;
306
+ if (Number(this.#table.paginate || 0) !== Number(this.paginate || 0)) {
307
+ this.#table.paginate = Number(this.paginate || 0);
308
+ }
309
+ if (!!this.#table.selectable !== !!this.selectable) this.#table.selectable = !!this.selectable;
310
+ });
311
+ }
312
+
313
+ // Toolbar count: forward visible row count to default toolbar.
314
+ if (this.#defaultToolbar) {
315
+ this.#defaultToolbar.setAttribute('count', String(this.#visibleInvoices().length));
316
+ }
317
+
318
+ // Export button visibility tracks `exportable`.
319
+ if (this.#exportBtn) {
320
+ this.#exportBtn.hidden = !this.exportable;
321
+ }
322
+ }
323
+
324
+ // ── Internal — DOM stamping ───────────────────────────────────────
325
+
326
+ #absorbInlineInvoicesScript() {
327
+ const script = this.querySelector(':scope > script[type="application/json"][data-invoices]');
328
+ if (!script) return;
329
+ try {
330
+ const parsed = JSON.parse(script.textContent || '[]');
331
+ if (Array.isArray(parsed)) this.invoices = parsed;
332
+ } catch (err) {
333
+ // eslint-disable-next-line no-console
334
+ console.warn('[invoice-history-ui] failed to parse <script data-invoices> JSON:', err?.message);
335
+ } finally {
336
+ script.remove();
337
+ }
338
+ }
339
+
340
+ #stamp() {
341
+ // ── Toolbar region ─────────────────────────────────────────────
342
+ const toolbar = document.createElement('div');
343
+ toolbar.setAttribute('data-toolbar', '');
344
+ this.appendChild(toolbar);
345
+ this.#toolbarHost = toolbar;
346
+
347
+ const userToolbar = this.querySelector(':scope > [slot="toolbar"]');
348
+ if (userToolbar) {
349
+ toolbar.appendChild(userToolbar);
350
+ } else {
351
+ // Default: <table-toolbar-ui> with title + count + filter chips +
352
+ // optional export button. The toolbar is wired by [for] when the
353
+ // table is stamped below.
354
+ const tt = document.createElement('table-toolbar-ui');
355
+ tt.setAttribute('data-default-toolbar', '');
356
+ tt.setAttribute('text', 'Invoices');
357
+ // Status-filter chips live in the [slot="actions"] region so they
358
+ // sit alongside the toolbar's title + search affordances.
359
+ const chips = document.createElement('div');
360
+ chips.setAttribute('data-invoice-filter', '');
361
+ chips.setAttribute('role', 'group');
362
+ chips.setAttribute('aria-label', 'Filter invoices by status');
363
+ chips.setAttribute('slot', 'actions');
364
+ tt.appendChild(chips);
365
+
366
+ const btn = document.createElement('button-ui');
367
+ btn.setAttribute('data-export', '');
368
+ btn.setAttribute('slot', 'actions');
369
+ btn.setAttribute('variant', 'outline');
370
+ btn.setAttribute('size', 'sm');
371
+ btn.setAttribute('text', 'Export CSV');
372
+ btn.setAttribute('icon', 'download-simple');
373
+ tt.appendChild(btn);
374
+
375
+ toolbar.appendChild(tt);
376
+ this.#defaultToolbar = tt;
377
+ this.#filterChipsHost = chips;
378
+ this.#exportBtn = btn;
379
+ }
380
+
381
+ // ── Table region ───────────────────────────────────────────────
382
+ const tableHost = document.createElement('div');
383
+ tableHost.setAttribute('data-table-host', '');
384
+ this.appendChild(tableHost);
385
+ this.#tableHost = tableHost;
386
+
387
+ const table = document.createElement('table-ui');
388
+ table.id = this.id ? `${this.id}__table` : `invoice-history-${Math.random().toString(36).slice(2, 8)}`;
389
+ table.setAttribute('data-invoice-table', '');
390
+ table.setAttribute('sortable', '');
391
+ tableHost.appendChild(table);
392
+ this.#table = table;
393
+
394
+ // Wire the default toolbar to this table by id.
395
+ if (this.#defaultToolbar) {
396
+ this.#defaultToolbar.setAttribute('for', table.id);
397
+ }
398
+
399
+ // ── Empty-state region ─────────────────────────────────────────
400
+ if (!this.querySelector(':scope > [slot="empty"]')) {
401
+ const empty = document.createElement('empty-state-ui');
402
+ empty.setAttribute('data-empty', '');
403
+ empty.setAttribute('icon', 'receipt');
404
+ empty.setAttribute('heading', 'No invoices yet');
405
+ empty.setAttribute('description', 'Invoices will appear here once issued.');
406
+ this.appendChild(empty);
407
+ this.#emptyHost = empty;
408
+ }
409
+ }
410
+
411
+ #renderFilterChips() {
412
+ if (!this.#filterChipsHost) return;
413
+ if (this.#filterChipsHost.children.length === 0) {
414
+ for (const chip of FILTER_CHIPS) {
415
+ const btn = document.createElement('button-ui');
416
+ btn.setAttribute('data-filter-chip', '');
417
+ btn.setAttribute('data-filter-value', chip.value);
418
+ btn.setAttribute('size', 'sm');
419
+ btn.setAttribute('text', chip.label);
420
+ this.#filterChipsHost.appendChild(btn);
421
+ }
422
+ }
423
+ // Sync `variant` to active state.
424
+ const active = this.filter || '';
425
+ for (const btn of this.#filterChipsHost.querySelectorAll('[data-filter-chip]')) {
426
+ const value = btn.getAttribute('data-filter-value') || '';
427
+ if (value === active) {
428
+ btn.setAttribute('variant', 'primary');
429
+ btn.setAttribute('aria-pressed', 'true');
430
+ } else {
431
+ btn.setAttribute('variant', 'ghost');
432
+ btn.setAttribute('aria-pressed', 'false');
433
+ }
434
+ }
435
+ }
436
+
437
+ // ── Derivation helpers ─────────────────────────────────────────────
438
+
439
+ #visibleInvoices() {
440
+ const list = Array.isArray(this.invoices) ? this.invoices : [];
441
+ const active = this.filter || '';
442
+ if (!active) return list;
443
+ return list.filter((inv) => (inv?.status || '') === active);
444
+ }
445
+
446
+ #deriveColumns() {
447
+ const locale = this.locale || (typeof document !== 'undefined' ? (document.documentElement?.lang || '') : '');
448
+ const defaultCurrency = this.currency || 'USD';
449
+
450
+ return [
451
+ {
452
+ key: 'number',
453
+ label: 'Invoice',
454
+ sortable: true,
455
+ wrap: false,
456
+ },
457
+ {
458
+ key: 'issuedAt',
459
+ label: 'Issued',
460
+ sortable: true,
461
+ format: (value) => formatDate(value, locale),
462
+ },
463
+ {
464
+ key: 'dueAt',
465
+ label: 'Due',
466
+ sortable: true,
467
+ format: (value) => formatDate(value, locale),
468
+ },
469
+ {
470
+ key: 'amount',
471
+ label: 'Amount',
472
+ sortable: true,
473
+ align: 'end',
474
+ render: (value, row, cell) => {
475
+ const currency = row?.currency || defaultCurrency;
476
+ const span = document.createElement('span');
477
+ span.setAttribute('data-invoice-amount', '');
478
+ span.textContent = formatAmount(value, currency, locale);
479
+ // Cell base alignment cue (also driven by CSS).
480
+ cell.dataset.align = 'end';
481
+ return span;
482
+ },
483
+ format: (value, row) => formatAmount(value, row?.currency || defaultCurrency, locale),
484
+ },
485
+ {
486
+ key: 'status',
487
+ label: 'Status',
488
+ sortable: true,
489
+ render: (value) => {
490
+ const key = String(value || '').toLowerCase();
491
+ const variant = STATUS_TO_VARIANT[key];
492
+ const label = STATUS_LABEL[key] || (value ? String(value) : '—');
493
+ const badge = document.createElement('badge-ui');
494
+ badge.setAttribute('data-invoice-status', '');
495
+ if (variant) {
496
+ badge.setAttribute('variant', variant);
497
+ } else {
498
+ badge.setAttribute('tone', 'muted');
499
+ }
500
+ badge.setAttribute('text', label);
501
+ badge.setAttribute('aria-label', label);
502
+ return badge;
503
+ },
504
+ format: (value) => STATUS_LABEL[String(value || '').toLowerCase()] || (value ? String(value) : ''),
505
+ },
506
+ {
507
+ key: 'actions',
508
+ label: '',
509
+ sortable: false,
510
+ align: 'end',
511
+ accessor: (row) => row,
512
+ render: (_value, row) => {
513
+ const wrap = document.createElement('span');
514
+ wrap.setAttribute('data-invoice-actions', '');
515
+
516
+ const viewBtn = document.createElement('button-ui');
517
+ viewBtn.setAttribute('data-invoice-action', 'view');
518
+ viewBtn.setAttribute('data-invoice-id', String(row?.id ?? row?.number ?? ''));
519
+ viewBtn.setAttribute('size', 'sm');
520
+ viewBtn.setAttribute('variant', 'ghost');
521
+ viewBtn.setAttribute('text', 'View');
522
+ wrap.appendChild(viewBtn);
523
+
524
+ const dlBtn = document.createElement('button-ui');
525
+ dlBtn.setAttribute('data-invoice-action', 'download');
526
+ dlBtn.setAttribute('data-invoice-id', String(row?.id ?? row?.number ?? ''));
527
+ dlBtn.setAttribute('size', 'sm');
528
+ dlBtn.setAttribute('variant', 'ghost');
529
+ dlBtn.setAttribute('icon', 'download-simple');
530
+ dlBtn.setAttribute('aria-label', `Download invoice ${row?.number ?? ''}`);
531
+ wrap.appendChild(dlBtn);
532
+
533
+ return wrap;
534
+ },
535
+ },
536
+ ];
537
+ }
538
+
539
+ // ── Listener wiring ────────────────────────────────────────────────
540
+
541
+ #wireListeners() {
542
+ // ── Filter chip clicks ──
543
+ const onChipClick = (e) => {
544
+ const chip = e.target?.closest?.('[data-filter-chip]');
545
+ if (!chip || !this.#filterChipsHost?.contains(chip)) return;
546
+ e.stopPropagation();
547
+ const value = chip.getAttribute('data-filter-value') || '';
548
+ this.setFilter(value);
549
+ };
550
+ this.addEventListener('press', onChipClick);
551
+ this.addEventListener('click', onChipClick);
552
+ this.#cleanups.push(() => this.removeEventListener('press', onChipClick));
553
+ this.#cleanups.push(() => this.removeEventListener('click', onChipClick));
554
+
555
+ // ── Row activation (cell-click excluding actions column) ──
556
+ const onCellClick = (e) => {
557
+ const detail = e?.detail;
558
+ if (!detail) return;
559
+ // Actions column has its own buttons — don't navigate when clicking them.
560
+ if (detail.key === 'actions') return;
561
+ const row = detail.row;
562
+ if (!row) return;
563
+ const href = resolveHref(this.hrefPattern, row);
564
+ const ev = new CustomEvent('invoice-row-click', {
565
+ bubbles: true,
566
+ cancelable: true,
567
+ detail: { invoice: row, href },
568
+ });
569
+ const proceed = this.dispatchEvent(ev);
570
+ if (proceed && typeof window !== 'undefined' && window.location?.assign) {
571
+ try { window.location.assign(href); } catch { /* test envs */ }
572
+ }
573
+ };
574
+ this.addEventListener('cell-click', onCellClick);
575
+ this.#cleanups.push(() => this.removeEventListener('cell-click', onCellClick));
576
+
577
+ // ── Per-row action buttons ──
578
+ const onActionClick = (e) => {
579
+ const btn = e.target?.closest?.('button-ui[data-invoice-action]');
580
+ if (!btn) return;
581
+ // Ensure we're inside our table region (not the toolbar or empty).
582
+ if (!this.#tableHost?.contains(btn)) return;
583
+ e.stopPropagation();
584
+ const action = btn.getAttribute('data-invoice-action');
585
+ const invoiceId = btn.getAttribute('data-invoice-id') || '';
586
+ const row = (this.invoices || []).find(
587
+ (r) => String(r?.id ?? r?.number ?? '') === invoiceId,
588
+ );
589
+ if (!row) return;
590
+ if (action === 'view') {
591
+ const href = resolveHref(this.hrefPattern, row);
592
+ const ev = new CustomEvent('invoice-row-click', {
593
+ bubbles: true,
594
+ cancelable: true,
595
+ detail: { invoice: row, href },
596
+ });
597
+ const proceed = this.dispatchEvent(ev);
598
+ if (proceed && typeof window !== 'undefined' && window.location?.assign) {
599
+ try { window.location.assign(href); } catch { /* test envs */ }
600
+ }
601
+ } else if (action === 'download') {
602
+ const blob = this.#exportSingleInvoiceCsv(row);
603
+ this.dispatchEvent(new CustomEvent('invoice-export', {
604
+ bubbles: true,
605
+ cancelable: true,
606
+ detail: { blob, rowCount: 1, invoice: row },
607
+ }));
608
+ }
609
+ };
610
+ this.addEventListener('press', onActionClick);
611
+ this.addEventListener('click', onActionClick);
612
+ this.#cleanups.push(() => this.removeEventListener('press', onActionClick));
613
+ this.#cleanups.push(() => this.removeEventListener('click', onActionClick));
614
+
615
+ // ── Export CSV button ──
616
+ const onExport = (e) => {
617
+ const btn = e.target?.closest?.('button-ui[data-export]');
618
+ if (!btn || !this.#toolbarHost?.contains(btn)) return;
619
+ e.stopPropagation();
620
+ const blob = this.exportCsv();
621
+ const ev = new CustomEvent('invoice-export', {
622
+ bubbles: true,
623
+ cancelable: true,
624
+ detail: { blob, rowCount: this.#visibleInvoices().length },
625
+ });
626
+ const proceed = this.dispatchEvent(ev);
627
+ if (proceed && typeof document !== 'undefined' && typeof URL !== 'undefined') {
628
+ try {
629
+ const url = URL.createObjectURL(blob);
630
+ const a = document.createElement('a');
631
+ a.href = url;
632
+ a.download = 'invoices.csv';
633
+ document.body.appendChild(a);
634
+ a.click();
635
+ a.remove();
636
+ setTimeout(() => URL.revokeObjectURL(url), 0);
637
+ } catch { /* download not available */ }
638
+ }
639
+ };
640
+ this.addEventListener('press', onExport);
641
+ this.addEventListener('click', onExport);
642
+ this.#cleanups.push(() => this.removeEventListener('press', onExport));
643
+ this.#cleanups.push(() => this.removeEventListener('click', onExport));
644
+
645
+ // ── Selection forwarding ──
646
+ const onSelect = (e) => {
647
+ if (e.target !== this.#table) return;
648
+ const selected = this.getSelected();
649
+ this.dispatchEvent(new CustomEvent('invoice-selection-change', {
650
+ bubbles: true,
651
+ detail: { selected },
652
+ }));
653
+ };
654
+ this.addEventListener('select', onSelect);
655
+ this.#cleanups.push(() => this.removeEventListener('select', onSelect));
656
+ }
657
+
658
+ #exportSingleInvoiceCsv(inv) {
659
+ const lines = [
660
+ CSV_HEADERS.join(','),
661
+ [
662
+ csvEscape(inv.number ?? ''),
663
+ csvEscape(inv.issuedAt ?? ''),
664
+ csvEscape(inv.dueAt ?? ''),
665
+ csvEscape(Number.isFinite(inv.amount) ? inv.amount : ''),
666
+ csvEscape(inv.currency || this.currency || 'USD'),
667
+ csvEscape(inv.status ?? ''),
668
+ ].join(','),
669
+ ];
670
+ return new Blob([lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
671
+ }
672
+ }