@livenetworks/ashlar 2.0.4 → 2.0.5

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.
@@ -1,403 +1,140 @@
1
- import { registerComponent, dispatch, hashGet, hashSet, hashParse, hashLinkClick, queueBoot, buildDict } from '../../ln-core';
2
-
3
- (function () {
4
- const DOM_SELECTOR = 'data-ln-ui-coordinator';
5
- const DOM_ATTRIBUTE = 'lnUiCoordinator';
6
- const DICT_SELECTOR = 'data-ln-ui-coordinator-dict';
7
-
8
- if (window[DOM_ATTRIBUTE] !== undefined) return;
9
-
10
- // ─── Attribute Contract (SSOT) ──────────────────────────
11
- const ATTRIBUTES = {
12
- 'data-ln-ui-coordinator': { type: 'marker', description: 'Mounts UI coordinator mediating global hash-routing, modals, and toasts' },
13
- 'data-ln-ui-coordinator-dict': { type: 'string', description: 'Dictionary key prefix mapping for translatable UI coordinator messages' }
14
- };
15
-
16
- // Resolve the active coordinator dictionary for a given event target.
17
- // Climbs the DOM hierarchy of UI coordinators and merges dictionary keys from
18
- // root to leaf so nested coordinators inherit and selectively override outer translations.
19
- function _getCoordinatorDict(target) {
20
- const dict = {};
21
- let current = target;
22
- const hosts = [];
23
-
24
- while (current) {
25
- const host = current.closest('[' + DOM_SELECTOR + ']');
26
- if (!host) break;
27
- if (host[DOM_ATTRIBUTE] && host[DOM_ATTRIBUTE].dict) {
28
- hosts.unshift(host[DOM_ATTRIBUTE].dict);
29
- }
30
- current = host.parentElement;
31
- }
32
-
33
- for (const hDict of hosts) {
34
- Object.assign(dict, hDict);
35
- }
36
-
37
- return dict;
38
- }
39
-
40
- // Helper to resolve target modal from trigger element / wrapper / ID
41
- function _findTargetModal(triggerEl, modalId) {
42
- if (modalId) {
43
- if (triggerEl) {
44
- const wrapper = triggerEl.closest('[' + DOM_SELECTOR + ']');
45
- if (wrapper) {
46
- if (wrapper.id === modalId && wrapper.hasAttribute('data-ln-modal')) return wrapper;
47
- const scoped = wrapper.querySelector('#' + CSS.escape(modalId) + '[data-ln-modal], [data-ln-modal="' + modalId + '"]');
48
- if (scoped) return scoped;
49
- }
50
- }
51
- const el = document.getElementById(modalId) || document.querySelector('[data-ln-modal="' + modalId + '"]');
52
- if (el) return el;
53
- }
54
-
55
- if (triggerEl) {
56
- const wrapper = triggerEl.closest('[' + DOM_SELECTOR + ']');
57
- if (wrapper) {
58
- if (wrapper.hasAttribute('data-ln-modal')) return wrapper;
59
- const scoped = wrapper.querySelector('[data-ln-modal]');
60
- if (scoped) return scoped;
61
- }
62
- const closest = triggerEl.closest('[data-ln-modal]');
63
- if (closest) return closest;
64
- }
65
-
66
- return document.querySelector('[data-ln-modal]');
67
- }
68
-
69
- // Hash param for a modal open. Edit opens must carry a TRUTHY segment so
70
- // foreign hashchanges never route them through the empty-param reset branch
71
- // (which would wipe the edit fill). Prefer the real entity id
72
- // (data-ln-fill-id) for a bookmarkable URL; fall back to the non-addressable
73
- // marker 'edit' when the trigger fills inline with no id. New opens carry ''.
74
- function _hashParamForOpen(mode, triggerEl) {
75
- if (mode !== 'edit') return '';
76
- if (triggerEl) {
77
- const fillId = triggerEl.getAttribute('data-ln-fill-id');
78
- if (fillId) return fillId;
79
- }
80
- return 'edit';
81
- }
82
-
83
- // Helper to reset all forms and display fields inside a modal panel
84
- function _resetModalForm(modal) {
85
- if (!modal) return;
86
- const fields = modal.querySelectorAll('[data-ln-field]');
87
- for (let i = 0; i < fields.length; i++) {
88
- fields[i].textContent = '';
89
- }
90
- const forms = modal.querySelectorAll('form');
91
- for (let i = 0; i < forms.length; i++) {
92
- if (window.lnCore && typeof window.lnCore.lnFill === 'function') {
93
- window.lnCore.lnFill(forms[i], null);
94
- } else {
95
- forms[i].reset();
96
- }
97
- }
98
- }
99
-
100
- // ─── Trigger & Hash Navigation Delegation ──────────────
101
-
102
- document.addEventListener('click', function (e) {
103
- if (e.ctrlKey || e.metaKey || e.button === 1) return;
104
-
105
- // 1. Handle trigger click [data-ln-modal-for="modalId"]
106
- const trigger = e.target.closest('[data-ln-modal-for]');
107
- if (trigger) {
108
- const modalId = trigger.getAttribute('data-ln-modal-for');
109
- const target = _findTargetModal(trigger, modalId);
110
- if (target && target.lnModal) {
111
- e.preventDefault();
112
-
113
- const MODAL_RESERVED = { lnModalFor: true, lnModalClose: true, lnModalMode: true };
114
- const record = {};
115
- const ds = trigger.dataset;
116
- for (const key in ds) {
117
- if (!key.startsWith('lnModal')) continue;
118
- if (MODAL_RESERVED[key]) continue;
119
- const suffix = key.slice(7);
120
- if (!suffix) continue;
121
- record[suffix.charAt(0).toLowerCase() + suffix.slice(1)] = ds[key];
122
- }
123
-
124
- const hasRecord = Object.keys(record).length > 0;
125
-
126
- if (trigger.hasAttribute('data-ln-modal-mode')) {
127
- target.dataset.lnModalMode = trigger.getAttribute('data-ln-modal-mode');
128
- } else {
129
- target.dataset.lnModalMode = hasRecord ? 'edit' : 'new';
130
- }
131
-
132
- if (hasRecord && window.lnCore && typeof window.lnCore.lnFill === 'function') {
133
- window.lnCore.lnFill(target, record);
134
- } else if (target.dataset.lnModalMode === 'new') {
135
- _resetModalForm(target);
136
- }
137
-
138
- const current = target.getAttribute('data-ln-modal');
139
- if (current === 'open') {
140
- dispatch(target, 'ln-modal:request-close', {});
141
- } else {
142
- // Restore per-open hash-write (pre-extraction invariant): every
143
- // open leaves a URL segment so _syncHashModals never closes it.
144
- if (target.id) {
145
- hashSet(target.id, _hashParamForOpen(target.dataset.lnModalMode, trigger));
146
- }
147
- dispatch(target, 'ln-modal:request-open', {});
148
- }
149
- }
150
- return;
151
- }
152
-
153
- // 2. Handle hash anchor click <a href="#modalId"> / <a href="#modalId:param">
154
- const hashAnchor = e.target.closest('a[href^="#"]');
155
- if (hashAnchor) {
156
- const map = hashParse(hashAnchor.getAttribute('href'));
157
- for (const ns in map) {
158
- const el = document.getElementById(ns);
159
- if (el && el.lnModal) {
160
- if (!hashLinkClick(e)) return;
161
- hashSet(ns, map[ns]);
162
- return;
163
- }
164
- }
165
- }
166
- });
167
-
168
- // ─── Before Open Reset Gate ────────────────────────────
169
-
170
- document.addEventListener('ln-modal:before-open', function (e) {
171
- const modal = e.target;
172
- if (!modal || !modal.lnModal) return;
173
-
174
- const mode = modal.dataset.lnModalMode || 'new';
175
- if (mode === 'new') {
176
- _resetModalForm(modal);
177
- }
178
- });
179
-
180
- // ─── Open Event Fill Dispatcher ───────────────────────
181
-
182
- document.addEventListener('ln-modal:open', function (e) {
183
- const modal = e.target;
184
- if (!modal || !modal.lnModal || !modal.id) return;
185
-
186
- let param = hashGet(modal.id);
187
-
188
- // Non-trigger opens (programmatic / SSR-via-mutation) reach here with no
189
- // segment — adopt one so foreign hashchanges never close the modal.
190
- // Trigger opens already wrote their segment, so this is skipped for them.
191
- if (param === null) {
192
- param = _hashParamForOpen(modal.dataset.lnModalMode, null);
193
- hashSet(modal.id, param);
194
- }
195
-
196
- if (param) {
197
- modal.dataset.lnModalMode = 'edit';
198
- dispatch(modal, 'ln-fill:request', { id: param });
199
- } else {
200
- modal.dataset.lnModalMode = 'new';
201
- _resetModalForm(modal);
202
- }
203
- });
204
-
205
- // ─── Hash Sync Listener & Modal Route State ────────────
206
-
207
- let _inSync = false;
208
- function _syncHashModals() {
209
- if (_inSync) return;
210
- _inSync = true;
211
- try {
212
- const modals = document.querySelectorAll('[data-ln-modal][id]');
213
- for (let i = 0; i < modals.length; i++) {
214
- const modal = modals[i];
215
- if (!modal.lnModal) continue;
216
- const hashNs = modal.id;
217
-
218
- const param = hashGet(hashNs);
219
- const present = param !== null;
220
- const isOpen = modal.lnModal.isOpen;
221
-
222
- if (present) {
223
- const mode = param ? 'edit' : 'new';
224
- modal.dataset.lnModalMode = mode;
225
- if (!isOpen) {
226
- dispatch(modal, 'ln-modal:request-open', {});
227
- } else {
228
- // Modal is already open, but hash param changed dynamically (e.g. #modal:42 -> #modal:5)
229
- if (param) {
230
- dispatch(modal, 'ln-fill:request', { id: param });
231
- } else {
232
- _resetModalForm(modal);
233
- }
234
- }
235
- } else if (isOpen) {
236
- dispatch(modal, 'ln-modal:request-close', {});
237
- }
238
- }
239
- } finally {
240
- _inSync = false;
241
- }
242
- }
243
-
244
- // SSR modals authored data-ln-modal="open" never pass through a trigger or
245
- // hash anchor, so they carry no URL segment. Adopt them into the hash BEFORE
246
- // the first sync, so _syncHashModals sees them present and keeps them open
247
- // instead of closing them as a phantom back-navigation. Skips modals that
248
- // already have a segment (e.g. an SSR deep-link URL).
249
- function _adoptOpenModals() {
250
- const openModals = document.querySelectorAll('[data-ln-modal="open"][id]');
251
- for (let i = 0; i < openModals.length; i++) {
252
- const modal = openModals[i];
253
- if (!modal.lnModal) continue;
254
- if (hashGet(modal.id) !== null) continue;
255
- hashSet(modal.id, _hashParamForOpen(modal.dataset.lnModalMode, null));
256
- }
257
- }
258
-
259
- window.addEventListener('hashchange', _syncHashModals);
260
-
261
- function _bootSync() {
262
- _adoptOpenModals();
263
- _syncHashModals();
264
- }
265
-
266
- if (document.readyState === 'loading') {
267
- document.addEventListener('DOMContentLoaded', function () {
268
- queueBoot(_bootSync);
269
- });
270
- } else {
271
- queueBoot(_bootSync);
272
- }
273
-
274
- // ─── AJAX Submission Mediation & Toast Routing ──────────
275
-
276
- function _handleAjaxSuccess(e) {
277
- const detail = e.detail || {};
278
- const data = detail.data;
279
-
280
- // 1. Dispatch UI toast notification if response envelope contains a message
281
- if (data && data.message) {
282
- const msg = data.message;
283
- dispatch(window, 'ln-toast:enqueue', {
284
- type: msg.type || 'success',
285
- title: msg.title || '',
286
- message: msg.body || ''
287
- });
288
- }
289
-
290
- // 2. Auto-close modal and clean up if the submitting element was inside a modal
291
- const modal = e.target.closest('[data-ln-modal]');
292
- if (modal && modal.lnModal) {
293
- if (modal.id) {
294
- hashSet(modal.id, null);
295
- }
296
-
297
- dispatch(modal, 'ln-modal:request-close', {});
298
- _resetModalForm(modal);
299
- }
300
- }
301
-
302
- function _handleAjaxError(e) {
303
- const detail = e.detail || {};
304
- const data = detail.data;
305
- const status = detail.status || 0;
306
- const dict = _getCoordinatorDict(e.target);
307
-
308
- // 1. If server returned structured error message, toast it
309
- if (data && data.message) {
310
- const msg = data.message;
311
- dispatch(window, 'ln-toast:enqueue', {
312
- type: msg.type || 'error',
313
- title: msg.title || '',
314
- message: msg.body || ''
315
- });
316
- } else if (status === 0) {
317
- // 2. Genuine network failure (DNS / offline / abort / connection dropped)
318
- dispatch(window, 'ln-toast:enqueue', {
319
- type: 'error',
320
- title: dict['network-error-title'] || '',
321
- message: dict['network-error'] || 'Network error'
322
- });
323
- } else {
324
- // 3. Server HTTP error (status >= 400 or HTML error page) without structured JSON message
325
- dispatch(window, 'ln-toast:enqueue', {
326
- type: 'error',
327
- title: dict['server-error-title'] || '',
328
- message: dict['server-error'] || 'Server error'
329
- });
330
- }
331
-
332
- // Note: We deliberately do NOT close the modal on error so form validation feedback remains visible
333
- }
334
-
335
- document.addEventListener('ln-ajax:success', _handleAjaxSuccess);
336
- document.addEventListener('ln-ajax:error', _handleAjaxError);
337
-
338
- // ─── Upload Feedback Mediation ──────────────────────────
339
-
340
- function _handleUploadInvalid(e) {
341
- const detail = e.detail || {};
342
- const dict = _getCoordinatorDict(e.target);
343
- const message = detail.message || (detail.reason === 'max-size' ? dict['upload-max-size'] || 'File is too large' : (detail.reason === 'max-files' ? dict['upload-max-files'] || 'Maximum file count exceeded' : dict['upload-invalid-type'] || 'This file type is not allowed'));
344
- const title = dict['upload-invalid-title'] || 'Invalid File';
345
-
346
- dispatch(window, 'ln-toast:enqueue', {
347
- type: 'error',
348
- title: title,
349
- message: message
350
- });
351
- }
352
-
353
- function _handleUploadError(e) {
354
- const detail = e.detail || {};
355
- const dict = _getCoordinatorDict(e.target);
356
- const message = detail.message || dict['upload-failed'] || 'Failed to upload file';
357
- const title = dict['upload-error-title'] || 'Upload Error';
358
-
359
- dispatch(window, 'ln-toast:enqueue', {
360
- type: 'error',
361
- title: title,
362
- message: message
363
- });
364
- }
365
-
366
- document.addEventListener('ln-upload:invalid', _handleUploadInvalid);
367
- document.addEventListener('ln-upload:error', _handleUploadError);
368
-
369
- // ─── Hash Cleanup & Reset on Modal Close ───────────────
370
-
371
- document.addEventListener('ln-modal:close', function (e) {
372
- const modal = e.target;
373
- if (!modal || !modal.lnModal) return;
374
-
375
- if (modal.id) {
376
- if (hashGet(modal.id) !== null) {
377
- hashSet(modal.id, null);
378
- }
379
- }
380
-
381
- if (modal.dataset.lnModalMode === 'new') {
382
- _resetModalForm(modal);
383
- }
384
- });
385
-
386
- // ─── Component Constructor & Registration ────────────
387
-
388
- function _component(dom) {
389
- this.dom = dom;
390
- this.dict = buildDict(dom, DICT_SELECTOR);
391
- return this;
392
- }
393
-
394
- _component.prototype.destroy = function () {
395
- if (!this.dom[DOM_ATTRIBUTE]) return;
396
- this.dict = {};
397
- delete this.dom[DOM_ATTRIBUTE];
398
- };
399
-
400
- registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-ui-coordinator', {
401
- attributes: ATTRIBUTES
402
- });
403
- })();
1
+ import { registerComponent, dispatch, hashGet, hashSet, hashParse, hashLinkClick } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-ui-coordinator';
5
+ const DOM_ATTRIBUTE = 'lnUiCoordinator';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ // ─── Attribute Contract (SSOT) ──────────────────────────
10
+ const ATTRIBUTES = {
11
+ 'data-ln-ui-coordinator': {
12
+ type: 'marker',
13
+ description: 'Mounts UI coordinator mediating global hash-routing, modals, and toasts'
14
+ }
15
+ };
16
+
17
+ // ─── Hash Navigation (Anchors & hashchange) ─────────────
18
+
19
+ document.addEventListener('click', function (e) {
20
+ if (e.ctrlKey || e.metaKey || e.button === 1) return;
21
+
22
+ const hashAnchor = e.target.closest('a[href^="#"]');
23
+ if (!hashAnchor) return;
24
+
25
+ const rawHref = hashAnchor.getAttribute('href');
26
+ const map = hashParse(rawHref);
27
+
28
+ for (const ns in map) {
29
+ const modal = document.getElementById(ns);
30
+ if (modal && modal.lnModal) {
31
+ if (!hashLinkClick(e)) return;
32
+ hashSet(ns, map[ns]);
33
+ if (!modal.lnModal.isOpen) {
34
+ dispatch(modal, 'ln-modal:request-open', {});
35
+ }
36
+ return;
37
+ }
38
+ }
39
+ });
40
+
41
+ function _syncHashModals() {
42
+ const hash = location.hash;
43
+ if (!hash) return;
44
+
45
+ const map = hashParse(hash);
46
+ for (const ns in map) {
47
+ const modal = document.getElementById(ns);
48
+ if (modal && modal.lnModal && !modal.lnModal.isOpen) {
49
+ dispatch(modal, 'ln-modal:request-open', {});
50
+ }
51
+ }
52
+ }
53
+
54
+ window.addEventListener('hashchange', _syncHashModals);
55
+
56
+ if (document.readyState === 'loading') {
57
+ document.addEventListener('DOMContentLoaded', _syncHashModals);
58
+ } else {
59
+ _syncHashModals();
60
+ }
61
+
62
+ // ─── Modal Close Hash Cleanup ───────────────────────────
63
+
64
+ document.addEventListener('ln-modal:close', function (e) {
65
+ const modal = e.target;
66
+ if (modal && modal.id && hashGet(modal.id) !== null) {
67
+ hashSet(modal.id, null);
68
+ }
69
+ });
70
+
71
+ // ─── AJAX Toast & Modal Auto-Close Mediation ───────────
72
+
73
+ document.addEventListener('ln-ajax:success', function (e) {
74
+ const detail = e.detail || {};
75
+ const data = detail.data;
76
+
77
+ // 1. Dispatch toast if server returned a message
78
+ if (data && data.message) {
79
+ const msg = data.message;
80
+ dispatch(window, 'ln-toast:enqueue', {
81
+ type: msg.type || 'success',
82
+ title: msg.title || '',
83
+ message: typeof msg === 'string' ? msg : (msg.body || '')
84
+ });
85
+ }
86
+
87
+ // 2. If submitted inside a modal, close modal and clear URL hash
88
+ const modal = e.target.closest('[data-ln-modal]');
89
+ if (modal && modal.lnModal) {
90
+ if (modal.id && hashGet(modal.id) !== null) {
91
+ hashSet(modal.id, null);
92
+ }
93
+ dispatch(modal, 'ln-modal:request-close', {});
94
+ }
95
+ });
96
+
97
+ document.addEventListener('ln-ajax:error', function (e) {
98
+ const detail = e.detail || {};
99
+ const data = detail.data;
100
+
101
+ // Dispatch toast if server returned an error message
102
+ if (data && data.message) {
103
+ const msg = data.message;
104
+ dispatch(window, 'ln-toast:enqueue', {
105
+ type: msg.type || 'error',
106
+ title: msg.title || '',
107
+ message: typeof msg === 'string' ? msg : (msg.body || '')
108
+ });
109
+ }
110
+ });
111
+
112
+ // ─── Upload Toast Mediation ─────────────────────────────
113
+
114
+ document.addEventListener('ln-upload:error', function (e) {
115
+ const detail = e.detail || {};
116
+ if (detail.message) {
117
+ dispatch(window, 'ln-toast:enqueue', {
118
+ type: 'error',
119
+ title: '',
120
+ message: detail.message
121
+ });
122
+ }
123
+ });
124
+
125
+
126
+ // ─── Component Constructor & Registration ──────────────
127
+
128
+ function _component(dom) {
129
+ this.dom = dom;
130
+ return this;
131
+ }
132
+
133
+ _component.prototype.destroy = function () {
134
+ delete this.dom[DOM_ATTRIBUTE];
135
+ };
136
+
137
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-ui-coordinator', {
138
+ attributes: ATTRIBUTES
139
+ });
140
+ })();
@@ -791,7 +791,7 @@
791
791
  },
792
792
  {
793
793
  "name": "data-ln-modal-for",
794
- "description": "data-ln-modal-for attribute"
794
+ "description": "Target modal ID to open on trigger click"
795
795
  },
796
796
  {
797
797
  "name": "data-ln-modal-mode",
@@ -1655,10 +1655,6 @@
1655
1655
  "description": "Mounts UI coordinator mediating global hash-routing, modals, and toasts",
1656
1656
  "valueSet": "v"
1657
1657
  },
1658
- {
1659
- "name": "data-ln-ui-coordinator-dict",
1660
- "description": "Dictionary key prefix mapping for translatable UI coordinator messages"
1661
- },
1662
1658
  {
1663
1659
  "name": "data-ln-upload",
1664
1660
  "description": "Endpoint URL for file uploads"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livenetworks/ashlar",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "LiveNetworks unified frontend library - CSS framework and vanilla JS components",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "author": "Live Networks",
package/web-types.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/web-types",
3
3
  "name": "@livenetworks/ashlar",
4
- "version": "2.0.4",
4
+ "version": "2.0.5",
5
5
  "framework": "html",
6
6
  "contributions": {
7
7
  "html": {
@@ -1054,7 +1054,7 @@
1054
1054
  },
1055
1055
  {
1056
1056
  "name": "data-ln-modal-for",
1057
- "description": "data-ln-modal-for attribute",
1057
+ "description": "Target modal ID to open on trigger click",
1058
1058
  "value": {
1059
1059
  "type": "string"
1060
1060
  }
@@ -2122,13 +2122,6 @@
2122
2122
  "type": "boolean"
2123
2123
  }
2124
2124
  },
2125
- {
2126
- "name": "data-ln-ui-coordinator-dict",
2127
- "description": "Dictionary key prefix mapping for translatable UI coordinator messages",
2128
- "value": {
2129
- "type": "string"
2130
- }
2131
- },
2132
2125
  {
2133
2126
  "name": "data-ln-upload",
2134
2127
  "description": "Endpoint URL for file uploads",