@livenetworks/ashlar 1.5.1 → 1.6.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.
- package/README.md +189 -177
- package/js/COMPONENTS.md +55 -49
- package/js/index.js +22 -5
- package/js/ln-accordion/README.md +186 -137
- package/js/ln-accordion/ln-accordion.js +1 -1
- package/js/ln-ajax/README.md +73 -4
- package/js/ln-ajax/ln-ajax.js +1 -1
- package/js/ln-ajax/src/ln-ajax.js +13 -13
- package/js/ln-api-connector/README.md +261 -150
- package/js/ln-api-connector/ln-api-connector.js +1 -1
- package/js/ln-api-connector/src/ln-api-connector.js +368 -265
- package/js/ln-api-queue/README.md +219 -0
- package/js/ln-api-queue/ln-api-queue.js +1 -0
- package/js/ln-api-queue/src/ln-api-queue.js +320 -0
- package/js/ln-api-queue/src/queue-storage.js +455 -0
- package/js/ln-autoresize/README.md +47 -0
- package/js/ln-autoresize/ln-autoresize.js +1 -1
- package/js/ln-autosave/README.md +33 -1
- package/js/ln-autosave/ln-autosave.js +1 -1
- package/js/ln-autosave/src/ln-autosave.js +4 -4
- package/js/ln-circular-progress/README.md +27 -13
- package/js/ln-circular-progress/ln-circular-progress.js +1 -1
- package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
- package/js/ln-confirm/README.md +162 -86
- package/js/ln-confirm/_ln-confirm.scss +11 -0
- package/js/ln-confirm/ln-confirm.js +1 -1
- package/js/ln-confirm/src/ln-confirm.js +77 -41
- package/js/ln-core/README.md +896 -0
- package/js/ln-core/hash.js +92 -0
- package/js/ln-core/helpers.js +337 -8
- package/js/ln-core/index.js +4 -2
- package/js/ln-core/persist.js +29 -3
- package/js/ln-core/positioning.js +1 -35
- package/js/ln-core/window-cache.js +213 -0
- package/js/ln-couchdb-connector/README.md +177 -156
- package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
- package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
- package/js/ln-data-coordinator/README.md +536 -165
- package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
- package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
- package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
- package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
- package/js/ln-data-store/README.md +241 -94
- package/js/ln-data-store/ln-data-store.js +1 -1
- package/js/ln-data-store/src/ln-data-store.js +694 -699
- package/js/ln-date/README.md +39 -1
- package/js/ln-date/ln-date-dev.scss +14 -0
- package/js/ln-date/ln-date.js +1 -1
- package/js/ln-date/src/ln-date.js +236 -178
- package/js/ln-debug/README.md +58 -0
- package/js/ln-debug/ln-debug.js +1 -0
- package/js/ln-debug/src/ln-debug.js +19 -0
- package/js/ln-dropdown/README.md +31 -8
- package/js/ln-dropdown/ln-dropdown.js +1 -1
- package/js/ln-dropdown/src/ln-dropdown.js +30 -13
- package/js/ln-editor/README.md +274 -0
- package/js/ln-editor/ln-editor.js +1 -0
- package/js/ln-editor/ln-editor.scss +43 -0
- package/js/ln-editor/src/ln-editor.js +619 -0
- package/js/ln-external-links/README.md +44 -5
- package/js/ln-external-links/ln-external-links.js +1 -1
- package/js/ln-fill/README.md +199 -0
- package/js/ln-fill/ln-fill.js +1 -0
- package/js/ln-fill/src/ln-fill.js +87 -0
- package/js/ln-filter/README.md +92 -2
- package/js/ln-filter/ln-filter-dev.scss +8 -0
- package/js/ln-filter/ln-filter.js +1 -1
- package/js/ln-filter/src/ln-filter.js +22 -7
- package/js/ln-form/README.md +212 -49
- package/js/ln-form/ln-form.js +1 -1
- package/js/ln-form/src/ln-form.js +99 -199
- package/js/ln-http/README.md +125 -89
- package/js/ln-http/ln-http.js +1 -1
- package/js/ln-http/src/ln-http.js +32 -13
- package/js/ln-icon/README.md +130 -0
- package/js/ln-icon/ln-icon.js +1 -0
- package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
- package/js/ln-link/README.md +47 -9
- package/js/ln-link/ln-link.js +1 -1
- package/js/ln-link/src/ln-link.js +22 -6
- package/js/ln-list/README.md +129 -0
- package/js/ln-list/ln-list.js +1 -0
- package/js/ln-list/ln-list.scss +28 -0
- package/js/ln-list/src/ln-list.js +1216 -0
- package/js/ln-modal/README.md +185 -154
- package/js/ln-modal/ln-modal-dev.scss +13 -0
- package/js/ln-modal/ln-modal.js +1 -1
- package/js/ln-modal/ln-modal.scss +28 -4
- package/js/ln-modal/src/ln-modal.js +137 -201
- package/js/ln-modal-coordinator/README.md +99 -0
- package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
- package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
- package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
- package/js/ln-nav/README.md +46 -3
- package/js/ln-nav/ln-nav.js +1 -1
- package/js/ln-nav/src/ln-nav.js +92 -127
- package/js/ln-number/README.md +28 -1
- package/js/ln-number/ln-number-dev.scss +13 -0
- package/js/ln-number/ln-number.js +1 -1
- package/js/ln-number/src/ln-number.js +443 -350
- package/js/ln-options/README.md +66 -0
- package/js/ln-options/ln-options.js +1 -0
- package/js/ln-options/src/ln-options.js +74 -0
- package/js/ln-popover/README.md +40 -5
- package/js/ln-popover/ln-popover.js +1 -1
- package/js/ln-popover/src/ln-popover.js +38 -11
- package/js/ln-progress/README.md +413 -442
- package/js/ln-progress/ln-progress.js +1 -1
- package/js/ln-progress/src/ln-progress.js +98 -150
- package/js/ln-router/README.md +432 -0
- package/js/ln-router/ln-router.js +1 -0
- package/js/ln-router/src/ln-router.js +539 -0
- package/js/ln-search/README.md +158 -83
- package/js/ln-search/ln-search-dev.scss +8 -0
- package/js/ln-search/ln-search.js +1 -1
- package/js/ln-search/src/ln-search.js +12 -9
- package/js/ln-slug/README.md +110 -0
- package/js/ln-slug/ln-slug.js +1 -0
- package/js/ln-slug/src/ln-slug.js +74 -0
- package/js/ln-sort/README.md +170 -0
- package/js/ln-sort/ln-sort-dev.scss +8 -0
- package/js/ln-sort/ln-sort.js +1 -0
- package/js/ln-sort/src/ln-sort.js +178 -0
- package/js/ln-sortable/README.md +133 -95
- package/js/ln-sortable/ln-sortable.js +1 -1
- package/js/ln-stat/README.md +55 -0
- package/js/ln-stat/ln-stat.js +1 -0
- package/js/ln-stat/src/ln-stat.js +55 -0
- package/js/ln-table/README.md +285 -101
- package/js/ln-table/ln-table-dev.scss +17 -0
- package/js/ln-table/ln-table.js +1 -1
- package/js/ln-table/ln-table.scss +1 -1
- package/js/ln-table/src/ln-table.js +1493 -473
- package/js/ln-table-coordinator/README.md +73 -0
- package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
- package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
- package/js/ln-tabs/README.md +221 -137
- package/js/ln-tabs/ln-tabs-dev.scss +18 -0
- package/js/ln-tabs/ln-tabs.js +1 -1
- package/js/ln-tabs/src/ln-tabs.js +41 -24
- package/js/ln-time/README.md +14 -0
- package/js/ln-time/ln-time.js +1 -1
- package/js/ln-time/src/ln-time.js +223 -192
- package/js/ln-toast/README.md +178 -122
- package/js/ln-toast/ln-toast.js +1 -15
- package/js/ln-toast/src/ln-toast.js +232 -210
- package/js/ln-toast/template.html +16 -12
- package/js/ln-toggle/README.md +61 -2
- package/js/ln-toggle/ln-toggle-dev.scss +13 -0
- package/js/ln-toggle/ln-toggle.js +1 -1
- package/js/ln-toggle/src/ln-toggle.js +56 -43
- package/js/ln-tooltip/README.md +95 -58
- package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
- package/js/ln-tooltip/ln-tooltip.js +1 -1
- package/js/ln-tooltip/src/ln-tooltip.js +45 -6
- package/js/ln-translations/README.md +118 -96
- package/js/ln-translations/ln-translations.js +1 -1
- package/js/ln-translations/src/ln-translations.js +10 -5
- package/js/ln-upload/README.md +85 -10
- package/js/ln-upload/ln-upload.js +1 -1
- package/js/ln-upload/src/ln-upload.js +26 -10
- package/js/ln-validate/README.md +72 -0
- package/js/ln-validate/ln-validate-dev.scss +15 -0
- package/js/ln-validate/ln-validate.js +1 -1
- package/js/ln-validate/src/ln-validate.js +64 -0
- package/package.json +61 -56
- package/scss/base/_global.scss +2 -2
- package/scss/base/_typography.scss +1 -2
- package/scss/components/_date.scss +1 -0
- package/scss/components/_dropdown.scss +0 -5
- package/scss/components/_editor.scss +5 -0
- package/scss/components/_filter.scss +26 -25
- package/scss/components/_form.scss +7 -0
- package/scss/components/_ln-table.scss +13 -3
- package/scss/components/_sort.scss +10 -0
- package/scss/components/_table.scss +36 -23
- package/scss/components/_toast.scss +68 -41
- package/scss/components/_toggle.scss +16 -0
- package/scss/config/_density.scss +205 -117
- package/scss/config/_icons.scss +10 -9
- package/scss/config/_tokens.scss +69 -47
- package/scss/config/mixins/_accordion.scss +2 -7
- package/scss/config/mixins/_alert.scss +4 -4
- package/scss/config/mixins/_breadcrumbs.scss +2 -2
- package/scss/config/mixins/_btn.scss +1 -1
- package/scss/config/mixins/_card.scss +89 -14
- package/scss/config/mixins/_collapsible.scss +3 -0
- package/scss/config/mixins/_colors.scss +1 -1
- package/scss/config/mixins/_diagnostics.scss +11 -0
- package/scss/config/mixins/_display.scss +1 -0
- package/scss/config/mixins/_dropdown.scss +3 -5
- package/scss/config/mixins/_editor.scss +124 -0
- package/scss/config/mixins/_empty-state.scss +7 -0
- package/scss/config/mixins/_filter.scss +3 -5
- package/scss/config/mixins/_footer.scss +1 -2
- package/scss/config/mixins/_form.scss +207 -21
- package/scss/config/mixins/_index.scss +2 -0
- package/scss/config/mixins/_ln-table.scss +41 -2
- package/scss/config/mixins/_modal.scss +149 -66
- package/scss/config/mixins/_nav.scss +3 -4
- package/scss/config/mixins/_popover.scss +39 -0
- package/scss/config/mixins/_sidebar.scss +1 -1
- package/scss/config/mixins/_sort.scss +42 -0
- package/scss/config/mixins/_table.scss +85 -60
- package/scss/config/mixins/_toast.scss +179 -152
- package/scss/config/mixins/_tooltip.scss +17 -7
- package/scss/config/mixins/_typography.scss +4 -2
- package/scss/ln-ashlar-dev.scss +27 -9
- package/scss/ln-ashlar.scss +7 -1
- package/js/ln-data-table/README.md +0 -110
- package/js/ln-data-table/ln-data-table.js +0 -1
- package/js/ln-data-table/ln-data-table.scss +0 -10
- package/js/ln-data-table/src/ln-data-table.js +0 -1087
- package/js/ln-icons/README.md +0 -88
- package/js/ln-icons/ln-icons.js +0 -1
- package/js/ln-table/ln-table-sort.js +0 -1
- package/js/ln-table/src/ln-table-sort.js +0 -156
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerComponent, dispatch, dispatchCancelable, guardBody, findElements, shouldInterceptLink
|
|
3
|
+
} from '../../ln-core';
|
|
4
|
+
|
|
5
|
+
// Singleton router implementation
|
|
6
|
+
export const router = {
|
|
7
|
+
navigate: function (fullPath) {
|
|
8
|
+
_navigate(fullPath, { historyAction: 'push' });
|
|
9
|
+
},
|
|
10
|
+
replace: function (fullPath) {
|
|
11
|
+
_navigate(fullPath, { historyAction: 'replace' });
|
|
12
|
+
},
|
|
13
|
+
current: function () {
|
|
14
|
+
if (!currentRoute) return null;
|
|
15
|
+
return {
|
|
16
|
+
path: currentPath,
|
|
17
|
+
params: currentParams,
|
|
18
|
+
query: currentQuery,
|
|
19
|
+
route: currentRoute,
|
|
20
|
+
regions: currentRegions
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DOM_SELECTOR = 'data-ln-route';
|
|
26
|
+
const DOM_ATTRIBUTE = 'lnRoute';
|
|
27
|
+
|
|
28
|
+
if (typeof window !== 'undefined') {
|
|
29
|
+
window.lnRouter = router;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Per-region registry: Map<regionKey, { routes: Map<pattern, routeMetadata>, sorted: routeMetadata[] }>
|
|
33
|
+
// regionKey = data-ln-route-target id, or '__primary__' for the default outlet
|
|
34
|
+
const regionRegistry = new Map();
|
|
35
|
+
|
|
36
|
+
// Tracks which template is currently mounted in each keep-region target element
|
|
37
|
+
const mountedTemplates = new WeakMap();
|
|
38
|
+
|
|
39
|
+
// Per-region state snapshot (updated after each navigation)
|
|
40
|
+
let currentRegions = new Map();
|
|
41
|
+
|
|
42
|
+
let booted = false;
|
|
43
|
+
let currentPath = null;
|
|
44
|
+
let currentParams = {};
|
|
45
|
+
let currentQuery = {};
|
|
46
|
+
let currentRoute = null;
|
|
47
|
+
let _booting = false;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Dispatch an event immediately, or defer one microtask when called from the boot path.
|
|
51
|
+
* Boot-path dispatches are deferred so listeners registered in the same DOMContentLoaded
|
|
52
|
+
* burst (i.e. after the <script> that loaded this bundle) always receive the event.
|
|
53
|
+
* All subsequent (click/popstate/programmatic) navigations dispatch synchronously.
|
|
54
|
+
*/
|
|
55
|
+
function _dispatchMaybeDeferred(target, name, detail) {
|
|
56
|
+
if (_booting) {
|
|
57
|
+
queueMicrotask(function () { dispatch(target, name, detail); });
|
|
58
|
+
} else {
|
|
59
|
+
dispatch(target, name, detail);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize path: collapse trailing slash, strip query/hash, parse query.
|
|
65
|
+
*/
|
|
66
|
+
function _normalizePath(fullPath) {
|
|
67
|
+
// Absolute same-origin URLs (route() helpers, anchor.href) → reduce to
|
|
68
|
+
// path+query+hash. new URL() handles both absolute (http://host/a/b) and
|
|
69
|
+
// already-relative (/a/b) inputs identically. Leave fullPath unchanged if
|
|
70
|
+
// it is not a parseable URL.
|
|
71
|
+
try {
|
|
72
|
+
const u = new URL(fullPath, window.location.origin);
|
|
73
|
+
fullPath = u.pathname + u.search + u.hash;
|
|
74
|
+
} catch (e) { /* not a parseable URL — use fullPath as-is */ }
|
|
75
|
+
|
|
76
|
+
let [pathAndQuery] = fullPath.split('#');
|
|
77
|
+
let [path, queryString] = pathAndQuery.split('?');
|
|
78
|
+
|
|
79
|
+
const query = {};
|
|
80
|
+
if (queryString) {
|
|
81
|
+
const searchParams = new URLSearchParams(queryString);
|
|
82
|
+
for (const [key, value] of searchParams.entries()) {
|
|
83
|
+
query[key] = value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
path = path.replace(/\/+$/, '');
|
|
88
|
+
if (path === '') {
|
|
89
|
+
path = '/';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { path, query };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compare specificity of two route metadata objects.
|
|
97
|
+
* Returns negative if a is more specific, positive if b is (descending order).
|
|
98
|
+
*/
|
|
99
|
+
function _compareSpecificity(a, b) {
|
|
100
|
+
if (a.pattern === '*') return 1;
|
|
101
|
+
if (b.pattern === '*') return -1;
|
|
102
|
+
|
|
103
|
+
const aSegs = a.segments;
|
|
104
|
+
const bSegs = b.segments;
|
|
105
|
+
const len = Math.max(aSegs.length, bSegs.length);
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < len; i++) {
|
|
108
|
+
const aSeg = aSegs[i];
|
|
109
|
+
const bSeg = bSegs[i];
|
|
110
|
+
|
|
111
|
+
if (aSeg === undefined) return 1;
|
|
112
|
+
if (bSeg === undefined) return -1;
|
|
113
|
+
|
|
114
|
+
if (aSeg === '*') return 1;
|
|
115
|
+
if (bSeg === '*') return -1;
|
|
116
|
+
|
|
117
|
+
const aIsParam = aSeg.startsWith(':');
|
|
118
|
+
const bIsParam = bSeg.startsWith(':');
|
|
119
|
+
|
|
120
|
+
if (aIsParam && !bIsParam) return 1;
|
|
121
|
+
if (!aIsParam && bIsParam) return -1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Match path to a route in the provided sorted routes array.
|
|
129
|
+
* Per-region: accepts the region's own sorted array rather than a module-level variable.
|
|
130
|
+
*/
|
|
131
|
+
function _matchRouteInRegion(path, sortedRoutes) {
|
|
132
|
+
const pathSegs = path.split('/').filter(Boolean);
|
|
133
|
+
|
|
134
|
+
for (const route of sortedRoutes) {
|
|
135
|
+
if (route.pattern === '*') {
|
|
136
|
+
return {
|
|
137
|
+
route,
|
|
138
|
+
params: { wildcard: path }
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const routeSegs = route.segments;
|
|
143
|
+
const params = {};
|
|
144
|
+
let matched = true;
|
|
145
|
+
|
|
146
|
+
if (pathSegs.length > routeSegs.length && routeSegs[routeSegs.length - 1] !== '*') {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < routeSegs.length; i++) {
|
|
151
|
+
const rSeg = routeSegs[i];
|
|
152
|
+
const pSeg = pathSegs[i];
|
|
153
|
+
|
|
154
|
+
if (rSeg === '*') {
|
|
155
|
+
params.wildcard = pathSegs.slice(i).join('/');
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (pSeg === undefined) {
|
|
160
|
+
matched = false;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (rSeg.startsWith(':')) {
|
|
165
|
+
params[rSeg.slice(1)] = decodeURIComponent(pSeg);
|
|
166
|
+
} else if (rSeg !== pSeg) {
|
|
167
|
+
matched = false;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (matched) {
|
|
173
|
+
const hasWildcard = routeSegs.indexOf('*') !== -1;
|
|
174
|
+
if (hasWildcard || pathSegs.length <= routeSegs.length) {
|
|
175
|
+
return { route, params };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve target DOM element for a region.
|
|
184
|
+
* Primary region (__primary__) uses the default outlet fallback chain.
|
|
185
|
+
* Auxiliary regions resolve by element id (route.target).
|
|
186
|
+
*/
|
|
187
|
+
function _resolveRegionTarget(regionKey, route) {
|
|
188
|
+
if (regionKey !== '__primary__') {
|
|
189
|
+
const el = document.getElementById(route.target);
|
|
190
|
+
if (!el) {
|
|
191
|
+
console.warn(`[ln-router] Explicit target element #${route.target} not found in DOM`);
|
|
192
|
+
}
|
|
193
|
+
return el;
|
|
194
|
+
}
|
|
195
|
+
const defaultOutlet = document.querySelector('[data-ln-outlet]') || document.querySelector('main');
|
|
196
|
+
if (!defaultOutlet) {
|
|
197
|
+
console.warn('[ln-router] Default outlet (element with [data-ln-outlet] or <main>) not found in DOM');
|
|
198
|
+
}
|
|
199
|
+
return defaultOutlet;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Perform teardown on all active components in the outgoing outlet tree.
|
|
204
|
+
*/
|
|
205
|
+
function _teardownOutlet(target) {
|
|
206
|
+
if (!target) return;
|
|
207
|
+
|
|
208
|
+
// 1. Traverse and destroy elements in the target subtree
|
|
209
|
+
const descendants = Array.from(target.querySelectorAll('*'));
|
|
210
|
+
const allElements = [target].concat(descendants);
|
|
211
|
+
|
|
212
|
+
for (const el of allElements) {
|
|
213
|
+
for (const key of Object.keys(el)) {
|
|
214
|
+
if (key.startsWith('ln') && el[key] && typeof el[key].destroy === 'function') {
|
|
215
|
+
try {
|
|
216
|
+
el[key].destroy();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
console.error(`[ln-router] Error destroying component ${key} on element:`, el, e);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 2. Close open popovers whose trigger lived inside the torn-down target. Popovers authored
|
|
225
|
+
// inside the outlet are already caught by the generic ln* destroy loop above (step 1) once
|
|
226
|
+
// they are real descendants again (no more DOM move on open); this second pass only matters for
|
|
227
|
+
// popovers authored outside the outlet (e.g. shell-level) but opened by a trigger inside it.
|
|
228
|
+
const popovers = document.querySelectorAll('[data-ln-popover="open"]');
|
|
229
|
+
for (const popover of popovers) {
|
|
230
|
+
const inst = popover.lnPopover;
|
|
231
|
+
if (inst && inst.trigger && target.contains(inst.trigger)) {
|
|
232
|
+
try {
|
|
233
|
+
inst.destroy();
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error('[ln-router] Error destroying open popover:', e);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Multi-region navigation pipeline.
|
|
243
|
+
* Matches path against every region's route table, computes which regions swap,
|
|
244
|
+
* fires one cancelable before-navigate on the primary outlet, then atomically
|
|
245
|
+
* swaps all regions that need it inside a single view transition.
|
|
246
|
+
*/
|
|
247
|
+
function _navigate(fullPath, opts = {}) {
|
|
248
|
+
const { path, query } = _normalizePath(fullPath);
|
|
249
|
+
|
|
250
|
+
// 1. Per-region match
|
|
251
|
+
const regionMatches = new Map();
|
|
252
|
+
for (const [regionKey, regionData] of regionRegistry) {
|
|
253
|
+
regionMatches.set(regionKey, _matchRouteInRegion(path, regionData.sorted));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const hasPrimaryRegion = regionRegistry.has('__primary__');
|
|
257
|
+
const primaryMatch = regionMatches.get('__primary__'); // undefined if no primary region
|
|
258
|
+
|
|
259
|
+
// 2. Not-found — ONLY when a primary region exists but did not match.
|
|
260
|
+
// Auxiliary-only pages (no '__primary__' region) skip not-found and
|
|
261
|
+
// proceed to paint their auxiliary regions. Use _dispatchMaybeDeferred
|
|
262
|
+
// so the boot path defers exactly like the legacy code did.
|
|
263
|
+
if (hasPrimaryRegion && !primaryMatch) {
|
|
264
|
+
_dispatchMaybeDeferred(document.body, 'ln-router:not-found', { path });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3 + 4. Resolve primary outlet and fire the single cancelable
|
|
269
|
+
// before-navigate — both conditional on a primary MATCH.
|
|
270
|
+
let primaryTarget = null;
|
|
271
|
+
if (primaryMatch) {
|
|
272
|
+
primaryTarget = _resolveRegionTarget('__primary__', primaryMatch.route);
|
|
273
|
+
if (!primaryTarget) return; // warn already emitted in _resolveRegionTarget
|
|
274
|
+
|
|
275
|
+
const beforeEvent = dispatchCancelable(primaryTarget, 'ln-router:before-navigate', {
|
|
276
|
+
from: currentPath,
|
|
277
|
+
to: fullPath,
|
|
278
|
+
params: primaryMatch.params,
|
|
279
|
+
query
|
|
280
|
+
});
|
|
281
|
+
if (beforeEvent.defaultPrevented) return; // aborts ALL regions + history
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 5. Compute swap plans — which regions will actually change DOM
|
|
285
|
+
const swapPlans = [];
|
|
286
|
+
for (const [regionKey, match] of regionMatches) {
|
|
287
|
+
if (!match) continue;
|
|
288
|
+
const targetEl = _resolveRegionTarget(regionKey, match.route);
|
|
289
|
+
if (!targetEl) continue;
|
|
290
|
+
|
|
291
|
+
// Keep-skip applies to AUXILIARY regions only — primary is always active.
|
|
292
|
+
if (regionKey !== '__primary__' && targetEl.hasAttribute('data-ln-route-keep')) {
|
|
293
|
+
const currentTmpl = mountedTemplates.get(targetEl);
|
|
294
|
+
if (currentTmpl === match.route.templateNode) {
|
|
295
|
+
// Same template — skip (keep-region state survival)
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
swapPlans.push({ regionKey, match, targetEl });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 6. History update (once per navigation)
|
|
303
|
+
if (opts.historyAction === 'push') {
|
|
304
|
+
window.history.pushState(null, '', fullPath);
|
|
305
|
+
} else if (opts.historyAction === 'replace') {
|
|
306
|
+
window.history.replaceState(null, '', fullPath);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 7. Atomic swap — one view transition wraps all region swaps
|
|
310
|
+
const executeSwaps = function () {
|
|
311
|
+
for (const { regionKey, match, targetEl } of swapPlans) {
|
|
312
|
+
// Per-region hydration check
|
|
313
|
+
const isRegionHydration = opts.isHydration
|
|
314
|
+
&& targetEl.hasAttribute('data-ln-router-hydrate')
|
|
315
|
+
&& targetEl.children.length > 0;
|
|
316
|
+
|
|
317
|
+
if (!isRegionHydration) {
|
|
318
|
+
_teardownOutlet(targetEl);
|
|
319
|
+
const clone = match.route.templateNode.content.cloneNode(true);
|
|
320
|
+
targetEl.replaceChildren(clone);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Track mounted template for keep-region logic — recorded even on
|
|
324
|
+
// hydration so the keep diff has a baseline after boot.
|
|
325
|
+
mountedTemplates.set(targetEl, match.route.templateNode);
|
|
326
|
+
|
|
327
|
+
// Focus, scroll, and title — primary region only
|
|
328
|
+
if (regionKey === '__primary__') {
|
|
329
|
+
if (match.route.title) {
|
|
330
|
+
document.title = match.route.title;
|
|
331
|
+
}
|
|
332
|
+
if (!opts.isHydration) {
|
|
333
|
+
if (!targetEl.hasAttribute('tabindex')) {
|
|
334
|
+
targetEl.setAttribute('tabindex', '-1');
|
|
335
|
+
}
|
|
336
|
+
const firstHeading = targetEl.querySelector('h1, h2, h3, h4, h5, h6');
|
|
337
|
+
if (firstHeading) {
|
|
338
|
+
firstHeading.setAttribute('tabindex', '-1');
|
|
339
|
+
firstHeading.focus();
|
|
340
|
+
} else {
|
|
341
|
+
targetEl.focus();
|
|
342
|
+
}
|
|
343
|
+
targetEl.scrollIntoView({ block: 'start', behavior: 'instant' });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Dispatch navigated per swapped region
|
|
348
|
+
_dispatchMaybeDeferred(targetEl, 'ln-router:navigated', {
|
|
349
|
+
path: fullPath,
|
|
350
|
+
params: match.params,
|
|
351
|
+
query,
|
|
352
|
+
route: match.route,
|
|
353
|
+
target: targetEl,
|
|
354
|
+
region: regionKey
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Update global primary state — only when a primary match exists.
|
|
359
|
+
// On auxiliary-only pages currentRoute stays null (current() returns null).
|
|
360
|
+
if (primaryMatch) {
|
|
361
|
+
currentPath = fullPath;
|
|
362
|
+
currentParams = primaryMatch.params;
|
|
363
|
+
currentQuery = query;
|
|
364
|
+
currentRoute = primaryMatch.route;
|
|
365
|
+
}
|
|
366
|
+
// currentRegions reflects every region's match (null when unmatched).
|
|
367
|
+
// Built from regionMatches — never from the loop variable `match`.
|
|
368
|
+
currentRegions = new Map(
|
|
369
|
+
Array.from(regionMatches.entries()).map(([k, m]) => [k, m ? { route: m.route, params: m.params } : null])
|
|
370
|
+
);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
if (document.startViewTransition && !opts.isHydration) {
|
|
374
|
+
document.startViewTransition(executeSwaps);
|
|
375
|
+
} else {
|
|
376
|
+
executeSwaps();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Click & Popstate Handlers ─────────────────────────────
|
|
381
|
+
|
|
382
|
+
function _onClick(e) {
|
|
383
|
+
const anchor = e.target.closest('a');
|
|
384
|
+
if (!anchor || !shouldInterceptLink(e, anchor)) return;
|
|
385
|
+
|
|
386
|
+
const fullPath = anchor.getAttribute('href');
|
|
387
|
+
const { path } = _normalizePath(fullPath);
|
|
388
|
+
|
|
389
|
+
// Intercept only if primary region has a match (auxiliary-only matches don't drive SPA nav)
|
|
390
|
+
const primaryData = regionRegistry.get('__primary__');
|
|
391
|
+
if (!primaryData) return;
|
|
392
|
+
const match = _matchRouteInRegion(path, primaryData.sorted);
|
|
393
|
+
if (match) {
|
|
394
|
+
e.preventDefault();
|
|
395
|
+
_navigate(fullPath, { historyAction: 'push' });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function _queryEqual(a, b) {
|
|
400
|
+
const ak = Object.keys(a);
|
|
401
|
+
const bk = Object.keys(b);
|
|
402
|
+
if (ak.length !== bk.length) return false;
|
|
403
|
+
for (let i = 0; i < ak.length; i++) {
|
|
404
|
+
const k = ak[i];
|
|
405
|
+
if (a[k] !== b[k]) return false;
|
|
406
|
+
}
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function _onPopState() {
|
|
411
|
+
const fullPath = window.location.pathname + window.location.search;
|
|
412
|
+
// Fragment-only popstate guard: when Back/Forward changes ONLY the hash
|
|
413
|
+
// (path + query identical to the current SPA state), skip navigation so the
|
|
414
|
+
// primary outlet is not torn down and re-cloned. Hash-bound components
|
|
415
|
+
// (e.g. ln-modal) react to the accompanying hashchange independently.
|
|
416
|
+
//
|
|
417
|
+
// cur.query is the authoritative already-parsed query for the active route
|
|
418
|
+
// (currentQuery), so compare target.query against it directly. cur.path is
|
|
419
|
+
// the raw fullPath (retains the query string), so normalize it for the
|
|
420
|
+
// pathname comparison only — do NOT re-parse it for the query.
|
|
421
|
+
const cur = router.current();
|
|
422
|
+
if (cur && cur.path != null) {
|
|
423
|
+
const target = _normalizePath(fullPath);
|
|
424
|
+
const currentPathNorm = _normalizePath(cur.path).path;
|
|
425
|
+
if (currentPathNorm === target.path && _queryEqual(cur.query, target.query)) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
_navigate(fullPath, { historyAction: 'skip' });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── Boot & Initial Render ─────────────────────────────────
|
|
433
|
+
|
|
434
|
+
function _boot() {
|
|
435
|
+
if (booted) return;
|
|
436
|
+
booted = true;
|
|
437
|
+
|
|
438
|
+
guardBody(function () {
|
|
439
|
+
document.addEventListener('click', _onClick);
|
|
440
|
+
window.addEventListener('popstate', _onPopState);
|
|
441
|
+
|
|
442
|
+
// _booting = true defers navigated/not-found one microtask so listeners
|
|
443
|
+
// registered in the same DOMContentLoaded burst still receive boot events.
|
|
444
|
+
_booting = true;
|
|
445
|
+
// Include the fragment so deep-link hash state survives boot. _navigate's
|
|
446
|
+
// own replaceState would otherwise rewrite the URL without it, silently
|
|
447
|
+
// dropping the fragment on every initial load. Hash-addressable
|
|
448
|
+
// components (e.g. ln-modal #id:param deep links) depend on the fragment
|
|
449
|
+
// being intact at boot to open in edit mode and fill from the matching
|
|
450
|
+
// source. _normalizePath strips the fragment before route matching and
|
|
451
|
+
// query parse, so this changes ONLY the preserved replaceState URL.
|
|
452
|
+
const fullPath = window.location.pathname + window.location.search + window.location.hash;
|
|
453
|
+
_navigate(fullPath, { historyAction: 'replace', isHydration: true });
|
|
454
|
+
_booting = false;
|
|
455
|
+
}, 'ln-router');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ─── Route Registration ────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
function _registerRoute(tmpl) {
|
|
461
|
+
const pattern = tmpl.getAttribute(DOM_SELECTOR);
|
|
462
|
+
if (!pattern) return;
|
|
463
|
+
|
|
464
|
+
const targetId = tmpl.getAttribute('data-ln-route-target') || null;
|
|
465
|
+
|
|
466
|
+
// Reserved-word guard: '__primary__' is the internal sentinel for the
|
|
467
|
+
// default outlet. A template targeting an element with this id would
|
|
468
|
+
// collide and silently render into the default outlet. Reject + warn.
|
|
469
|
+
if (targetId === '__primary__') {
|
|
470
|
+
console.warn(`[ln-router] "__primary__" is a reserved region key and cannot be used as data-ln-route-target. Route "${pattern}" rejected.`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const regionKey = targetId || '__primary__';
|
|
475
|
+
|
|
476
|
+
if (!regionRegistry.has(regionKey)) {
|
|
477
|
+
regionRegistry.set(regionKey, { routes: new Map(), sorted: [] });
|
|
478
|
+
}
|
|
479
|
+
const region = regionRegistry.get(regionKey);
|
|
480
|
+
|
|
481
|
+
// Duplicate guard is now per (pattern, regionKey) pair
|
|
482
|
+
if (region.routes.has(pattern)) {
|
|
483
|
+
console.warn(`[ln-router] Duplicate route pattern registered: "${pattern}" in region "${regionKey}"`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const title = tmpl.getAttribute('data-ln-route-title');
|
|
488
|
+
const segments = pattern.split('/').filter(Boolean);
|
|
489
|
+
|
|
490
|
+
const routeMetadata = {
|
|
491
|
+
pattern,
|
|
492
|
+
segments,
|
|
493
|
+
target: targetId,
|
|
494
|
+
title,
|
|
495
|
+
templateNode: tmpl
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Warn if template is declared inside its own outlet
|
|
499
|
+
const resolvedTarget = _resolveRegionTarget(regionKey, routeMetadata);
|
|
500
|
+
if (resolvedTarget && resolvedTarget.contains(tmpl)) {
|
|
501
|
+
console.warn(`[ln-router] Route template with pattern "${pattern}" is declared inside its own outlet element:`, tmpl);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
region.routes.set(pattern, routeMetadata);
|
|
505
|
+
region.sorted = Array.from(region.routes.values()).sort(_compareSpecificity);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function _unregisterRoute(tmpl) {
|
|
509
|
+
const pattern = tmpl.getAttribute(DOM_SELECTOR);
|
|
510
|
+
if (!pattern) return;
|
|
511
|
+
const targetId = tmpl.getAttribute('data-ln-route-target') || null;
|
|
512
|
+
const regionKey = targetId || '__primary__';
|
|
513
|
+
const region = regionRegistry.get(regionKey);
|
|
514
|
+
if (!region) return;
|
|
515
|
+
region.routes.delete(pattern);
|
|
516
|
+
region.sorted = Array.from(region.routes.values()).sort(_compareSpecificity);
|
|
517
|
+
if (region.routes.size === 0) {
|
|
518
|
+
regionRegistry.delete(regionKey);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ─── Component Factory ─────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
function _component(dom) {
|
|
525
|
+
this.dom = dom;
|
|
526
|
+
_registerRoute(dom);
|
|
527
|
+
return this;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
_component.prototype.destroy = function () {
|
|
531
|
+
_unregisterRoute(this.dom);
|
|
532
|
+
delete this.dom[DOM_ATTRIBUTE];
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// registerComponent feeds registerRoute via onInit
|
|
536
|
+
registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-router', {
|
|
537
|
+
extraAttributes: ['data-ln-route-target', 'data-ln-route-title'],
|
|
538
|
+
onInit: function () { if (regionRegistry.size > 0) _boot(); }
|
|
539
|
+
});
|