@pure-ds/core 0.7.32 → 0.7.34

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,530 @@
1
+ import { fragmentFromTemplateLike } from "./common.js";
2
+ import { PDS } from "../pds-singleton.js";
3
+
4
+ /**
5
+ * Get the current page title for dialogs
6
+ */
7
+ function getPageTitle() {
8
+ return document.title ||
9
+ document.querySelector('h1')?.textContent ||
10
+ 'Application';
11
+ }
12
+
13
+ /**
14
+ * Append message content using vanilla DOM APIs
15
+ * @param {HTMLElement} container
16
+ * @param {unknown} message
17
+ */
18
+ function appendMessageContent(container, message) {
19
+ if (message == null) return;
20
+
21
+ if (
22
+ typeof message === "object" &&
23
+ Array.isArray(message.strings) &&
24
+ Array.isArray(message.values)
25
+ ) {
26
+ container.appendChild(fragmentFromTemplateLike(message));
27
+ return;
28
+ }
29
+
30
+ if (message instanceof Node) {
31
+ container.appendChild(message);
32
+ return;
33
+ }
34
+
35
+ if (Array.isArray(message)) {
36
+ message.forEach((item) => appendMessageContent(container, item));
37
+ return;
38
+ }
39
+
40
+ const text = typeof message === "string" ? message : String(message);
41
+ container.appendChild(document.createTextNode(text));
42
+ }
43
+
44
+ /**
45
+ * Validate host form plus nested shadow-root forms (e.g. inside custom elements like pds-form).
46
+ * @param {HTMLFormElement} form
47
+ * @returns {boolean}
48
+ */
49
+ function validateDialogFormTree(form) {
50
+ if (!form) return true;
51
+
52
+ let valid = true;
53
+
54
+ const describeElement = (el) => {
55
+ if (!el || typeof el !== "object") return "<unknown>";
56
+ const tag = el.tagName ? String(el.tagName).toLowerCase() : "node";
57
+ const id = el.id ? `#${el.id}` : "";
58
+ const name = typeof el.getAttribute === "function" ? el.getAttribute("name") : null;
59
+ const namePart = name ? `[name="${name}"]` : "";
60
+ return `${tag}${id}${namePart}`;
61
+ };
62
+
63
+ const reportInvalidControls = (root, scopeLabel) => {
64
+ if (!root || typeof root.querySelectorAll !== "function") return;
65
+ const invalidControls = Array.from(root.querySelectorAll(":invalid"));
66
+ if (!invalidControls.length) return;
67
+ const list = invalidControls.map((el) => {
68
+ const message = typeof el.validationMessage === "string" ? el.validationMessage : "";
69
+ return `${describeElement(el)}${message ? ` — ${message}` : ""}`;
70
+ });
71
+ PDS.log("warn", `ask.validateDialogFormTree: invalid controls in ${scopeLabel}:`, list);
72
+ };
73
+
74
+ const runValidity = (target, scopeLabel) => {
75
+ try {
76
+ const targetValid = typeof target.reportValidity === "function"
77
+ ? target.reportValidity()
78
+ : target.checkValidity?.() ?? true;
79
+ if (!targetValid) {
80
+ reportInvalidControls(target, scopeLabel);
81
+ }
82
+ return targetValid;
83
+ } catch (error) {
84
+ PDS.log("error", `ask.validateDialogFormTree: validation threw in ${scopeLabel}`, error);
85
+ return false;
86
+ }
87
+ };
88
+
89
+ valid = runValidity(form, "host dialog form") && valid;
90
+
91
+ const nestedLightDomForms = Array.from(form.querySelectorAll("form"));
92
+ for (const nestedForm of nestedLightDomForms) {
93
+ if (nestedForm === form) continue;
94
+ const nestedValid = runValidity(nestedForm, `nested light DOM form ${describeElement(nestedForm)}`);
95
+ valid = nestedValid && valid;
96
+ }
97
+
98
+ const descendants = Array.from(form.querySelectorAll("*"));
99
+ for (const host of descendants) {
100
+ const root = host?.shadowRoot;
101
+ if (!root) continue;
102
+
103
+ const nestedForms = Array.from(root.querySelectorAll("form"));
104
+ for (const nestedForm of nestedForms) {
105
+ const nestedValid = runValidity(
106
+ nestedForm,
107
+ `shadow form under ${describeElement(host)}`
108
+ );
109
+ valid = nestedValid && valid;
110
+ }
111
+ }
112
+
113
+ return valid;
114
+ }
115
+
116
+ function isSafariBrowser() {
117
+ const userAgent = navigator.userAgent;
118
+ const isSafariEngine = /Safari/i.test(userAgent);
119
+ const isOtherBrowser = /(Chrome|Chromium|CriOS|FxiOS|EdgiOS|OPiOS|Opera)/i.test(userAgent);
120
+ return isSafariEngine && !isOtherBrowser;
121
+ }
122
+
123
+ function playDialogEnterAnimation(dialog) {
124
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
125
+ return;
126
+ }
127
+
128
+ const isMobile = window.matchMedia?.('(max-width: 639px)').matches;
129
+ const animationName = dialog.classList.contains('dialog-no-scale-animation')
130
+ ? 'pds-dialog-fade-enter'
131
+ : isMobile
132
+ ? 'pds-dialog-enter-mobile'
133
+ : 'pds-dialog-enter';
134
+
135
+ dialog.style.animation = 'none';
136
+ void dialog.offsetWidth;
137
+ dialog.style.animation = `${animationName} var(--transition-normal) ease`;
138
+
139
+ dialog.addEventListener('animationend', () => {
140
+ dialog.style.animation = '';
141
+ }, { once: true });
142
+ }
143
+
144
+ function shouldUseLiquidGlass(options = {}) {
145
+ return options?.liquidGlassEffects === true;
146
+ }
147
+
148
+ /**
149
+ * Create a PDS-compliant dialog with proper semantic structure
150
+ * @param {string|Node|Array} message - Message content (string or DOM nodes)
151
+ * @param {Object} options - Dialog options
152
+ * @returns {Promise} Resolves with result when dialog closes
153
+ */
154
+ export async function ask(message, options = {}) {
155
+
156
+ const defaults = {
157
+ title: "Confirm",
158
+ type: "confirm", // 'alert', 'confirm', 'custom'
159
+ buttons: {
160
+ ok: { name: "OK", primary: true },
161
+ cancel: { name: "Cancel", cancel: true },
162
+ },
163
+ };
164
+
165
+ options = { ...defaults, ...options };
166
+
167
+ const buttonConfigs = options.buttons && typeof options.buttons === "object"
168
+ ? options.buttons
169
+ : defaults.buttons;
170
+
171
+ const resolveActionMeta = (actionCode) => {
172
+ if (actionCode == null) {
173
+ return {
174
+ actionCode: "dismiss",
175
+ actionKind: "dismiss",
176
+ button: null,
177
+ };
178
+ }
179
+
180
+ const button = buttonConfigs?.[actionCode] ?? null;
181
+ const actionKind = actionCode === "ok"
182
+ ? "ok"
183
+ : actionCode === "dismiss"
184
+ ? "dismiss"
185
+ : (button?.cancel || actionCode === "cancel")
186
+ ? "cancel"
187
+ : "custom";
188
+
189
+ return {
190
+ actionCode,
191
+ actionKind,
192
+ button,
193
+ };
194
+ };
195
+
196
+ const normalizeBeforeCloseResult = (result) => {
197
+ if (typeof result === "undefined" || result === null || result === true) {
198
+ return { allow: true };
199
+ }
200
+
201
+ if (result === false) {
202
+ return { allow: false };
203
+ }
204
+
205
+ if (typeof result === "object") {
206
+ const hasResult = Object.prototype.hasOwnProperty.call(result, "result")
207
+ || Object.prototype.hasOwnProperty.call(result, "value");
208
+
209
+ return {
210
+ allow: result.allow !== false,
211
+ hasResult,
212
+ result: Object.prototype.hasOwnProperty.call(result, "result")
213
+ ? result.result
214
+ : result.value,
215
+ };
216
+ }
217
+
218
+ return { allow: Boolean(result) };
219
+ };
220
+
221
+ return new Promise((resolve) => {
222
+ let settled = false;
223
+ const settle = (value, dialog, { shouldClose = true } = {}) => {
224
+ if (settled) return;
225
+ settled = true;
226
+ resolve(value);
227
+
228
+ if (!shouldClose || !dialog?.open) {
229
+ return;
230
+ }
231
+
232
+ try {
233
+ dialog.close();
234
+ } catch (error) {
235
+ PDS.log("warn", "ask: dialog.close() failed", error);
236
+ }
237
+ };
238
+
239
+ const runBeforeClose = async (context) => {
240
+ if (context.actionKind !== "ok" || typeof options.beforeClose !== "function") {
241
+ return { allow: true };
242
+ }
243
+
244
+ try {
245
+ const beforeCloseResult = await options.beforeClose(context);
246
+ return normalizeBeforeCloseResult(beforeCloseResult);
247
+ } catch (error) {
248
+ PDS.log("error", "ask.beforeClose: validation failed", error);
249
+ return { allow: false };
250
+ }
251
+ };
252
+
253
+ const resolveDefaultResult = ({ actionKind, form }) => {
254
+ if (actionKind === "ok") {
255
+ if (options.useForm && form) {
256
+ return new FormData(form);
257
+ }
258
+ return true;
259
+ }
260
+
261
+ return false;
262
+ };
263
+
264
+ const attemptResolve = async ({
265
+ actionCode,
266
+ form,
267
+ submitter,
268
+ originalEvent,
269
+ bypassValidation = false,
270
+ shouldClose = true,
271
+ }) => {
272
+ if (settled) return;
273
+
274
+ const { actionKind, button } = resolveActionMeta(actionCode);
275
+ const activeForm = form || dialog.querySelector("form") || null;
276
+
277
+ if (options.useForm && actionKind === "ok" && activeForm && !bypassValidation) {
278
+ const valid = validateDialogFormTree(activeForm);
279
+ if (!valid) {
280
+ return;
281
+ }
282
+ }
283
+
284
+ const defaultResult = resolveDefaultResult({
285
+ actionKind,
286
+ form: activeForm,
287
+ });
288
+
289
+ const guard = await runBeforeClose({
290
+ actionCode,
291
+ actionKind,
292
+ dialog,
293
+ form: activeForm,
294
+ formData: options.useForm && actionKind === "ok" && activeForm
295
+ ? defaultResult
296
+ : null,
297
+ submitter,
298
+ originalEvent,
299
+ options,
300
+ button,
301
+ defaultResult,
302
+ });
303
+
304
+ if (!guard.allow) {
305
+ return;
306
+ }
307
+
308
+ const result = guard.hasResult ? guard.result : defaultResult;
309
+ settle(result, dialog, { shouldClose });
310
+ };
311
+
312
+ // Create native dialog element
313
+ const dialog = document.createElement("dialog");
314
+
315
+ if (isSafariBrowser()) {
316
+ dialog.classList.add("dialog-no-scale-animation");
317
+ }
318
+
319
+ if (shouldUseLiquidGlass(options))
320
+ dialog.classList.add("liquid-glass");
321
+
322
+ // Add optional CSS classes
323
+ if (options.size) {
324
+ dialog.classList.add(`dialog-${options.size}`); // dialog-sm, dialog-lg, dialog-xl
325
+ }
326
+ if (options.type) {
327
+ dialog.classList.add(`dialog-${options.type}`);
328
+ }
329
+ if (options.class) {
330
+ if (Array.isArray(options.class)) {
331
+ dialog.classList.add(...options.class);
332
+ } else {
333
+ dialog.classList.add(options.class);
334
+ }
335
+ }
336
+
337
+ // Set maxHeight via CSS custom property (constrained to 90vh by default)
338
+ if (options.maxHeight) {
339
+ dialog.style.setProperty('--dialog-max-height', options.maxHeight);
340
+ }
341
+
342
+ // Build button elements
343
+ const buttons = Object.entries(buttonConfigs).map(([code, obj]) => {
344
+ const btnClass = obj.primary ? "btn-primary btn-sm" : "btn-outline btn-sm";
345
+ const btnType = obj.cancel ? "button" : "submit";
346
+ const formNoValidate = obj.formNoValidate ? " formnovalidate" : "";
347
+ return `<button type="${btnType}" class="${btnClass}" value="${code}"${formNoValidate}>${obj.name}</button>`;
348
+ });
349
+
350
+ // Create PDS-compliant dialog structure
351
+ // When useForm is true, don't wrap in a form - let the content provide the form
352
+ if (options.useForm) {
353
+ // Create a temporary container to render the message content
354
+ const tempContainer = document.createElement("div");
355
+ appendMessageContent(tempContainer, message);
356
+
357
+ // Find the form in the rendered content
358
+ const form = tempContainer.querySelector("form");
359
+ if (form) {
360
+ // Build dialog structure with form as direct child for proper flex layout
361
+ dialog.innerHTML = /*html*/ `
362
+ <header>
363
+ <h2>${options.title}</h2>
364
+ </header>
365
+ `;
366
+
367
+ // Create article wrapper and move form children into it (preserves DOM nodes & bindings)
368
+ const article = document.createElement("article");
369
+ article.className = "dialog-body";
370
+ while (form.firstChild) {
371
+ article.appendChild(form.firstChild);
372
+ }
373
+ form.appendChild(article);
374
+
375
+ // Add footer with buttons
376
+ const footer = document.createElement("footer");
377
+ footer.innerHTML = buttons.join("");
378
+ form.appendChild(footer);
379
+
380
+ // Append the restructured form to dialog
381
+ dialog.appendChild(form);
382
+ } else {
383
+ // No form found, use standard article structure
384
+ dialog.innerHTML = /*html*/ `
385
+ <header>
386
+ <h2>${options.title}</h2>
387
+ </header>
388
+ <article id="msg-container"></article>
389
+ <footer>
390
+ ${buttons.join("")}
391
+ </footer>
392
+ `;
393
+ const article = dialog.querySelector("#msg-container");
394
+ article.appendChild(tempContainer);
395
+ }
396
+ } else {
397
+ dialog.innerHTML = /*html*/ `
398
+ <form method="dialog">
399
+ <header>
400
+ <h2>${options.title}</h2>
401
+ </header>
402
+
403
+ <article id="msg-container"></article>
404
+
405
+ <footer>
406
+ ${buttons.join("")}
407
+ </footer>
408
+ </form>
409
+ `;
410
+
411
+ // Render message content
412
+ const article = dialog.querySelector("#msg-container");
413
+ appendMessageContent(article, message);
414
+ }
415
+
416
+ // Handle cancel button clicks
417
+ dialog.addEventListener("click", (e) => {
418
+ const btn = e.target.closest('button[value="cancel"]');
419
+ if (btn) {
420
+ attemptResolve({
421
+ actionCode: "cancel",
422
+ form: dialog.querySelector("form"),
423
+ submitter: btn,
424
+ originalEvent: e,
425
+ });
426
+ }
427
+ });
428
+
429
+ // Wait for form to exist before adding submit listener
430
+ const setupFormListener = () => {
431
+ const form = dialog.querySelector("form");
432
+ if (form) {
433
+ if (form.dataset.askSubmitBound === "true") {
434
+ return;
435
+ }
436
+ form.dataset.askSubmitBound = "true";
437
+
438
+ form.addEventListener("submit", (event) => {
439
+ event.preventDefault();
440
+
441
+ const submitValue = event.submitter?.value ?? (options.useForm ? "ok" : undefined);
442
+ const bypassValidation = Boolean(event.submitter?.hasAttribute("formnovalidate"));
443
+
444
+ attemptResolve({
445
+ actionCode: submitValue,
446
+ form,
447
+ submitter: event.submitter,
448
+ originalEvent: event,
449
+ bypassValidation,
450
+ });
451
+ });
452
+ } else {
453
+ // Form doesn't exist yet, wait and try again
454
+ requestAnimationFrame(setupFormListener);
455
+ }
456
+ };
457
+
458
+ dialog.addEventListener("cancel", (event) => {
459
+ event.preventDefault();
460
+ attemptResolve({
461
+ actionCode: "dismiss",
462
+ form: dialog.querySelector("form"),
463
+ originalEvent: event,
464
+ });
465
+ });
466
+
467
+ // Handle dialog close event
468
+ dialog.addEventListener("close", () => {
469
+ if (!settled) {
470
+ settle(false, dialog, { shouldClose: false });
471
+ }
472
+
473
+ // Small delay to allow exit animation
474
+ setTimeout(() => dialog.remove(), 200);
475
+ });
476
+
477
+ // Append to body and show
478
+ document.body.appendChild(dialog);
479
+
480
+ // Bind submit behavior after element is connected so lazy-rendered forms are discoverable
481
+ requestAnimationFrame(setupFormListener);
482
+
483
+ // Call optional rendered callback
484
+ if (typeof options.rendered === "function") {
485
+ options.rendered(dialog);
486
+ }
487
+
488
+ // Show the dialog as modal
489
+ dialog.showModal();
490
+
491
+ requestAnimationFrame(() => playDialogEnterAnimation(dialog));
492
+ });
493
+ }
494
+
495
+ /**
496
+ * Show an alert dialog
497
+ * @param {string|Node|Array} message - Alert message
498
+ * @param {Object} options - Optional dialog options
499
+ * @returns {Promise}
500
+ */
501
+ export async function alert(message, options = {}) {
502
+ const defaults = {
503
+ title: getPageTitle(),
504
+ type: "alert",
505
+ buttons: {
506
+ ok: { name: "OK", primary: true },
507
+ },
508
+ };
509
+
510
+ return ask(message, { ...defaults, ...options });
511
+ }
512
+
513
+ /**
514
+ * Show a confirmation dialog
515
+ * @param {string|Node|Array} message - Confirmation message
516
+ * @param {Object} options - Optional dialog options
517
+ * @returns {Promise<boolean>}
518
+ */
519
+ export async function confirm(message, options = {}) {
520
+ const defaults = {
521
+ title: "Confirm Action",
522
+ type: "confirm",
523
+ buttons: {
524
+ ok: { name: "Confirm", primary: true },
525
+ cancel: { name: "Cancel", cancel: true },
526
+ },
527
+ };
528
+
529
+ return ask(message, { ...defaults, ...options });
530
+ }
@@ -0,0 +1,122 @@
1
+ export function isObject(item) {
2
+ return item && typeof item === 'object' && !Array.isArray(item);
3
+ }
4
+
5
+ export function deepMerge(target, source) {
6
+ const output = { ...target };
7
+ if (isObject(target) && isObject(source)) {
8
+ Object.keys(source).forEach(key => {
9
+ if (isObject(source[key])) {
10
+ if (!(key in target))
11
+ Object.assign(output, { [key]: source[key] });
12
+ else
13
+ output[key] = deepMerge(target[key], source[key]);
14
+ } else {
15
+ Object.assign(output, { [key]: source[key] });
16
+ }
17
+ });
18
+ }
19
+ return output;
20
+ }
21
+
22
+ /**
23
+ * Build a DocumentFragment from a template-like object (strings + values)
24
+ * @param {{strings: string[], values: unknown[]}} templateLike
25
+ * @returns {DocumentFragment}
26
+ */
27
+ export function fragmentFromTemplateLike(templateLike) {
28
+ const strings = Array.isArray(templateLike?.strings) ? templateLike.strings : [];
29
+ const values = Array.isArray(templateLike?.values) ? templateLike.values : [];
30
+ const consumedValues = new Set();
31
+ const htmlParts = [];
32
+
33
+ const propBindingPattern = /(\s)(\.[\w-]+)=\s*$/;
34
+
35
+ for (let i = 0; i < strings.length; i += 1) {
36
+ let chunk = strings[i] ?? "";
37
+ const match = chunk.match(propBindingPattern);
38
+
39
+ if (match && i < values.length) {
40
+ const propToken = match[2];
41
+ const propName = propToken.slice(1);
42
+ const marker = `pds-val-${i}`;
43
+ chunk = chunk.replace(
44
+ propBindingPattern,
45
+ `$1data-pds-prop="${propName}:${marker}"`
46
+ );
47
+ consumedValues.add(i);
48
+ }
49
+
50
+ htmlParts.push(chunk);
51
+
52
+ if (i < values.length && !consumedValues.has(i)) {
53
+ htmlParts.push(`<!--pds-val-${i}-->`);
54
+ }
55
+ }
56
+
57
+ const tpl = document.createElement("template");
58
+ tpl.innerHTML = htmlParts.join("");
59
+
60
+ const replaceValueAtMarker = (markerNode, value) => {
61
+ const parent = markerNode.parentNode;
62
+ if (!parent) return;
63
+
64
+ if (value == null) {
65
+ parent.removeChild(markerNode);
66
+ return;
67
+ }
68
+
69
+ const insertValue = (val) => {
70
+ if (val == null) return;
71
+ if (val instanceof Node) {
72
+ parent.insertBefore(val, markerNode);
73
+ return;
74
+ }
75
+ if (Array.isArray(val)) {
76
+ val.forEach((item) => insertValue(item));
77
+ return;
78
+ }
79
+ parent.insertBefore(document.createTextNode(String(val)), markerNode);
80
+ };
81
+
82
+ insertValue(value);
83
+ parent.removeChild(markerNode);
84
+ };
85
+
86
+ const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_COMMENT);
87
+ const markers = [];
88
+ while (walker.nextNode()) {
89
+ const node = walker.currentNode;
90
+ if (node?.nodeValue?.startsWith("pds-val-")) {
91
+ markers.push(node);
92
+ }
93
+ }
94
+
95
+ markers.forEach((node) => {
96
+ const index = Number(node.nodeValue.replace("pds-val-", ""));
97
+ replaceValueAtMarker(node, values[index]);
98
+ });
99
+
100
+ const elements = tpl.content.querySelectorAll("*");
101
+ elements.forEach((el) => {
102
+ const propAttr = el.getAttribute("data-pds-prop");
103
+ if (!propAttr) return;
104
+ const [propName, markerValue] = propAttr.split(":");
105
+ const index = Number(String(markerValue).replace("pds-val-", ""));
106
+ if (propName && Number.isInteger(index)) {
107
+ el[propName] = values[index];
108
+ }
109
+ el.removeAttribute("data-pds-prop");
110
+ });
111
+
112
+ return tpl.content;
113
+ }
114
+
115
+ /**
116
+ * Parses an HTML string into a NodeList
117
+ * @param {String} html
118
+ * @returns {NodeListOf<ChildNode>}
119
+ */
120
+ export function parseHTML(html) {
121
+ return new DOMParser().parseFromString(html, "text/html").body.childNodes;
122
+ }