@tillsc/progressive-web-components 0.1.0

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.
@@ -0,0 +1,365 @@
1
+ // src/core/pwc-element.js
2
+ var PwcElement = class extends HTMLElement {
3
+ /**
4
+ * List of DOM event types to bind on the host element.
5
+ * Subclasses may override.
6
+ *
7
+ * Example:
8
+ * static events = ["click", "input"];
9
+ */
10
+ static events = [];
11
+ static registerCss(cssText) {
12
+ const sheet = new CSSStyleSheet();
13
+ sheet.replaceSync(cssText);
14
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
15
+ }
16
+ connectedCallback() {
17
+ if (this._connected) return;
18
+ this._connected = true;
19
+ this._bindEvents();
20
+ }
21
+ disconnectedCallback() {
22
+ if (!this._connected) return;
23
+ this._connected = false;
24
+ this._unbindEvents();
25
+ this.onDisconnect();
26
+ }
27
+ /**
28
+ * Optional cleanup hook for subclasses.
29
+ */
30
+ onDisconnect() {
31
+ }
32
+ /**
33
+ * Bind declared events using the handleEvent pattern.
34
+ */
35
+ _bindEvents() {
36
+ const events = this.constructor.events ?? [];
37
+ for (const type of events) {
38
+ this.addEventListener(type, this);
39
+ }
40
+ }
41
+ /**
42
+ * Unbind all previously declared events.
43
+ */
44
+ _unbindEvents() {
45
+ const events = this.constructor.events ?? [];
46
+ for (const type of events) {
47
+ this.removeEventListener(type, this);
48
+ }
49
+ }
50
+ /**
51
+ * Default event handler.
52
+ * Subclasses are expected to override this method
53
+ * and route events as needed.
54
+ */
55
+ handleEvent(_event) {
56
+ }
57
+ };
58
+
59
+ // src/core/pwc-children-observer-element.js
60
+ var PwcChildrenObserverElement = class extends PwcElement {
61
+ static observeMode = "children";
62
+ // "children" | "tree"
63
+ connectedCallback() {
64
+ if (this._connected) return;
65
+ super.connectedCallback();
66
+ this._startChildrenObserver();
67
+ }
68
+ disconnectedCallback() {
69
+ this._stopChildrenObserver();
70
+ super.disconnectedCallback();
71
+ }
72
+ onChildrenChanged(_mutations) {
73
+ }
74
+ /** Run fn() without triggering onChildrenChanged for the resulting DOM mutations. */
75
+ _withoutChildrenChangedNotification(fn) {
76
+ fn();
77
+ this._childrenObserver?.takeRecords();
78
+ }
79
+ _startChildrenObserver() {
80
+ const mode = this.constructor.observeMode || "children";
81
+ const subtree = mode === "tree";
82
+ this._childrenObserver = new MutationObserver((mutations) => {
83
+ if (!this._connected) return;
84
+ this.onChildrenChanged(mutations);
85
+ });
86
+ this._childrenObserver.observe(this, { childList: true, subtree });
87
+ this.onChildrenChanged([]);
88
+ }
89
+ _stopChildrenObserver() {
90
+ if (!this._childrenObserver) return;
91
+ this._childrenObserver.disconnect();
92
+ this._childrenObserver = null;
93
+ }
94
+ };
95
+
96
+ // src/multiselect-dual-list/base.js
97
+ var MultiselectDualListBase = class extends PwcChildrenObserverElement {
98
+ static observeMode = "tree";
99
+ static events = ["click", "input"];
100
+ get _selectedClass() {
101
+ return "pwc-msdl-item--selected";
102
+ }
103
+ onChildrenChanged() {
104
+ const select = this.querySelector("select");
105
+ if (!select) return;
106
+ this._select = select;
107
+ const items = this._parseOptions(select);
108
+ this._items = items;
109
+ this._itemsByValue = new Map(items.map((item) => [item.value, item]));
110
+ this._withoutChildrenChangedNotification(() => {
111
+ if (!this._availableList) {
112
+ const ui = this._buildUI();
113
+ this._availableList = ui.availableList;
114
+ this._selectedList = ui.selectedList;
115
+ this._filterInput = ui.filterInput;
116
+ }
117
+ this._populateLists(items);
118
+ select.style.display = "none";
119
+ const filterText = this._filterInput?.value;
120
+ if (filterText) this._applyFilter(filterText);
121
+ });
122
+ }
123
+ _populateLists(items) {
124
+ this._availableList.replaceChildren();
125
+ this._selectedList.replaceChildren();
126
+ for (const item of items) {
127
+ this._availableList.appendChild(this._createAvailableEntry(item));
128
+ }
129
+ for (const item of items) {
130
+ if (item.selected) {
131
+ this._selectedList.appendChild(this._createSelectedEntry(item));
132
+ }
133
+ }
134
+ }
135
+ _parseOptions(select) {
136
+ const options = Array.from(select.options);
137
+ const parentMap = /* @__PURE__ */ new Map();
138
+ for (const opt of options) {
139
+ const parent = opt.dataset.parent;
140
+ if (parent) parentMap.set(opt.value, parent);
141
+ }
142
+ return options.map((opt) => ({
143
+ value: opt.value,
144
+ label: opt.textContent,
145
+ parent: opt.dataset.parent || null,
146
+ depth: this._calculateDepth(opt.value, parentMap),
147
+ selected: opt.selected,
148
+ disabled: opt.disabled,
149
+ warnOnUnselect: opt.dataset.warnOnUnselect || null
150
+ }));
151
+ }
152
+ _calculateDepth(value, parentMap) {
153
+ let depth = 0;
154
+ let current = value;
155
+ const visited = /* @__PURE__ */ new Set();
156
+ while (parentMap.has(current)) {
157
+ if (visited.has(current)) break;
158
+ visited.add(current);
159
+ current = parentMap.get(current);
160
+ depth++;
161
+ }
162
+ return depth;
163
+ }
164
+ handleEvent(e) {
165
+ if (e.type === "click") {
166
+ const actionEl = e.target.closest("[data-action]");
167
+ if (!actionEl || !this.contains(actionEl)) return;
168
+ const action = actionEl.dataset.action;
169
+ const value = actionEl.closest("[data-value]")?.dataset.value;
170
+ if (!value) return;
171
+ if (action === "add") this._addItem(value);
172
+ else if (action === "remove") this._removeItem(value);
173
+ return;
174
+ }
175
+ if (e.type === "input") {
176
+ if (this._filterInput && e.target === this._filterInput) {
177
+ this._applyFilter(this._filterInput.value);
178
+ }
179
+ }
180
+ }
181
+ _addItem(value) {
182
+ const item = this._itemsByValue.get(value);
183
+ if (!item || item.disabled) return;
184
+ if (!this.select.hasAttribute("multiple")) {
185
+ for (const opt2 of this._select.options) {
186
+ if (opt2.selected) opt2.selected = false;
187
+ }
188
+ for (const el of this._availableList.querySelectorAll(`.${this._selectedClass}`)) {
189
+ el.classList.remove(this._selectedClass);
190
+ el.setAttribute("aria-selected", "false");
191
+ const btn = el.querySelector("[data-action='add']");
192
+ if (btn) btn.style.display = "";
193
+ }
194
+ }
195
+ const opt = this._select.querySelector(`option[value="${CSS.escape(value)}"]`);
196
+ if (opt) opt.selected = true;
197
+ const availEl = this._availableList.querySelector(`[data-value="${CSS.escape(value)}"]`);
198
+ if (availEl) {
199
+ availEl.classList.add(this._selectedClass);
200
+ availEl.setAttribute("aria-selected", "true");
201
+ const btn = availEl.querySelector("[data-action='add']");
202
+ if (btn) btn.style.display = "none";
203
+ }
204
+ this._withoutChildrenChangedNotification(() => {
205
+ if (!this.select.hasAttribute("multiple")) this._selectedList.replaceChildren();
206
+ this._selectedList.appendChild(this._createSelectedEntry(item));
207
+ });
208
+ }
209
+ _removeItem(value) {
210
+ const item = this._itemsByValue.get(value);
211
+ if (!item) return;
212
+ if (item.warnOnUnselect && !confirm(item.warnOnUnselect)) return;
213
+ const opt = this._select.querySelector(`option[value="${CSS.escape(value)}"]`);
214
+ if (opt) opt.selected = false;
215
+ const availEl = this._availableList.querySelector(`[data-value="${CSS.escape(value)}"]`);
216
+ if (availEl) {
217
+ availEl.classList.remove(this._selectedClass);
218
+ availEl.setAttribute("aria-selected", "false");
219
+ const btn = availEl.querySelector("[data-action='add']");
220
+ if (btn) btn.style.display = "";
221
+ }
222
+ const selEl = this._selectedList.querySelector(`[data-value="${CSS.escape(value)}"]`);
223
+ if (selEl) this._withoutChildrenChangedNotification(() => selEl.remove());
224
+ }
225
+ get select() {
226
+ return this._select;
227
+ }
228
+ get selectedLabel() {
229
+ return this.getAttribute("selected-label") || "Selected";
230
+ }
231
+ get availableLabel() {
232
+ return this.getAttribute("available-label") || "Available";
233
+ }
234
+ get addLabel() {
235
+ return this.getAttribute("add-label") || "\u2190";
236
+ }
237
+ get removeLabel() {
238
+ return this.getAttribute("remove-label") || "\xD7";
239
+ }
240
+ get filterText() {
241
+ return this._filterInput?.value ?? "";
242
+ }
243
+ set filterText(text) {
244
+ if (this._filterInput) this._filterInput.value = text;
245
+ this._applyFilter(text);
246
+ }
247
+ _applyFilter(text) {
248
+ const { matchCount, totalCount } = this._filterAvailable(text);
249
+ this.dispatchEvent(new CustomEvent("pwc-multiselect-dual-list:filter", {
250
+ bubbles: true,
251
+ detail: { filterText: text, matchCount, totalCount }
252
+ }));
253
+ }
254
+ _buildFilterRegex(text) {
255
+ if (!text) return null;
256
+ try {
257
+ return new RegExp(text, "i");
258
+ } catch {
259
+ return new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
260
+ }
261
+ }
262
+ };
263
+
264
+ // src/core/utils.js
265
+ function defineOnce(name, classDef) {
266
+ if (customElements.get(name)) return;
267
+ customElements.define(name, classDef);
268
+ }
269
+
270
+ // src/multiselect-dual-list/multiselect-dual-list.js
271
+ var PwcMultiselectDualList = class extends MultiselectDualListBase {
272
+ _buildUI() {
273
+ const container = document.createElement("div");
274
+ container.innerHTML = `
275
+ <div class="pwc-msdl-selected">
276
+ <div class="pwc-msdl-header">${this.selectedLabel}</div>
277
+ <ul class="pwc-msdl-list" role="listbox" aria-label="${this.selectedLabel}"></ul>
278
+ </div>
279
+ <div class="pwc-msdl-available">
280
+ <div class="pwc-msdl-header">${this.availableLabel}</div>
281
+ <input type="search" class="pwc-msdl-filter" placeholder="Filter\u2026" aria-label="Filter ${this.availableLabel}" />
282
+ <ul class="pwc-msdl-list" role="listbox" aria-label="${this.availableLabel}"></ul>
283
+ </div>
284
+ `;
285
+ container.className = "pwc-msdl-container";
286
+ this.select.after(container);
287
+ return {
288
+ selectedList: container.querySelector(".pwc-msdl-selected .pwc-msdl-list"),
289
+ availableList: container.querySelector(".pwc-msdl-available .pwc-msdl-list"),
290
+ filterInput: container.querySelector(".pwc-msdl-filter")
291
+ };
292
+ }
293
+ _createEntry(item) {
294
+ const li = document.createElement("li");
295
+ li.className = "pwc-msdl-item";
296
+ li.role = "option";
297
+ li.dataset.value = item.value;
298
+ const label = document.createElement("span");
299
+ label.textContent = item.label;
300
+ li.appendChild(label);
301
+ return li;
302
+ }
303
+ _createAvailableEntry(item) {
304
+ const li = this._createEntry(item);
305
+ li.setAttribute("aria-selected", String(item.selected));
306
+ if (item.disabled) {
307
+ li.classList.add("pwc-msdl-item--disabled");
308
+ li.setAttribute("aria-disabled", "true");
309
+ }
310
+ if (item.selected) li.classList.add("pwc-msdl-item--selected");
311
+ if (item.depth > 0) li.style.paddingLeft = `${item.depth * 1.5}em`;
312
+ if (!item.disabled) {
313
+ const btn = document.createElement("button");
314
+ btn.type = "button";
315
+ btn.className = "pwc-msdl-action";
316
+ btn.dataset.action = "add";
317
+ btn.textContent = this.addLabel;
318
+ btn.setAttribute("aria-label", `${this.addLabel} ${item.label}`);
319
+ if (item.selected) btn.style.display = "none";
320
+ li.appendChild(btn);
321
+ }
322
+ return li;
323
+ }
324
+ _createSelectedEntry(item) {
325
+ const li = this._createEntry(item);
326
+ const btn = document.createElement("button");
327
+ btn.type = "button";
328
+ btn.className = "pwc-msdl-action";
329
+ btn.dataset.action = "remove";
330
+ btn.textContent = this.removeLabel;
331
+ btn.setAttribute("aria-label", `${this.removeLabel} ${item.label}`);
332
+ li.appendChild(btn);
333
+ return li;
334
+ }
335
+ _filterAvailable(text) {
336
+ const items = this._availableList.querySelectorAll("[data-value]");
337
+ const totalCount = items.length;
338
+ const regex = this._buildFilterRegex(text);
339
+ if (!regex) {
340
+ for (const el of items) el.style.display = "";
341
+ return { matchCount: totalCount, totalCount };
342
+ }
343
+ let matchCount = 0;
344
+ for (const el of items) {
345
+ const match = regex.test(el.textContent);
346
+ el.style.display = match ? "" : "none";
347
+ if (match) matchCount++;
348
+ }
349
+ return { matchCount, totalCount };
350
+ }
351
+ };
352
+ var define = () => defineOnce("pwc-multiselect-dual-list", PwcMultiselectDualList);
353
+
354
+ // src/multiselect-dual-list/multiselect-dual-list.css
355
+ var multiselect_dual_list_default = "pwc-multiselect-dual-list {\n /* sizing */\n --pwc-msdl-width: 100%;\n\n /* spacing */\n --pwc-msdl-gap: 12px;\n --pwc-msdl-padding: 8px;\n --pwc-msdl-item-padding: 6px 10px;\n --pwc-msdl-indent: 1.5em;\n\n /* list */\n --pwc-msdl-list-max-height: 20em;\n\n /* visuals */\n --pwc-msdl-bg: #fff;\n --pwc-msdl-border: 1px solid rgba(0, 0, 0, 0.15);\n --pwc-msdl-border-radius: 4px;\n --pwc-msdl-separator: rgba(0, 0, 0, 0.08);\n\n /* item */\n --pwc-msdl-item-bg: #f8f8f8;\n --pwc-msdl-item-hover-bg: #f0f0f0;\n --pwc-msdl-item-selected-bg: #e8e8e8;\n --pwc-msdl-item-selected-color: #999;\n --pwc-msdl-item-disabled-color: #bbb;\n\n /* button */\n --pwc-msdl-action-bg: transparent;\n --pwc-msdl-action-hover-bg: rgba(0, 0, 0, 0.06);\n --pwc-msdl-action-border: 1px solid rgba(0, 0, 0, 0.2);\n --pwc-msdl-action-radius: 3px;\n\n display: block;\n width: var(--pwc-msdl-width);\n}\n\n.pwc-msdl-container {\n display: flex;\n gap: var(--pwc-msdl-gap);\n}\n\n.pwc-msdl-selected,\n.pwc-msdl-available {\n flex: 1;\n min-width: 0;\n background: var(--pwc-msdl-bg);\n border: var(--pwc-msdl-border);\n border-radius: var(--pwc-msdl-border-radius);\n padding: var(--pwc-msdl-padding);\n}\n\n.pwc-msdl-header {\n font-weight: 600;\n margin-bottom: 6px;\n}\n\n.pwc-msdl-filter {\n display: block;\n width: 100%;\n box-sizing: border-box;\n padding: 4px 8px;\n margin-bottom: 6px;\n border: var(--pwc-msdl-border);\n border-radius: var(--pwc-msdl-border-radius);\n font: inherit;\n font-size: 0.9em;\n}\n\n.pwc-msdl-list {\n list-style: none;\n margin: 0;\n padding: 0;\n max-height: var(--pwc-msdl-list-max-height);\n overflow-y: auto;\n}\n\n.pwc-msdl-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: var(--pwc-msdl-item-padding);\n background: var(--pwc-msdl-item-bg);\n border-bottom: 1px solid var(--pwc-msdl-separator);\n}\n\n.pwc-msdl-item:last-child {\n border-bottom: none;\n}\n\n.pwc-msdl-item:hover {\n background: var(--pwc-msdl-item-hover-bg);\n}\n\n.pwc-msdl-item--selected {\n background: var(--pwc-msdl-item-selected-bg);\n color: var(--pwc-msdl-item-selected-color);\n}\n\n.pwc-msdl-item--disabled {\n color: var(--pwc-msdl-item-disabled-color);\n cursor: default;\n}\n\n.pwc-msdl-action {\n appearance: none;\n border: var(--pwc-msdl-action-border);\n background: var(--pwc-msdl-action-bg);\n padding: 2px 8px;\n border-radius: var(--pwc-msdl-action-radius);\n cursor: pointer;\n font: inherit;\n font-size: 0.85em;\n flex-shrink: 0;\n margin-left: 8px;\n}\n\n.pwc-msdl-action:hover {\n background: var(--pwc-msdl-action-hover-bg);\n}\n\npwc-multiselect-dual-list[hide-selected] .pwc-msdl-item--selected {\n display: none;\n}\n";
356
+
357
+ // src/multiselect-dual-list/index.js
358
+ function register() {
359
+ PwcMultiselectDualList.registerCss(multiselect_dual_list_default);
360
+ define();
361
+ }
362
+ register();
363
+ export {
364
+ register
365
+ };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@tillsc/progressive-web-components",
3
+ "version": "0.1.0",
4
+ "description": "Server-first web components.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "exports": {
11
+ "./all": "./dist/all.js",
12
+ "./all-bs5": "./dist/all-bs5.js",
13
+ "./dialog-opener": "./dist/dialog-opener.js",
14
+ "./dialog-opener-bs5": "./dist/dialog-opener-bs5.js",
15
+ "./modal-dialog": "./dist/modal-dialog.js",
16
+ "./modal-dialog-bs5": "./dist/modal-dialog-bs5.js",
17
+ "./multiselect-dual-list": "./dist/multiselect-dual-list.js",
18
+ "./multiselect-dual-list-bs5": "./dist/multiselect-dual-list-bs5.js"
19
+ },
20
+ "scripts": {
21
+ "clean": "rm -rf dist",
22
+ "build": "node build.js",
23
+ "build:watch": "node build.js --watch",
24
+ "test": "npm run build && node test/run.mjs",
25
+ "test:verbose": "npm run build && TEST_VERBOSE=1 TEST_CONCURRENCY=1 node test/run.mjs",
26
+ "serve": "node test/server/serve.mjs",
27
+ "watch": "concurrently -k -n build,serve \"npm run build:watch\" \"npm run serve\""
28
+ },
29
+ "devDependencies": {
30
+ "concurrently": "^9.0.0",
31
+ "esbuild": "^0.27.2",
32
+ "express": "^4.19.2",
33
+ "playwright": "1.58.1"
34
+ }
35
+ }