@adia-ai/web-modules 0.8.36 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +3 -3
- 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 +1 -1
- 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 +4 -4
- package/chat/chat-sidebar/chat-sidebar.js +2 -2
- package/chat/chat-surfaces/chat-surfaces.js +2 -2
- 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/everything.min.js +1 -1
- 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,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <notification-preferences-ui> — Module-tier Settings composite.
|
|
3
|
+
*
|
|
4
|
+
* <notification-preferences-ui id="prefs" name="notifications">
|
|
5
|
+
* </notification-preferences-ui>
|
|
6
|
+
* <script>
|
|
7
|
+
* const el = document.querySelector('#prefs');
|
|
8
|
+
* el.channels = [
|
|
9
|
+
* { key: 'in-app', label: 'In-app', icon: 'chat-circle' },
|
|
10
|
+
* { key: 'email', label: 'Email', icon: 'envelope' },
|
|
11
|
+
* { key: 'sms', label: 'SMS', icon: 'device-mobile' }
|
|
12
|
+
* ];
|
|
13
|
+
* el.preferences = [
|
|
14
|
+
* { key: 'mentions', label: 'Mentions',
|
|
15
|
+
* channels: { 'in-app': true, email: true, sms: false } },
|
|
16
|
+
* { key: 'releases', label: 'Releases',
|
|
17
|
+
* channels: { 'in-app': false, email: true, sms: false } }
|
|
18
|
+
* ];
|
|
19
|
+
* el.addEventListener('change', (e) => save(e.detail));
|
|
20
|
+
* el.addEventListener('bulk-toggle', (e) => bulkSave(e.detail));
|
|
21
|
+
* </script>
|
|
22
|
+
*
|
|
23
|
+
* Composes <check-ui> (per-cell binary toggle) + <switch-ui> (column-
|
|
24
|
+
* master master) + <icon-ui> (channel-header icons) + <text-ui> +
|
|
25
|
+
* <empty-state-ui>. Renders as an ARIA grid via role="table"/row/cell/
|
|
26
|
+
* rowheader/columnheader on plain <div> nodes — NOT native <table>
|
|
27
|
+
* (HTML parser strips <thead>/<tbody>/<tr>/<th>/<td> out of custom-
|
|
28
|
+
* element subtrees per FEEDBACK rule).
|
|
29
|
+
*
|
|
30
|
+
* Form participation:
|
|
31
|
+
* Extends UIFormElement. The `value` is a JSON string of the current
|
|
32
|
+
* preferences shape ({preferences, channelEnabled}) serialized via
|
|
33
|
+
* ElementInternals.setFormValue. A host <form> submit carries the
|
|
34
|
+
* matrix through standard form semantics; the composite never mutates
|
|
35
|
+
* the passed-in `preferences` (host-controlled per spec).
|
|
36
|
+
*
|
|
37
|
+
* Architecture (light-DOM, ADR-0033):
|
|
38
|
+
* First-connect stamps a four-region skeleton (header · matrix ·
|
|
39
|
+
* empty · footer). Subsequent renders mutate the matrix subtree by
|
|
40
|
+
* id-keyed in-place diff so focus survives toggling.
|
|
41
|
+
*
|
|
42
|
+
* Per ADR-0027 — composites that programmatically create primitives in
|
|
43
|
+
* their render() do NOT auto-import those primitives. Consumer (or
|
|
44
|
+
* demo shell) must explicitly import <check-ui>, <switch-ui>,
|
|
45
|
+
* <icon-ui>, <text-ui>, <empty-state-ui> in addition to this module.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { UIFormElement } from '@adia-ai/web-components/core/form';
|
|
49
|
+
import { untracked } from '@adia-ai/web-components/core/signals';
|
|
50
|
+
|
|
51
|
+
class UINotificationPreferences extends UIFormElement {
|
|
52
|
+
static get properties() {
|
|
53
|
+
return {
|
|
54
|
+
...UIFormElement.properties,
|
|
55
|
+
preferences: { type: Array, default: [] },
|
|
56
|
+
channels: { type: Array, default: [] },
|
|
57
|
+
channelEnabled: { type: Object, default: {} },
|
|
58
|
+
loading: { type: Boolean, default: false, reflect: true },
|
|
59
|
+
disabled: { type: Boolean, default: false, reflect: true },
|
|
60
|
+
'group-by': { type: String, default: 'none', reflect: true },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Composite stamps a fixed region skeleton once and mutates the
|
|
65
|
+
// matrix subtree across renders. Returning html`` from template()
|
|
66
|
+
// would trigger stamp() → replaceChildren() and wipe authored slot
|
|
67
|
+
// children (same rationale as plan-picker / confirm-dialog).
|
|
68
|
+
static template = () => null;
|
|
69
|
+
|
|
70
|
+
// ── Field refs ────────────────────────────────────────────────────
|
|
71
|
+
#stamped = false;
|
|
72
|
+
#cleanups = [];
|
|
73
|
+
#headerHost = null;
|
|
74
|
+
#matrixHost = null;
|
|
75
|
+
#headerRow = null;
|
|
76
|
+
#bodyHost = null;
|
|
77
|
+
#emptyHost = null;
|
|
78
|
+
#footerHost = null;
|
|
79
|
+
#rowsByKey = new Map(); // row-key → <div role="row">
|
|
80
|
+
#cellsByKey = new Map(); // `${row}::${channel}` → <check-ui>
|
|
81
|
+
#headerCellsByKey = new Map(); // channel-key → <div role="columnheader">
|
|
82
|
+
#masterByKey = new Map(); // channel-key → <switch-ui> column master
|
|
83
|
+
#groupHeadingsByKey = new Map(); // group-key → <div data-part="group-heading">
|
|
84
|
+
#internalUpdate = false;
|
|
85
|
+
#headerId = `np-h-${Math.random().toString(36).slice(2, 8)}`;
|
|
86
|
+
|
|
87
|
+
// ── attribute parsing — preferences / channels / channelEnabled
|
|
88
|
+
// accept JSON via the HTML attribute path (canonical authoring
|
|
89
|
+
// shape for a2ui-generated markup). Default UIElement attribute
|
|
90
|
+
// reflection would assign the string verbatim and break the
|
|
91
|
+
// array/object shape; parse here.
|
|
92
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
93
|
+
if (name === 'preferences' || name === 'channels' || name === 'channelenabled') {
|
|
94
|
+
// Key-cased prop name (preferences, channels, channelEnabled);
|
|
95
|
+
// the attribute is always lower-case so map back.
|
|
96
|
+
const propName = name === 'channelenabled' ? 'channelEnabled' : name;
|
|
97
|
+
if (newVal == null) {
|
|
98
|
+
this[propName] = (propName === 'channelEnabled') ? {} : [];
|
|
99
|
+
} else {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(newVal);
|
|
102
|
+
if (propName === 'channelEnabled') {
|
|
103
|
+
this[propName] = (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
104
|
+
? parsed : {};
|
|
105
|
+
} else {
|
|
106
|
+
this[propName] = Array.isArray(parsed) ? parsed : [];
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
this[propName] = (propName === 'channelEnabled') ? {} : [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
super.attributeChangedCallback?.(name, oldVal, newVal);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Lifecycle ─────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
connected() {
|
|
120
|
+
super.connected();
|
|
121
|
+
|
|
122
|
+
// ARIA landmark + accessible name. Don't steal a host-supplied
|
|
123
|
+
// aria-label; default to a sensible string.
|
|
124
|
+
if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
|
|
125
|
+
this.setAttribute('aria-label', 'Notification preferences');
|
|
126
|
+
}
|
|
127
|
+
this.setAttribute('role', 'region');
|
|
128
|
+
|
|
129
|
+
if (!this.#stamped) {
|
|
130
|
+
// Late-parse JSON attrs that were present at construct time
|
|
131
|
+
// (UIElement reads attributes as String on first connect).
|
|
132
|
+
if (this.hasAttribute('preferences') && !Array.isArray(this.preferences)) {
|
|
133
|
+
try { this.preferences = JSON.parse(this.getAttribute('preferences')); }
|
|
134
|
+
catch { this.preferences = []; }
|
|
135
|
+
}
|
|
136
|
+
if (this.hasAttribute('channels') && !Array.isArray(this.channels)) {
|
|
137
|
+
try { this.channels = JSON.parse(this.getAttribute('channels')); }
|
|
138
|
+
catch { this.channels = []; }
|
|
139
|
+
}
|
|
140
|
+
if (this.hasAttribute('channelenabled') &&
|
|
141
|
+
(typeof this.channelEnabled !== 'object' || Array.isArray(this.channelEnabled))) {
|
|
142
|
+
try { this.channelEnabled = JSON.parse(this.getAttribute('channelenabled')); }
|
|
143
|
+
catch { this.channelEnabled = {}; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.#stamp();
|
|
147
|
+
this.#wireListeners();
|
|
148
|
+
this.#stamped = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Trigger first render after the registry has had a chance to
|
|
152
|
+
// upgrade the composed primitives.
|
|
153
|
+
untracked(() => this.render());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
disconnected() {
|
|
157
|
+
super.disconnected();
|
|
158
|
+
for (const fn of this.#cleanups) {
|
|
159
|
+
try { fn(); } catch { /* ignore stale */ }
|
|
160
|
+
}
|
|
161
|
+
this.#cleanups.length = 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Form participation ────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The form value is the JSON-stringified current shape so a host
|
|
168
|
+
* <form> submit carries the matrix through standard form semantics.
|
|
169
|
+
* `value` is recomputed in render(); we override syncValue to read
|
|
170
|
+
* the live shape rather than the inherited `this.value`.
|
|
171
|
+
*/
|
|
172
|
+
syncValue() {
|
|
173
|
+
const shape = {
|
|
174
|
+
preferences: Array.isArray(this.preferences) ? this.preferences : [],
|
|
175
|
+
channelEnabled: this.channelEnabled && typeof this.channelEnabled === 'object'
|
|
176
|
+
? this.channelEnabled : {},
|
|
177
|
+
};
|
|
178
|
+
const serialized = JSON.stringify(shape);
|
|
179
|
+
this.value = serialized;
|
|
180
|
+
this.internals.setFormValue(serialized);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Render — narrow; mutates matrix subtree only ──────────────────
|
|
184
|
+
|
|
185
|
+
render() {
|
|
186
|
+
if (!this.#stamped) return;
|
|
187
|
+
|
|
188
|
+
const channels = Array.isArray(this.channels) ? this.channels : [];
|
|
189
|
+
const prefs = Array.isArray(this.preferences) ? this.preferences : [];
|
|
190
|
+
|
|
191
|
+
// Empty-state attribute (CSS-gated reveal).
|
|
192
|
+
if (prefs.length === 0 || channels.length === 0) {
|
|
193
|
+
this.setAttribute('empty', '');
|
|
194
|
+
} else {
|
|
195
|
+
this.removeAttribute('empty');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// CSS column-template uses --np-columns; set it on the host so
|
|
199
|
+
// the grid-template-columns expansion is data-driven.
|
|
200
|
+
this.style.setProperty(
|
|
201
|
+
'--np-columns',
|
|
202
|
+
`repeat(${Math.max(channels.length, 1)}, minmax(var(--notification-preferences-channel-width, var(--notification-preferences-channel-width-default)), auto))`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Re-render the column headers (header row + column-masters).
|
|
206
|
+
this.#renderHeaderRow(channels);
|
|
207
|
+
|
|
208
|
+
// Re-render the body — grouped or flat.
|
|
209
|
+
const groupBy = this['group-by'] || 'none';
|
|
210
|
+
if (groupBy === 'group') {
|
|
211
|
+
this.#renderGroupedBody(prefs, channels);
|
|
212
|
+
} else {
|
|
213
|
+
this.#renderFlatBody(prefs, channels);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Sync form value with current shape.
|
|
217
|
+
this.syncValue();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Internal — DOM stamping ───────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
#stamp() {
|
|
223
|
+
// Header slot host (consumer-supplied content above the matrix).
|
|
224
|
+
const header = document.createElement('div');
|
|
225
|
+
header.setAttribute('data-part', 'header');
|
|
226
|
+
const headerSlot = document.createElement('slot');
|
|
227
|
+
headerSlot.setAttribute('name', 'header');
|
|
228
|
+
header.appendChild(headerSlot);
|
|
229
|
+
this.appendChild(header);
|
|
230
|
+
this.#headerHost = header;
|
|
231
|
+
|
|
232
|
+
// Matrix host — the ARIA grid container.
|
|
233
|
+
const matrix = document.createElement('div');
|
|
234
|
+
matrix.setAttribute('data-part', 'matrix');
|
|
235
|
+
matrix.setAttribute('role', 'table');
|
|
236
|
+
matrix.setAttribute('aria-labelledby', this.#headerId);
|
|
237
|
+
this.appendChild(matrix);
|
|
238
|
+
this.#matrixHost = matrix;
|
|
239
|
+
|
|
240
|
+
// Column-header row — separate `data-part` so CSS can style it
|
|
241
|
+
// distinctly from body rows (no zebra).
|
|
242
|
+
const headerRow = document.createElement('div');
|
|
243
|
+
headerRow.setAttribute('data-part', 'header-row');
|
|
244
|
+
headerRow.setAttribute('role', 'row');
|
|
245
|
+
matrix.appendChild(headerRow);
|
|
246
|
+
this.#headerRow = headerRow;
|
|
247
|
+
|
|
248
|
+
// Body wrapper (zebra stripes target [data-part="body"] > [role="row"]).
|
|
249
|
+
const body = document.createElement('div');
|
|
250
|
+
body.setAttribute('data-part', 'body');
|
|
251
|
+
matrix.appendChild(body);
|
|
252
|
+
this.#bodyHost = body;
|
|
253
|
+
|
|
254
|
+
// Default empty-state (overridable via slot="empty").
|
|
255
|
+
const empty = document.createElement('div');
|
|
256
|
+
empty.setAttribute('data-part', 'empty');
|
|
257
|
+
const emptySlot = document.createElement('slot');
|
|
258
|
+
emptySlot.setAttribute('name', 'empty');
|
|
259
|
+
// Fallback content if no consumer slot content is provided.
|
|
260
|
+
const fallback = document.createElement('empty-state-ui');
|
|
261
|
+
fallback.setAttribute('icon', 'bell');
|
|
262
|
+
fallback.setAttribute('heading', 'No notification types configured');
|
|
263
|
+
fallback.setAttribute(
|
|
264
|
+
'description',
|
|
265
|
+
'Add at least one notification type and one delivery channel to begin.',
|
|
266
|
+
);
|
|
267
|
+
emptySlot.appendChild(fallback);
|
|
268
|
+
empty.appendChild(emptySlot);
|
|
269
|
+
this.appendChild(empty);
|
|
270
|
+
this.#emptyHost = empty;
|
|
271
|
+
|
|
272
|
+
// Footer slot host (consumer-supplied content below the matrix).
|
|
273
|
+
const footer = document.createElement('div');
|
|
274
|
+
footer.setAttribute('data-part', 'footer');
|
|
275
|
+
const footerSlot = document.createElement('slot');
|
|
276
|
+
footerSlot.setAttribute('name', 'footer');
|
|
277
|
+
footer.appendChild(footerSlot);
|
|
278
|
+
this.appendChild(footer);
|
|
279
|
+
this.#footerHost = footer;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Internal — render header row (column headers) ─────────────────
|
|
283
|
+
|
|
284
|
+
#renderHeaderRow(channels) {
|
|
285
|
+
if (!this.#headerRow) return;
|
|
286
|
+
|
|
287
|
+
// First cell — empty row-label spacer (rowheader column).
|
|
288
|
+
let labelCell = this.#headerRow.querySelector(':scope > [data-row-label]');
|
|
289
|
+
if (!labelCell) {
|
|
290
|
+
labelCell = document.createElement('div');
|
|
291
|
+
labelCell.setAttribute('role', 'columnheader');
|
|
292
|
+
labelCell.setAttribute('data-row-label', '');
|
|
293
|
+
labelCell.id = this.#headerId;
|
|
294
|
+
labelCell.textContent = 'Notification';
|
|
295
|
+
this.#headerRow.appendChild(labelCell);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Diff channel header cells by key.
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
for (const channel of channels) {
|
|
301
|
+
if (!channel?.key) continue;
|
|
302
|
+
seen.add(channel.key);
|
|
303
|
+
let cell = this.#headerCellsByKey.get(channel.key);
|
|
304
|
+
if (!cell) {
|
|
305
|
+
cell = document.createElement('div');
|
|
306
|
+
cell.setAttribute('role', 'columnheader');
|
|
307
|
+
cell.setAttribute('data-channel-key', channel.key);
|
|
308
|
+
this.#headerRow.appendChild(cell);
|
|
309
|
+
this.#headerCellsByKey.set(channel.key, cell);
|
|
310
|
+
}
|
|
311
|
+
this.#decorateHeaderCell(cell, channel);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Remove dropped header cells.
|
|
315
|
+
for (const [key, cell] of this.#headerCellsByKey) {
|
|
316
|
+
if (!seen.has(key)) {
|
|
317
|
+
try { cell.remove(); } catch { /* ignore */ }
|
|
318
|
+
this.#headerCellsByKey.delete(key);
|
|
319
|
+
this.#masterByKey.delete(key);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Re-order header cells to match channels array.
|
|
324
|
+
for (const channel of channels) {
|
|
325
|
+
const cell = this.#headerCellsByKey.get(channel.key);
|
|
326
|
+
if (cell) this.#headerRow.appendChild(cell);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#decorateHeaderCell(cell, channel) {
|
|
331
|
+
// Wipe + restamp the small fixed shape (icon? + label + switch).
|
|
332
|
+
cell.replaceChildren();
|
|
333
|
+
|
|
334
|
+
if (channel.icon) {
|
|
335
|
+
const icon = document.createElement('icon-ui');
|
|
336
|
+
icon.setAttribute('name', String(channel.icon));
|
|
337
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
338
|
+
cell.appendChild(icon);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const label = document.createElement('span');
|
|
342
|
+
label.setAttribute('data-channel-label', '');
|
|
343
|
+
label.textContent = String(channel.label || channel.key);
|
|
344
|
+
cell.appendChild(label);
|
|
345
|
+
|
|
346
|
+
// Column master <switch-ui>. The default master state is `true`
|
|
347
|
+
// unless the host explicitly set `channelEnabled[channel.key]`
|
|
348
|
+
// to false.
|
|
349
|
+
let master = this.#masterByKey.get(channel.key);
|
|
350
|
+
if (!master) {
|
|
351
|
+
master = document.createElement('switch-ui');
|
|
352
|
+
master.setAttribute('size', 'sm');
|
|
353
|
+
master.setAttribute('data-channel-master', channel.key);
|
|
354
|
+
this.#masterByKey.set(channel.key, master);
|
|
355
|
+
}
|
|
356
|
+
const enabledMap = this.channelEnabled && typeof this.channelEnabled === 'object'
|
|
357
|
+
? this.channelEnabled : {};
|
|
358
|
+
const masterChecked = Object.prototype.hasOwnProperty.call(enabledMap, channel.key)
|
|
359
|
+
? !!enabledMap[channel.key]
|
|
360
|
+
: true;
|
|
361
|
+
if (master.checked !== masterChecked) {
|
|
362
|
+
this.#internalUpdate = true;
|
|
363
|
+
try { master.checked = masterChecked; }
|
|
364
|
+
finally { this.#internalUpdate = false; }
|
|
365
|
+
}
|
|
366
|
+
master.setAttribute(
|
|
367
|
+
'aria-label',
|
|
368
|
+
`Toggle ${channel.label || channel.key} for all rows`,
|
|
369
|
+
);
|
|
370
|
+
if (this.disabled || this.loading) {
|
|
371
|
+
master.setAttribute('disabled', '');
|
|
372
|
+
} else {
|
|
373
|
+
master.removeAttribute('disabled');
|
|
374
|
+
}
|
|
375
|
+
cell.appendChild(master);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Internal — render flat body (group-by="none") ─────────────────
|
|
379
|
+
|
|
380
|
+
#renderFlatBody(prefs, channels) {
|
|
381
|
+
if (!this.#bodyHost) return;
|
|
382
|
+
|
|
383
|
+
// Remove any group-heading rows from a prior grouped render.
|
|
384
|
+
for (const [, heading] of this.#groupHeadingsByKey) {
|
|
385
|
+
try { heading.remove(); } catch { /* ignore */ }
|
|
386
|
+
}
|
|
387
|
+
this.#groupHeadingsByKey.clear();
|
|
388
|
+
|
|
389
|
+
const seenRows = new Set();
|
|
390
|
+
const seenCells = new Set();
|
|
391
|
+
|
|
392
|
+
for (const pref of prefs) {
|
|
393
|
+
if (!pref?.key) continue;
|
|
394
|
+
seenRows.add(pref.key);
|
|
395
|
+
const row = this.#getOrCreateRow(pref);
|
|
396
|
+
this.#decorateRow(row, pref, channels, seenCells);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Remove dropped rows.
|
|
400
|
+
for (const [key, row] of this.#rowsByKey) {
|
|
401
|
+
if (!seenRows.has(key)) {
|
|
402
|
+
try { row.remove(); } catch { /* ignore */ }
|
|
403
|
+
this.#rowsByKey.delete(key);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Remove dropped cells.
|
|
408
|
+
for (const [key, cell] of this.#cellsByKey) {
|
|
409
|
+
if (!seenCells.has(key)) {
|
|
410
|
+
try { cell.remove(); } catch { /* ignore */ }
|
|
411
|
+
this.#cellsByKey.delete(key);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Re-order rows to match preferences array.
|
|
416
|
+
for (const pref of prefs) {
|
|
417
|
+
const row = this.#rowsByKey.get(pref.key);
|
|
418
|
+
if (row) this.#bodyHost.appendChild(row);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Internal — render grouped body (group-by="group") ─────────────
|
|
423
|
+
|
|
424
|
+
#renderGroupedBody(prefs, channels) {
|
|
425
|
+
if (!this.#bodyHost) return;
|
|
426
|
+
|
|
427
|
+
// Bucket rows by `group` field (preserve first-seen order).
|
|
428
|
+
const order = [];
|
|
429
|
+
const byGroup = new Map();
|
|
430
|
+
for (const pref of prefs) {
|
|
431
|
+
const g = String(pref?.group ?? 'Other') || 'Other';
|
|
432
|
+
if (!byGroup.has(g)) {
|
|
433
|
+
byGroup.set(g, []);
|
|
434
|
+
order.push(g);
|
|
435
|
+
}
|
|
436
|
+
byGroup.get(g).push(pref);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const seenGroups = new Set();
|
|
440
|
+
const seenRows = new Set();
|
|
441
|
+
const seenCells = new Set();
|
|
442
|
+
|
|
443
|
+
for (const groupKey of order) {
|
|
444
|
+
seenGroups.add(groupKey);
|
|
445
|
+
// Group heading row.
|
|
446
|
+
let heading = this.#groupHeadingsByKey.get(groupKey);
|
|
447
|
+
if (!heading) {
|
|
448
|
+
heading = document.createElement('div');
|
|
449
|
+
heading.setAttribute('data-part', 'group-heading');
|
|
450
|
+
heading.setAttribute('data-group', groupKey);
|
|
451
|
+
heading.setAttribute('role', 'rowgroup');
|
|
452
|
+
this.#groupHeadingsByKey.set(groupKey, heading);
|
|
453
|
+
}
|
|
454
|
+
heading.textContent = groupKey;
|
|
455
|
+
this.#bodyHost.appendChild(heading);
|
|
456
|
+
|
|
457
|
+
for (const pref of byGroup.get(groupKey)) {
|
|
458
|
+
if (!pref?.key) continue;
|
|
459
|
+
seenRows.add(pref.key);
|
|
460
|
+
const row = this.#getOrCreateRow(pref);
|
|
461
|
+
this.#decorateRow(row, pref, channels, seenCells);
|
|
462
|
+
this.#bodyHost.appendChild(row);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Remove dropped rows / cells / group headings.
|
|
467
|
+
for (const [key, row] of this.#rowsByKey) {
|
|
468
|
+
if (!seenRows.has(key)) {
|
|
469
|
+
try { row.remove(); } catch { /* ignore */ }
|
|
470
|
+
this.#rowsByKey.delete(key);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
for (const [key, cell] of this.#cellsByKey) {
|
|
474
|
+
if (!seenCells.has(key)) {
|
|
475
|
+
try { cell.remove(); } catch { /* ignore */ }
|
|
476
|
+
this.#cellsByKey.delete(key);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const [key, heading] of this.#groupHeadingsByKey) {
|
|
480
|
+
if (!seenGroups.has(key)) {
|
|
481
|
+
try { heading.remove(); } catch { /* ignore */ }
|
|
482
|
+
this.#groupHeadingsByKey.delete(key);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Internal — per-row helpers ────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
#getOrCreateRow(pref) {
|
|
490
|
+
let row = this.#rowsByKey.get(pref.key);
|
|
491
|
+
if (!row) {
|
|
492
|
+
row = document.createElement('div');
|
|
493
|
+
row.setAttribute('role', 'row');
|
|
494
|
+
row.setAttribute('data-row-key', pref.key);
|
|
495
|
+
this.#bodyHost.appendChild(row);
|
|
496
|
+
this.#rowsByKey.set(pref.key, row);
|
|
497
|
+
}
|
|
498
|
+
return row;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#decorateRow(row, pref, channels, seenCells) {
|
|
502
|
+
// Row header (notification-type label cell).
|
|
503
|
+
let rowHeader = row.querySelector(':scope > [role="rowheader"]');
|
|
504
|
+
if (!rowHeader) {
|
|
505
|
+
rowHeader = document.createElement('div');
|
|
506
|
+
rowHeader.setAttribute('role', 'rowheader');
|
|
507
|
+
row.appendChild(rowHeader);
|
|
508
|
+
}
|
|
509
|
+
rowHeader.replaceChildren();
|
|
510
|
+
|
|
511
|
+
const labelText = document.createElement('span');
|
|
512
|
+
labelText.setAttribute('data-row-label-text', '');
|
|
513
|
+
labelText.textContent = String(pref.label || pref.key);
|
|
514
|
+
rowHeader.appendChild(labelText);
|
|
515
|
+
|
|
516
|
+
if (pref.description) {
|
|
517
|
+
const desc = document.createElement('span');
|
|
518
|
+
desc.setAttribute('data-row-description', '');
|
|
519
|
+
desc.textContent = String(pref.description);
|
|
520
|
+
rowHeader.appendChild(desc);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Cells — one <check-ui> per channel.
|
|
524
|
+
const cellMap = (pref.channels && typeof pref.channels === 'object') ? pref.channels : {};
|
|
525
|
+
|
|
526
|
+
for (const channel of channels) {
|
|
527
|
+
if (!channel?.key) continue;
|
|
528
|
+
const cellKey = `${pref.key}::${channel.key}`;
|
|
529
|
+
seenCells.add(cellKey);
|
|
530
|
+
|
|
531
|
+
let cell = row.querySelector(`:scope > [data-cell-key="${CSS.escape(cellKey)}"]`);
|
|
532
|
+
if (!cell) {
|
|
533
|
+
cell = document.createElement('div');
|
|
534
|
+
cell.setAttribute('role', 'cell');
|
|
535
|
+
cell.setAttribute('data-cell-key', cellKey);
|
|
536
|
+
row.appendChild(cell);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let check = this.#cellsByKey.get(cellKey);
|
|
540
|
+
if (!check) {
|
|
541
|
+
check = document.createElement('check-ui');
|
|
542
|
+
check.setAttribute('data-row-key', pref.key);
|
|
543
|
+
check.setAttribute('data-channel-key', channel.key);
|
|
544
|
+
cell.appendChild(check);
|
|
545
|
+
this.#cellsByKey.set(cellKey, check);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const checked = !!cellMap[channel.key];
|
|
549
|
+
if (check.checked !== checked) {
|
|
550
|
+
this.#internalUpdate = true;
|
|
551
|
+
try { check.checked = checked; }
|
|
552
|
+
finally { this.#internalUpdate = false; }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
check.setAttribute(
|
|
556
|
+
'aria-label',
|
|
557
|
+
`${pref.label || pref.key} via ${channel.label || channel.key}`,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const cellDisabled = !!this.disabled || !!this.loading || !!pref.disabled;
|
|
561
|
+
if (cellDisabled) check.setAttribute('disabled', '');
|
|
562
|
+
else check.removeAttribute('disabled');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Re-order cells to match channels array (right of rowheader).
|
|
566
|
+
for (const channel of channels) {
|
|
567
|
+
const cellKey = `${pref.key}::${channel.key}`;
|
|
568
|
+
const cell = row.querySelector(`:scope > [data-cell-key="${CSS.escape(cellKey)}"]`);
|
|
569
|
+
if (cell) row.appendChild(cell);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Internal — event delegation ───────────────────────────────────
|
|
574
|
+
|
|
575
|
+
#wireListeners() {
|
|
576
|
+
const onChange = (e) => {
|
|
577
|
+
if (this.#internalUpdate) return;
|
|
578
|
+
const target = e?.target;
|
|
579
|
+
if (!target) return;
|
|
580
|
+
|
|
581
|
+
// ── Column master <switch-ui> bulk toggle ──
|
|
582
|
+
const master = target.closest?.('switch-ui[data-channel-master]');
|
|
583
|
+
if (master && this.#matrixHost.contains(master)) {
|
|
584
|
+
const channelKey = master.getAttribute('data-channel-master') || '';
|
|
585
|
+
if (!channelKey) return;
|
|
586
|
+
if (this.disabled || this.loading) return;
|
|
587
|
+
e.stopPropagation();
|
|
588
|
+
const enabled = !!master.checked;
|
|
589
|
+
this.dispatchEvent(new CustomEvent('bulk-toggle', {
|
|
590
|
+
bubbles: true,
|
|
591
|
+
detail: { channel: channelKey, enabled },
|
|
592
|
+
}));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── Per-cell <check-ui> toggle ──
|
|
597
|
+
const check = target.closest?.('check-ui[data-row-key][data-channel-key]');
|
|
598
|
+
if (check && this.#bodyHost.contains(check)) {
|
|
599
|
+
const rowKey = check.getAttribute('data-row-key') || '';
|
|
600
|
+
const channelKey = check.getAttribute('data-channel-key') || '';
|
|
601
|
+
if (!rowKey || !channelKey) return;
|
|
602
|
+
if (this.disabled || this.loading) return;
|
|
603
|
+
e.stopPropagation();
|
|
604
|
+
const enabled = !!check.checked;
|
|
605
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
606
|
+
bubbles: true,
|
|
607
|
+
detail: { row: rowKey, channel: channelKey, enabled },
|
|
608
|
+
}));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
this.#matrixHost.addEventListener('change', onChange);
|
|
614
|
+
this.#cleanups.push(() => this.#matrixHost.removeEventListener('change', onChange));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (!customElements.get('notification-preferences-ui')) {
|
|
619
|
+
customElements.define('notification-preferences-ui', UINotificationPreferences);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export { UINotificationPreferences };
|