@livenetworks/ashlar 1.4.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 +189 -99
- 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 -55
- 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 -0
- package/scss/components/_form.scss +7 -0
- package/scss/components/_ln-table.scss +14 -5
- package/scss/components/_sort.scss +10 -0
- package/scss/components/_table.scss +68 -1
- 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 +48 -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 +47 -0
- package/scss/config/mixins/_footer.scss +1 -2
- package/scss/config/mixins/_form.scss +207 -21
- package/scss/config/mixins/_index.scss +4 -1
- package/scss/config/mixins/_ln-table.scss +51 -93
- 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 +232 -38
- 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 +39 -0
- package/scss/ln-ashlar.scss +8 -2
- 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 -1103
- 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 -168
- package/scss/components/_data-table.scss +0 -83
- package/scss/config/mixins/_data-table.scss +0 -346
|
@@ -96,11 +96,16 @@ import { dispatch } from '../../ln-core';
|
|
|
96
96
|
// Combine consumer signal with wrapper controller.
|
|
97
97
|
const controller = new AbortController();
|
|
98
98
|
const consumerSignal = options.signal;
|
|
99
|
+
let abortHandler = null;
|
|
99
100
|
if (consumerSignal) {
|
|
100
|
-
if (consumerSignal.aborted)
|
|
101
|
-
else consumerSignal.addEventListener('abort', function () {
|
|
101
|
+
if (consumerSignal.aborted) {
|
|
102
102
|
controller.abort(consumerSignal.reason);
|
|
103
|
-
}
|
|
103
|
+
} else {
|
|
104
|
+
abortHandler = function () {
|
|
105
|
+
controller.abort(consumerSignal.reason);
|
|
106
|
+
};
|
|
107
|
+
consumerSignal.addEventListener('abort', abortHandler, { once: true });
|
|
108
|
+
}
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
const merged = Object.assign({}, options, { signal: controller.signal });
|
|
@@ -108,6 +113,10 @@ import { dispatch } from '../../ln-core';
|
|
|
108
113
|
_inflight.set(key, controller);
|
|
109
114
|
|
|
110
115
|
return _origFetch(resource, merged).finally(function () {
|
|
116
|
+
// Clean up listener to prevent memory leak
|
|
117
|
+
if (consumerSignal && abortHandler) {
|
|
118
|
+
consumerSignal.removeEventListener('abort', abortHandler);
|
|
119
|
+
}
|
|
111
120
|
// Only clear if THIS controller is still the one in the map.
|
|
112
121
|
if (_inflight.get(key) === controller) _inflight.delete(key);
|
|
113
122
|
});
|
|
@@ -119,12 +128,11 @@ import { dispatch } from '../../ln-core';
|
|
|
119
128
|
// ─── Path B: ln-http:request event listener ───────────────────
|
|
120
129
|
|
|
121
130
|
function _onRequest(e) {
|
|
122
|
-
|
|
123
|
-
if (!opts.url) return;
|
|
131
|
+
if (!e.detail || !e.detail.url) return;
|
|
124
132
|
|
|
125
133
|
const target = e.target;
|
|
126
|
-
const method = (
|
|
127
|
-
const userKey =
|
|
134
|
+
const method = (e.detail.method || (e.detail.body ? 'POST' : 'GET')).toUpperCase();
|
|
135
|
+
const userKey = e.detail.key;
|
|
128
136
|
|
|
129
137
|
// Explicit-key dedup — abort previous on same key, any method.
|
|
130
138
|
if (userKey && _keyed.has(userKey)) {
|
|
@@ -134,24 +142,32 @@ import { dispatch } from '../../ln-core';
|
|
|
134
142
|
|
|
135
143
|
// Combine consumer signal (if any) with our own controller.
|
|
136
144
|
const controller = new AbortController();
|
|
137
|
-
const consumerSignal =
|
|
145
|
+
const consumerSignal = e.detail.signal;
|
|
146
|
+
let abortHandler = null;
|
|
138
147
|
if (consumerSignal) {
|
|
139
|
-
if (consumerSignal.aborted)
|
|
140
|
-
else consumerSignal.addEventListener('abort', function () {
|
|
148
|
+
if (consumerSignal.aborted) {
|
|
141
149
|
controller.abort(consumerSignal.reason);
|
|
142
|
-
}
|
|
150
|
+
} else {
|
|
151
|
+
abortHandler = function () {
|
|
152
|
+
controller.abort(consumerSignal.reason);
|
|
153
|
+
};
|
|
154
|
+
consumerSignal.addEventListener('abort', abortHandler, { once: true });
|
|
155
|
+
}
|
|
143
156
|
}
|
|
144
157
|
|
|
145
158
|
if (userKey) _keyed.set(userKey, controller);
|
|
146
159
|
|
|
147
160
|
const fetchOptions = { method: method, signal: controller.signal };
|
|
148
|
-
if (
|
|
161
|
+
if (e.detail.body !== undefined) fetchOptions.body = e.detail.body;
|
|
149
162
|
|
|
150
163
|
// fetch() here IS the wrapped fetch — Path A still applies
|
|
151
164
|
// for GET/HEAD on top of Path B's explicit-key dedup. Aborts
|
|
152
165
|
// from either side are idempotent.
|
|
153
|
-
window.fetch(
|
|
166
|
+
window.fetch(e.detail.url, fetchOptions)
|
|
154
167
|
.then(function (response) {
|
|
168
|
+
if (consumerSignal && abortHandler) {
|
|
169
|
+
consumerSignal.removeEventListener('abort', abortHandler);
|
|
170
|
+
}
|
|
155
171
|
if (userKey && _keyed.get(userKey) === controller) _keyed.delete(userKey);
|
|
156
172
|
dispatch(target, 'ln-http:response', {
|
|
157
173
|
ok: response.ok,
|
|
@@ -160,6 +176,9 @@ import { dispatch } from '../../ln-core';
|
|
|
160
176
|
});
|
|
161
177
|
})
|
|
162
178
|
.catch(function (err) {
|
|
179
|
+
if (consumerSignal && abortHandler) {
|
|
180
|
+
consumerSignal.removeEventListener('abort', abortHandler);
|
|
181
|
+
}
|
|
163
182
|
if (userKey && _keyed.get(userKey) === controller) _keyed.delete(userKey);
|
|
164
183
|
if (err && err.name === 'AbortError') return; // silent on abort
|
|
165
184
|
dispatch(target, 'ln-http:error', {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# ln-icon
|
|
2
|
+
|
|
3
|
+
A zero-dependency, local-first **On-Demand SVG Sprite Generator** that dynamically monitors, fetches, and compiles SVG icons at runtime.
|
|
4
|
+
|
|
5
|
+
Instead of bundling thousands of heavy vector paths or requiring complex manual build steps, it intercepts standard DOM `<use>` tags, fetches vector definitions from a remote CDN, caches them in `localStorage`, and injects them into a single unified hidden SVG sprite sheet.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🧭 Philosophy & Architecture
|
|
10
|
+
|
|
11
|
+
1. **Declarative On-Demand Rendering:** Icons are declared directly in HTML. The component monitors the DOM via `MutationObserver` for `<use>` references with `#ln-` and `#lnc-` prefixes. It only fetches and compiles icons that are actively present on the page.
|
|
12
|
+
2. **Dual-Prefix Routing:**
|
|
13
|
+
- **`#ln-{name}`**: Automatically routes to the [Tabler Icons](https://tabler.io/icons) library fetched from a public CDN. No configuration required.
|
|
14
|
+
- **`#lnc-{name}`**: Routes to a custom corporate CDN defined via global window settings.
|
|
15
|
+
3. **Local Caching Layer:** Fetched SVG path structures are instantly cached in `localStorage` under `lni:{id}`. Subsequent visits render icons instantly with zero network roundtrips.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 📦 Minimal Blueprint
|
|
20
|
+
|
|
21
|
+
### Native Tabler Icon
|
|
22
|
+
```html
|
|
23
|
+
<svg class="ln-icon" aria-hidden="true">
|
|
24
|
+
<use href="#ln-icon-home"></use>
|
|
25
|
+
</svg>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Custom Asset Icon
|
|
29
|
+
Define your custom CDN endpoint before importing the library:
|
|
30
|
+
```html
|
|
31
|
+
<script>
|
|
32
|
+
window.LN_ICON_CUSTOM_CDN = "https://cdn.mycompany.com/assets/icons";
|
|
33
|
+
</script>
|
|
34
|
+
<script src="dist/ln-ashlar.iife.js" defer></script>
|
|
35
|
+
|
|
36
|
+
<!-- Renders icon from your custom CDN -->
|
|
37
|
+
<svg class="ln-icon" aria-hidden="true">
|
|
38
|
+
<use href="#ln-icon-custom-corporate-logo"></use>
|
|
39
|
+
</svg>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 🛠️ Declarative API Contract
|
|
45
|
+
|
|
46
|
+
### CSS Utility Classes
|
|
47
|
+
|
|
48
|
+
Configure icon sizes and alignments using standard CSS classes:
|
|
49
|
+
|
|
50
|
+
| Class | Size | Description |
|
|
51
|
+
| :--- | :--- | :--- |
|
|
52
|
+
| `ln-icon` | `1.25rem` | Base styles, sets `fill: none`, `stroke: currentColor`, inherits color. |
|
|
53
|
+
| `ln-icon--sm` | `1rem` | Small icon, designed for inline text badges or buttons. |
|
|
54
|
+
| `ln-icon--lg` | `1.5rem` | Large icon, designed for toolbar buttons. |
|
|
55
|
+
| `ln-icon--xl` | `4rem` | Extra-large icon, designed for empty state illustrations. |
|
|
56
|
+
| `ln-chevron` | — | Automatically rotates `90deg` when an ancestor `.is-active` class is toggled. |
|
|
57
|
+
|
|
58
|
+
### Global Configuration (`window`)
|
|
59
|
+
|
|
60
|
+
Configure these properties before script initialization:
|
|
61
|
+
|
|
62
|
+
| Variable | Default | Description |
|
|
63
|
+
| :--- | :--- | :--- |
|
|
64
|
+
| `LN_ICON_CDN` | `https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline` | Base CDN URL for Tabler Icons. |
|
|
65
|
+
| `LN_ICON_CUSTOM_CDN` | `null` | Base CDN URL for custom `#lnc-` prefixed SVG resources. |
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## ⚡ Dynamic Interaction Flow
|
|
70
|
+
|
|
71
|
+
### Automated Mutation Observability
|
|
72
|
+
The loader observes the DOM continuously. When new content is injected (e.g. by `ln-ajax` or `ln-store`), any new icon `<use>` tag is intercepted, resolved, and rendered.
|
|
73
|
+
|
|
74
|
+
### Dynamic Attribute Swaps
|
|
75
|
+
Modifying the `href` attribute of a `<use>` element dynamically via JavaScript triggers automatic resolution of the new target icon:
|
|
76
|
+
```javascript
|
|
77
|
+
const useElement = document.querySelector('use');
|
|
78
|
+
// Dynamically fetches and switches the icon to a checkmark
|
|
79
|
+
useElement.setAttribute('href', '#ln-icon-check');
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## ⚠️ Common Pitfalls
|
|
85
|
+
|
|
86
|
+
- **Forgetting `ln-icon` Class:** Standard SVGs default to `100%` width/height. Failing to include the `ln-icon` class will cause the icon to blow up to full viewport size.
|
|
87
|
+
- **Incorrect Prefix Configuration:** Forgetting to define `window.LN_ICON_CUSTOM_CDN` when using `#lnc-` will cause the loader to fail silently with undefined endpoint errors.
|
|
88
|
+
- **Omitting `aria-hidden="true"`:** Screen readers attempt to read SVG nodes. Always decorate decorative icons with `aria-hidden="true"`, or include an `aria-label` on their parent button.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 🔧 Internals
|
|
93
|
+
|
|
94
|
+
Source: `js/ln-icon/ln-icon.js`. Routing is purely prefix-based — no config lists, no runtime mapping. Symbol IDs in the generated sprite mirror the full `href` value minus `#` (e.g. `#ln-icon-custom-file-pdf` → `<symbol id="ln-icon-custom-file-pdf">`).
|
|
95
|
+
|
|
96
|
+
### Scan
|
|
97
|
+
|
|
98
|
+
`_scan(root)` runs on init (`document`) and on every MutationObserver batch (each added node), matching `use[href^="#ln-icon-"], use[href^="#ln-icon-custom-"]` — the root node itself is also checked via `matches()` to cover single-node mutations. Each match calls `_load(href)`.
|
|
99
|
+
|
|
100
|
+
### Dedup + caching
|
|
101
|
+
|
|
102
|
+
Two in-memory `Set`s (`loaded`, `pending`), keyed by full `href`, prevent duplicate fetches within a page load — `_load` bails immediately if either contains the href. Beyond that, fetched SVG content persists in `localStorage` under `lni:{id}` (plus a `lni:v` version key); `_load` checks the cache before `fetch()`, and a cache hit injects the symbol synchronously with zero network round-trip. Bumping the internal `CACHE_VERSION` clears all `lni:*` keys on next load, forcing a re-fetch. All `localStorage` access is wrapped in `try/catch` for private-browsing/storage-full environments.
|
|
103
|
+
|
|
104
|
+
### Fetch + symbol injection
|
|
105
|
+
|
|
106
|
+
On a cache miss: resolve the CDN URL (`lnc-` → `LN_ICON_CUSTOM_CDN`, `ln-` → `LN_ICON_CDN`; `lnc-` bails silently if the custom CDN isn't configured), `fetch()`, then parse the raw SVG string — extract `viewBox` (fallback `'0 0 24 24'`), the inner content between the `<svg>` tags, and root presentation attributes (`fill`, `stroke`, `stroke-width`, `stroke-linecap`, `stroke-linejoin`). A `<symbol>` is built from these and appended to the sprite's `<defs>`; the raw SVG is also written back to `localStorage`. Errors delete the `pending` entry silently — the icon stays blank, no retry.
|
|
107
|
+
|
|
108
|
+
### Sprite element
|
|
109
|
+
|
|
110
|
+
Created lazily on first icon load, inserted as `document.body`'s first child: `<svg id="ln-icon-sprite" hidden aria-hidden="true"><defs>...</defs></svg>`.
|
|
111
|
+
|
|
112
|
+
### MutationObserver
|
|
113
|
+
|
|
114
|
+
Observes `document.body` with `{ childList: true, subtree: true }`, firing `_scan(node)` per added element — covers modals, dynamic lists, and any JS-rendered content (e.g. `ln-ajax`/`ln-store` swaps).
|
|
115
|
+
|
|
116
|
+
### Color inheritance
|
|
117
|
+
|
|
118
|
+
Tabler SVGs use `stroke="currentColor"`, so rendered icons inherit the nearest ancestor's CSS `color`. The custom multi-color icons (`ln-icon-custom-file-pdf`, `ln-icon-custom-file-doc`, `ln-icon-custom-file-epub`) embed explicit stroke colors in their source and intentionally do not follow `currentColor`.
|
|
119
|
+
|
|
120
|
+
### Checkbox exception
|
|
121
|
+
|
|
122
|
+
`<input type="checkbox">` is a replaced element and cannot contain children, so its checkmark cannot use the sprite — it's a `background-image` data URI in `_form.scss`, the only remaining data URI in the codebase.
|
|
123
|
+
|
|
124
|
+
### Cross-component icon injection
|
|
125
|
+
|
|
126
|
+
Several components inject `<use>` elements dynamically rather than authoring them in HTML: `ln-toast` (`#ln-icon-x` dismiss button), `ln-upload` (`#lnc-file[-pdf|-doc|-epub]` per item, `#ln-icon-x` remove button), `ln-confirm` (swaps an existing `<use href>` to `#ln-icon-check` during confirm, restores on reset). All route through the same scan/fetch pipeline — no special-casing needed since detection is attribute-based, not author-time. `ln-sort` is the opposite case: its three trigger icons are statically authored in HTML (see `js/ln-sort/README.md`), never injected.
|
|
127
|
+
|
|
128
|
+
### Offline behavior
|
|
129
|
+
|
|
130
|
+
Uncached icons fail silently (`.catch()`) if the page loads offline — the icon stays blank. For offline-first deployments, self-host the SVG set and point `window.LN_ICON_CDN` at a local server before the library initializes; `vite.config.js` copies `js/ln-icon/icons/*.svg` → `dist/icons/` at build for this purpose.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";(function(){const h="ln-icon-sprite",a="#ln-icon-",s="#ln-icon-custom-",d=new Set,l=new Set;let c=null;const y=(window.LN_ICON_CDN||"https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline").replace(/\/$/,""),g=(window.LN_ICON_CUSTOM_CDN||"").replace(/\/$/,""),u="lni:",m="lni:v",w="1";function v(){try{if(localStorage.getItem(m)!==w){for(let t=localStorage.length-1;t>=0;t--){const e=localStorage.key(t);e&&e.indexOf(u)===0&&localStorage.removeItem(e)}localStorage.setItem(m,w)}}catch{}}v();function N(){return c||(c=document.getElementById(h),c||(c=document.createElementNS("http://www.w3.org/2000/svg","svg"),c.id=h,c.setAttribute("hidden",""),c.setAttribute("aria-hidden","true"),c.appendChild(document.createElementNS("http://www.w3.org/2000/svg","defs")),document.body.insertBefore(c,document.body.firstChild))),c}function A(t){return t.indexOf(s)===0?g+"/"+t.slice(s.length)+".svg":y+"/"+t.slice(a.length)+".svg"}function S(t,e){const o=e.match(/viewBox="([^"]+)"/),n=o?o[1]:"0 0 24 24",i=e.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i),I=i?i[1].trim():"",p=e.match(/<svg([^>]*)>/i),O=p?p[1]:"",r=document.createElementNS("http://www.w3.org/2000/svg","symbol");r.id=t,r.setAttribute("viewBox",n),["fill","stroke","stroke-width","stroke-linecap","stroke-linejoin"].forEach(function(_){const b=O.match(new RegExp(_+'="([^"]*)"'));b&&r.setAttribute(_,b[1])}),r.innerHTML=I,N().querySelector("defs").appendChild(r)}function f(t){if(d.has(t)||l.has(t))return;if(t.indexOf(s)===0&&!g){console.warn("[ln-icon] Custom icon requested but no CUSTOM_CDN configured:",t);return}const e=t.slice(1);try{const n=localStorage.getItem(u+e);if(n){S(e,n),d.add(t);return}}catch{}l.add(t);const o=A(t);fetch(o).then(function(n){if(!n.ok)throw new Error(n.status);return n.text()}).then(function(n){S(e,n),d.add(t),l.delete(t);try{localStorage.setItem(u+e,n)}catch{}}).catch(function(n){console.error("[ln-icon] Fetch failed for:",e,n),l.delete(t)})}function C(t){const e='use[href^="'+a+'"], use[href^="'+s+'"]',o=t.querySelectorAll?t.querySelectorAll(e):[];if(t.matches&&t.matches(e)){const n=t.getAttribute("href");n&&f(n)}Array.prototype.forEach.call(o,function(n){const i=n.getAttribute("href");i&&f(i)})}function E(){C(document),new MutationObserver(function(t){t.forEach(function(e){if(e.type==="childList")e.addedNodes.forEach(function(o){o.nodeType===1&&C(o)});else if(e.type==="attributes"&&e.attributeName==="href"){const o=e.target.getAttribute("href");o&&(o.indexOf(a)===0||o.indexOf(s)===0)&&f(o)}})}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["href"]})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",E):E()})()})();
|
|
@@ -3,31 +3,31 @@
|
|
|
3
3
|
// page loads resolve instantly without network requests.
|
|
4
4
|
//
|
|
5
5
|
// Two prefixes:
|
|
6
|
-
// #ln-{name}
|
|
7
|
-
// #
|
|
6
|
+
// #ln-icon-{name} → Tabler CDN (https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline)
|
|
7
|
+
// #ln-icon-custom-{name} → Custom CDN (window.LN_ICON_CUSTOM_CDN)
|
|
8
8
|
//
|
|
9
9
|
// Config — set on window BEFORE this script loads:
|
|
10
|
-
// window.
|
|
11
|
-
// window.
|
|
10
|
+
// window.LN_ICON_CDN = 'https://...' (override Tabler CDN base)
|
|
11
|
+
// window.LN_ICON_CUSTOM_CDN = 'https://...' (required for custom icons)
|
|
12
12
|
//
|
|
13
13
|
// Cache: bump CACHE_VERSION to invalidate all cached icons.
|
|
14
14
|
//
|
|
15
15
|
// Usage:
|
|
16
|
-
// <svg class="ln-icon" aria-hidden="true"><use href="#ln-home"></use></svg>
|
|
17
|
-
// <svg class="ln-icon" aria-hidden="true"><use href="#
|
|
16
|
+
// <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-home"></use></svg>
|
|
17
|
+
// <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-custom-file-pdf"></use></svg>
|
|
18
18
|
|
|
19
19
|
(function () {
|
|
20
|
-
const SPRITE_ID = 'ln-
|
|
21
|
-
const PREFIX_LN = '#ln-';
|
|
22
|
-
const PREFIX_LNC = '#
|
|
20
|
+
const SPRITE_ID = 'ln-icon-sprite';
|
|
21
|
+
const PREFIX_LN = '#ln-icon-';
|
|
22
|
+
const PREFIX_LNC = '#ln-icon-custom-';
|
|
23
23
|
|
|
24
24
|
const loaded = new Set();
|
|
25
25
|
const pending = new Set();
|
|
26
26
|
let spriteEl = null;
|
|
27
27
|
|
|
28
|
-
// Pinned version for cache stability — bump explicitly when upgrading. Override: window.
|
|
29
|
-
const BASE_CDN = (window.
|
|
30
|
-
const CUSTOM_CDN = (window.
|
|
28
|
+
// Pinned version for cache stability — bump explicitly when upgrading. Override: window.LN_ICON_CDN
|
|
29
|
+
const BASE_CDN = (window.LN_ICON_CDN || 'https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline').replace(/\/$/, '');
|
|
30
|
+
const CUSTOM_CDN = (window.LN_ICON_CUSTOM_CDN || '').replace(/\/$/, '');
|
|
31
31
|
|
|
32
32
|
const CACHE_PREFIX = 'lni:';
|
|
33
33
|
const CACHE_VER_KEY = 'lni:v';
|
|
@@ -91,7 +91,10 @@
|
|
|
91
91
|
|
|
92
92
|
function _load(href) {
|
|
93
93
|
if (loaded.has(href) || pending.has(href)) return;
|
|
94
|
-
if (href.indexOf(PREFIX_LNC) === 0 && !CUSTOM_CDN)
|
|
94
|
+
if (href.indexOf(PREFIX_LNC) === 0 && !CUSTOM_CDN) {
|
|
95
|
+
console.warn('[ln-icon] Custom icon requested but no CUSTOM_CDN configured:', href);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
95
98
|
|
|
96
99
|
const id = href.slice(1);
|
|
97
100
|
|
|
@@ -106,8 +109,9 @@
|
|
|
106
109
|
} catch (e) { /* proceed to fetch */ }
|
|
107
110
|
|
|
108
111
|
pending.add(href);
|
|
112
|
+
const url = _url(href);
|
|
109
113
|
|
|
110
|
-
fetch(
|
|
114
|
+
fetch(url)
|
|
111
115
|
.then(function (r) {
|
|
112
116
|
if (!r.ok) throw new Error(r.status);
|
|
113
117
|
return r.text();
|
|
@@ -118,7 +122,8 @@
|
|
|
118
122
|
pending.delete(href);
|
|
119
123
|
try { localStorage.setItem(CACHE_PREFIX + id, raw); } catch (e) { /* storage full */ }
|
|
120
124
|
})
|
|
121
|
-
.catch(function () {
|
|
125
|
+
.catch(function (err) {
|
|
126
|
+
console.error('[ln-icon] Fetch failed for:', id, err);
|
|
122
127
|
pending.delete(href);
|
|
123
128
|
});
|
|
124
129
|
}
|
package/js/ln-link/README.md
CHANGED
|
@@ -188,9 +188,8 @@ window.lnLink.init(root);
|
|
|
188
188
|
window.lnLink.destroy(container);
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
`init` traverses descendants; `destroy` does not
|
|
192
|
-
[
|
|
193
|
-
behind this asymmetry.
|
|
191
|
+
`init` traverses descendants; `destroy` does not — see
|
|
192
|
+
[Cleanup asymmetry](#cleanup-asymmetry) below.
|
|
194
193
|
|
|
195
194
|
## What it does NOT do
|
|
196
195
|
|
|
@@ -255,18 +254,18 @@ click event for `ln-confirm` to intercept.
|
|
|
255
254
|
For confirmation flows on row navigation, use `ln-link:navigate`
|
|
256
255
|
with `e.preventDefault()` instead — see §Events.
|
|
257
256
|
|
|
258
|
-
### With ln-
|
|
257
|
+
### With ln-table
|
|
259
258
|
|
|
260
|
-
`ln-
|
|
259
|
+
`ln-table` inserts `<tr>` elements into a `<tbody>` from a
|
|
261
260
|
template. `ln-link`'s MutationObserver wires each new row as it
|
|
262
|
-
arrives — no manual re-init is needed after `ln-
|
|
261
|
+
arrives — no manual re-init is needed after `ln-table`
|
|
263
262
|
populates the body.
|
|
264
263
|
|
|
265
264
|
```html
|
|
266
265
|
<table data-ln-link data-ln-table>
|
|
267
266
|
<thead>...</thead>
|
|
268
267
|
<tbody>
|
|
269
|
-
<!-- ln-
|
|
268
|
+
<!-- ln-table inserts <tr> elements here dynamically;
|
|
270
269
|
ln-link wires them automatically. -->
|
|
271
270
|
</tbody>
|
|
272
271
|
</table>
|
|
@@ -297,7 +296,46 @@ If you only need this component and want to avoid loading the full bundle, you c
|
|
|
297
296
|
|
|
298
297
|
## See also
|
|
299
298
|
|
|
300
|
-
- [
|
|
301
|
-
- [`../../docs/js/ajax.md`](../../docs/js/ajax.md) — ln-ajax interop details
|
|
299
|
+
- [`../ln-ajax/README.md`](../ln-ajax/README.md) — ln-ajax interop details
|
|
302
300
|
- [`../../scss/config/mixins/_link.scss`](../../scss/config/mixins/_link.scss) — `link-row` and `link-status` mixins
|
|
303
301
|
- [`../../demo/admin/src/pages/link.html`](../../demo/admin/src/pages/link.html) — interactive demo
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## 🔧 Internals
|
|
306
|
+
|
|
307
|
+
Source: `js/ln-link/ln-link.js`. Imports `dispatchCancelable` and `guardBody` from `../ln-core`.
|
|
308
|
+
|
|
309
|
+
### State surface
|
|
310
|
+
|
|
311
|
+
- `_statusEl` — module-level, a single shared `<div class="ln-link-status">` appended to `document.body` once, at first init. Never destroyed, even if all containers are.
|
|
312
|
+
- `container.lnLinkInit` — per-container flag, guards double-init; deleted on destroy.
|
|
313
|
+
- `row.lnLinkRow`, `row._lnLinkClick`, `row._lnLinkEnter` — per-row flag plus stored listener closures, kept so `removeEventListener` gets the exact same reference at destroy time. `_handleMouseLeave` has no closure and is shared across rows.
|
|
314
|
+
- No Proxy, no reactive store — this is a listener-attachment component.
|
|
315
|
+
|
|
316
|
+
### Local `findElements`
|
|
317
|
+
|
|
318
|
+
Like `ln-ajax`, `ln-link` keeps a local `findElements` instead of the `ln-core` helper: it must check the root itself for `data-ln-link` AND iterate descendants, both calling `_initContainer` — a dual-path shape the shared helper's signature doesn't fit.
|
|
319
|
+
|
|
320
|
+
### MutationObserver
|
|
321
|
+
|
|
322
|
+
Registered inside `guardBody`, watching `document.body` with `childList`/`subtree`/`attributes` (filtered to `data-ln-link`).
|
|
323
|
+
|
|
324
|
+
- **childList** — `findElements(node)` on every added element. Special-cased for `<tr>`: walks up via `closest('[data-ln-link]')` and calls `_initRow` directly, since a bare `<tr>` isn't itself a `[data-ln-link]` container and `findElements` would miss it — this is what makes `ln-table`'s appended rows wire automatically.
|
|
325
|
+
- **attributes** — re-runs `findElements(mutation.target)` when `data-ln-link` is set/changed programmatically.
|
|
326
|
+
|
|
327
|
+
### Click handler order
|
|
328
|
+
|
|
329
|
+
`_handleClick`: interactive-child check (`closest('a, button, input, select, textarea')`) → resolve `row.querySelector('a')` → empty-href guard → modifier/middle-click branch (`window.open`, no event dispatched) → dispatch `ln-link:navigate` → cancel check → `link.click()`.
|
|
330
|
+
|
|
331
|
+
### Status bar
|
|
332
|
+
|
|
333
|
+
Created once in `_createStatusBar`, before the observer wires up. `mouseenter` sets `textContent` and adds `.ln-link-status--visible`; `mouseleave` removes the class only (text is left stale, overwritten on the next show). Empty-href rows never trigger it.
|
|
334
|
+
|
|
335
|
+
### Cleanup asymmetry
|
|
336
|
+
|
|
337
|
+
`init(root)` traverses descendants; `destroy(container)` operates on the exact element passed only — call it once per container. Always `destroy(container)` **before** replacing `innerHTML`: swapping first detaches the wired `<tr>` elements, so `destroy`'s row loop would walk the *new*, unwired rows and leak the old listeners.
|
|
338
|
+
|
|
339
|
+
### Registration pattern
|
|
340
|
+
|
|
341
|
+
Assigns `window.lnLink = { init, destroy }` directly rather than the shared `registerComponent` helper used by newer components — equivalent behavior (double-load guard, observer, DOMContentLoaded wiring), implemented manually.
|
package/js/ln-link/ln-link.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
(function(){"use strict";function
|
|
1
|
+
(function(){"use strict";if(typeof window<"u"){const t=console.warn;console.warn=function(...e){typeof e[0]=="string"&&(e[0].startsWith("[ln-")||e[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||t.apply(console,e)}}const m={};function D(t,e){m[t]||(m[t]=document.querySelector('[data-ln-template="'+t+'"]'));const l=m[t];return l?l.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function O(t,e,l){const a=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:l||{}});return t.dispatchEvent(a),a}function L(t,e){if(!t||!e)return t;const l=t.querySelectorAll("[data-ln-field]");for(let i=0;i<l.length;i++){const r=l[i],o=r.getAttribute("data-ln-field");e[o]!=null&&(r.textContent=e[o])}const a=t.querySelectorAll("[data-ln-attr]");for(let i=0;i<a.length;i++){const r=a[i],o=r.getAttribute("data-ln-attr").split(",");for(let s=0;s<o.length;s++){const h=o[s].trim().split(":");if(h.length!==2)continue;const b=h[0].trim(),g=h[1].trim();e[g]!=null&&r.setAttribute(b,e[g])}}const d=t.querySelectorAll("[data-ln-show]");for(let i=0;i<d.length;i++){const r=d[i],o=r.getAttribute("data-ln-show");o in e&&r.classList.toggle("hidden",!e[o])}const c=t.querySelectorAll("[data-ln-class]");for(let i=0;i<c.length;i++){const r=c[i],o=r.getAttribute("data-ln-class").split(",");for(let s=0;s<o.length;s++){const h=o[s].trim().split(":");if(h.length!==2)continue;const b=h[0].trim(),g=h[1].trim();g in e&&r.classList.toggle(b,!!e[g])}}return t}function B(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const l=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let a=0;a<l.length;a++)l[a].dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));return t}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore._fillBound||(window.lnCore._fillBound=!0,document.addEventListener("ln-fill",function(t){if(!(!t.target.matches||!t.target.matches("[data-ln-fillable]")))if(t.detail)L(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let l=0;l<e.length;l++)e[l].textContent=""}})));function k(t,e){if(!t||!e)return t;const l=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;l.nextNode();){const c=l.currentNode;c.textContent.indexOf("{{")!==-1&&(c.textContent=c.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(i,r){return e[r]!==void 0?e[r]:""}))}const a=function(c,i){return e[i]!==void 0?e[i]:""},d=Array.from(t.querySelectorAll("*"));t.nodeType===1&&d.push(t);for(let c=0;c<d.length;c++){const i=d[c],r=i.attributes;for(let o=0;o<r.length;o++){const s=r[o];s.value.indexOf("{{")!==-1&&i.setAttribute(s.name,s.value.replace(/\{\{\s*(\w+)\s*\}\}/g,a))}}return t}function M(t,e,l,a,d,c){const i={};for(let o=0;o<t.children.length;o++){const s=t.children[o],h=s.getAttribute("data-ln-key");h&&(i[h]=s)}const r=document.createDocumentFragment();for(let o=0;o<e.length;o++){const s=e[o],h=String(a(s));let b=i[h];if(b)d(b,s,o);else{const g=D(l,c);if(!g||(k(g,s),b=g.firstElementChild,!b))continue;b.setAttribute("data-ln-key",h),d(b,s,o)}r.appendChild(b)}t.textContent="",t.appendChild(r)}function A(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){A(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}const v={};function R(t,e){v[t]=e}function N(t){return v[t]||{ingress:e=>e,egress:e=>e}}const _={};function E(t,e){if(!t||typeof e!="object")return;const l=t.toLowerCase().split("-")[0];_[l]=e}function F(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return _[e]||null}E("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=R,window.lnCore.getDataMapper=N,window.lnCore.registerLocaleFallback=E,window.lnCore.getLocaleFallback=F,window.lnCore.fillTemplate=k,window.lnCore.fill=L,window.lnCore.lnFill=B,window.lnCore.renderList=M);function S(t){return(t||"").replace(/^#/,"")}function y(t){const e=t===void 0?location.hash:t,l={},a=S(e);if(!a)return l;const d=a.split("&");for(let c=0;c<d.length;c++){const i=d[c];if(!i)continue;const r=i.indexOf(":"),o=r>-1?i.slice(0,r):i,s=r>-1?i.slice(r+1):"";if(o)try{l[o]=decodeURIComponent(s)}catch{l[o]=s}}return l}function I(t){if(!t)return null;const e=y();return t in e?e[t]:null}function W(t,e){if(!t)return;const l=y();e==null?delete l[t]:l[t]=String(e);const d=Object.keys(l).map(function(c){const i=l[c];return i===""?c:c+":"+encodeURIComponent(i)}).join("&");S(location.hash)!==d&&(location.hash=d)}function j(t){return t.button===1||t.ctrlKey||t.metaKey||t.shiftKey?!1:(t.preventDefault(),!0)}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.hashParse=y,window.lnCore.hashGet=I,window.lnCore.hashSet=W,window.lnCore.hashLinkClick=j),(function(){const t="data-ln-link",e="lnLink";if(window[e]!==void 0)return;let l=null;function a(){l=document.createElement("div"),l.className="ln-link-status",document.body.appendChild(l)}function d(n){l&&(l.textContent=n,l.classList.add("ln-link-status--visible"))}function c(){l&&l.classList.remove("ln-link-status--visible")}function i(n,f){if(f.target.closest("a, button, input, select, textarea"))return;const u=n.querySelector("a");if(!u)return;const p=u.getAttribute("href");if(!p)return;if(f.ctrlKey||f.metaKey||f.button===1){window.open(p,"_blank");return}O(n,"ln-link:navigate",{target:n,href:p,link:u}).defaultPrevented||u.click()}function r(n){const f=n.querySelector("a");if(!f)return;const u=f.getAttribute("href");u&&d(u)}function o(){c()}function s(n){n[e+"Row"]||!n.querySelector("a")||(n[e+"Row"]=!0,n._lnLinkClick=function(u){i(n,u)},n._lnLinkEnter=function(){r(n)},n.addEventListener("click",n._lnLinkClick),n.addEventListener("mouseenter",n._lnLinkEnter),n.addEventListener("mouseleave",o))}function h(n){n[e+"Row"]&&(n._lnLinkClick&&n.removeEventListener("click",n._lnLinkClick),n._lnLinkEnter&&n.removeEventListener("mouseenter",n._lnLinkEnter),n.removeEventListener("mouseleave",o),delete n._lnLinkClick,delete n._lnLinkEnter,delete n[e+"Row"])}function b(n){if(!n[e+"Init"])return;const f=n.tagName;if(f==="TABLE"||f==="TBODY"){const u=f==="TABLE"&&n.querySelector("tbody")||n;for(const p of u.querySelectorAll("tr"))h(p)}else h(n);delete n[e+"Init"]}function g(n){if(n[e+"Init"])return;n[e+"Init"]=!0;const f=n.tagName;if(f==="TABLE"||f==="TBODY"){const u=f==="TABLE"&&n.querySelector("tbody")||n;for(const p of u.querySelectorAll("tr"))s(p)}else s(n)}function w(n){n.hasAttribute&&n.hasAttribute(t)&&g(n);const f=n.querySelectorAll?n.querySelectorAll("["+t+"]"):[];for(const u of f)g(u)}function K(){A(function(){new MutationObserver(function(f){for(const u of f)if(u.type==="childList"){for(const p of u.addedNodes)if(p.nodeType===1){w(p);const C=p.closest("["+t+"]");if(C)if(p.tagName==="TR")s(p);else{const x=C.tagName;if(x==="TABLE"||x==="TBODY"){const U=p.querySelectorAll?p.querySelectorAll("tr"):[];for(const Y of U)s(Y)}}}}else u.type==="attributes"&&(u.target.hasAttribute&&u.target.hasAttribute(t)?w(u.target):b(u.target))}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[t]})},"ln-link")}function T(n){w(n)}window[e]={init:T,destroy:b};function q(){a(),K(),T(document.body)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",q):q()})()})();
|
|
@@ -66,9 +66,11 @@ import { dispatchCancelable, guardBody } from '../../ln-core';
|
|
|
66
66
|
|
|
67
67
|
function _initRow(row) {
|
|
68
68
|
if (row[DOM_ATTRIBUTE + 'Row']) return;
|
|
69
|
-
row[DOM_ATTRIBUTE + 'Row'] = true;
|
|
70
69
|
|
|
71
|
-
|
|
70
|
+
const link = row.querySelector('a');
|
|
71
|
+
if (!link) return;
|
|
72
|
+
|
|
73
|
+
row[DOM_ATTRIBUTE + 'Row'] = true;
|
|
72
74
|
|
|
73
75
|
row._lnLinkClick = function (e) {
|
|
74
76
|
_handleClick(row, e);
|
|
@@ -151,14 +153,28 @@ import { dispatchCancelable, guardBody } from '../../ln-core';
|
|
|
151
153
|
if (node.nodeType === 1) {
|
|
152
154
|
findElements(node);
|
|
153
155
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
156
|
+
const parent = node.closest('[' + DOM_SELECTOR + ']');
|
|
157
|
+
if (parent) {
|
|
158
|
+
if (node.tagName === 'TR') {
|
|
159
|
+
_initRow(node);
|
|
160
|
+
} else {
|
|
161
|
+
const tag = parent.tagName;
|
|
162
|
+
if (tag === 'TABLE' || tag === 'TBODY') {
|
|
163
|
+
const rows = node.querySelectorAll ? node.querySelectorAll('tr') : [];
|
|
164
|
+
for (const r of rows) {
|
|
165
|
+
_initRow(r);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
172
|
} else if (mutation.type === 'attributes') {
|
|
161
|
-
|
|
173
|
+
if (mutation.target.hasAttribute && mutation.target.hasAttribute(DOM_SELECTOR)) {
|
|
174
|
+
findElements(mutation.target);
|
|
175
|
+
} else {
|
|
176
|
+
_destroyContainer(mutation.target);
|
|
177
|
+
}
|
|
162
178
|
}
|
|
163
179
|
}
|
|
164
180
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# ln-list
|
|
2
|
+
|
|
3
|
+
A unified, structure-agnostic, and local-first **Data Presenter Component** designed to render datasets in list, card, section, or grid layouts. It connects to `ln-data-store` via CustomEvents using the **Coordinator Pattern**, supporting client-side filtering, sorting, searching, and high-performance **Virtual Scrolling** for large datasets.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 📦 Declarative Setup in HTML
|
|
8
|
+
|
|
9
|
+
### 1. Simple SSR List
|
|
10
|
+
For a server-rendered list where the backend outputs `<li>` elements directly:
|
|
11
|
+
|
|
12
|
+
```html
|
|
13
|
+
<form role="search" onsubmit="return false;">
|
|
14
|
+
<input type="search" data-ln-search="documents-list" placeholder="Search...">
|
|
15
|
+
</form>
|
|
16
|
+
|
|
17
|
+
<ul id="documents-list" data-ln-list="documents">
|
|
18
|
+
<li data-ln-item-id="1">Document A</li>
|
|
19
|
+
<li data-ln-item-id="2">Document B</li>
|
|
20
|
+
</ul>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 2. Data-Driven Grid List (with Virtual Scroll)
|
|
24
|
+
Opted-in by adding the `data-ln-list-source` attribute. It clones and renders the specified `<template>`:
|
|
25
|
+
|
|
26
|
+
```html
|
|
27
|
+
<form role="search" onsubmit="return false;">
|
|
28
|
+
<input type="search" data-ln-search="documents-grid" placeholder="Search...">
|
|
29
|
+
</form>
|
|
30
|
+
|
|
31
|
+
<section id="documents-grid"
|
|
32
|
+
data-ln-list="documents"
|
|
33
|
+
data-ln-list-source="documents"
|
|
34
|
+
data-ln-list-selectable>
|
|
35
|
+
|
|
36
|
+
<!-- Items container -->
|
|
37
|
+
<ul class="grid-layout" data-ln-list-body></ul>
|
|
38
|
+
|
|
39
|
+
<!-- Row Template (Must contain an element with data-ln-item attribute) -->
|
|
40
|
+
<template data-ln-template="documents-row">
|
|
41
|
+
<li data-ln-item class="card-item">
|
|
42
|
+
<header>
|
|
43
|
+
<input type="checkbox" data-ln-item-select>
|
|
44
|
+
<h3 data-ln-field="title"></h3>
|
|
45
|
+
</header>
|
|
46
|
+
<p>{{ description }}</p>
|
|
47
|
+
<button type="button" data-ln-item-action="delete">Delete</button>
|
|
48
|
+
</li>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<!-- Empty States -->
|
|
52
|
+
<template data-ln-template="documents-empty">
|
|
53
|
+
<div class="empty-state">No documents found.</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<template data-ln-template="documents-empty-filtered">
|
|
57
|
+
<div class="empty-state">No matching documents found.</div>
|
|
58
|
+
</template>
|
|
59
|
+
</section>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 🛠️ Attributes Reference
|
|
65
|
+
|
|
66
|
+
| Attribute | Elements | Description |
|
|
67
|
+
| :--- | :--- | :--- |
|
|
68
|
+
| `data-ln-list` | Root wrapper | Component identifier. Target must carry a unique `id`. |
|
|
69
|
+
| `data-ln-list-source` | Root wrapper | Opt-in indicator for Data-Driven Mode. |
|
|
70
|
+
| `data-ln-list-selectable` | Root wrapper | Enables checkbox-based item selections. |
|
|
71
|
+
| `data-ln-list-window="N"` | Root wrapper | Opt-in server-side sliding-window virtualization. `N` sets the resident-item cap (default 1000). Requires Data-Driven Mode. Observable: add/remove toggles windowed mode ON/OFF live; changing `N` while windowed reconfigures the live cache. `data-ln-list-window-page`, `data-ln-list-window-threshold`, and `data-ln-list-count` are likewise observable. |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## ⚡ DOM Events
|
|
76
|
+
|
|
77
|
+
### Listened Events
|
|
78
|
+
|
|
79
|
+
* `ln-list:set-data` `{ data, total, filtered }`: Hydrates/renders the items. Windowed mode expects `{ offset, queryGen }` echoed back — routed into the internal window cache.
|
|
80
|
+
* `ln-list:set-loading` `{ loading: true|false }`: Toggles the loading dimming overlay class (`.ln-list--loading`).
|
|
81
|
+
* `ln-list:page-failed` `{ offset }`: Windowed mode — the coordinator reports the page fetch at `offset` failed. Releases it from the cache's in-flight set; no auto-retry, the next `ensure()` requests it again.
|
|
82
|
+
* `ln-list:request-revalidate`: Windowed mode — the coordinator asks the cache to revalidate the currently visible page after a local mutation. Stale rows stay visible, no jump to page 0.
|
|
83
|
+
* `ln-search:change` `{ term }`: Captures search query from `data-ln-search` inputs.
|
|
84
|
+
* `ln-sort:change` `{ field, column, direction }`: Sets `currentSort` and re-sorts (data-driven only). Ignored when `field` is `null` (index-only events have nothing to key a record by).
|
|
85
|
+
|
|
86
|
+
### Emitted Events
|
|
87
|
+
|
|
88
|
+
* `ln-list:request-data` `{ list, search, sort, filters }`: Requests data query from the Coordinator. Windowed mode (`data-ln-list-window`) adds `{ offset, limit, queryGen }`. Disabling windowed mode live issues a fresh full `ln-list:request-data` (no `offset`/`limit`) to repopulate the complete dataset.
|
|
89
|
+
* `ln-list:ready` `{ total }`: Fired when initial markup parsing completes.
|
|
90
|
+
* `ln-list:rendered` `{ list, total, visible }`: Fired after items have been drawn to DOM.
|
|
91
|
+
* `ln-list:item-click` `{ list, id, record }`: Fired when clicking item body (excluding buttons, anchors, inputs).
|
|
92
|
+
* `ln-list:item-action` `{ list, id, action, record }`: Fired when clicking `[data-ln-item-action]`.
|
|
93
|
+
* `ln-list:select` `{ list, selectedIds, count }`: Fired when selection updates.
|
|
94
|
+
* `ln-list:select-all` `{ list, selected: true|false }`: Fired when select-all triggers.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 🔧 Internals
|
|
99
|
+
|
|
100
|
+
Source: `js/ln-list/src/ln-list.js`.
|
|
101
|
+
|
|
102
|
+
### Spacer element matches container semantics
|
|
103
|
+
|
|
104
|
+
Virtual scroll (both plain and windowed) needs top/bottom spacer elements to preserve scroll height without rendering off-screen rows. If `[data-ln-list-body]` is a `<ul>`/`<ol>`, spacers are `<li class="ln-list__spacer">` to stay HTML5-valid; any other container gets `<div class="ln-list__spacer">`.
|
|
105
|
+
|
|
106
|
+
### `_renderVirtual()` vs `_renderWindowed()`
|
|
107
|
+
|
|
108
|
+
Both compute the visible `start`/`end` index range from the measured first-child height and the nearest scrollable ancestor's viewport, then mount spacers and clone the row template for the visible slice via `fillTemplate`/`fill`. They differ only in row source: `_renderVirtual()` reads `this._data[i]` (the full parsed/fetched dataset); `_renderWindowed()` reads `this._cache.get(i)` (the resident sliding window) and renders a blank placeholder spacer — no shimmer — for indices outside the cache, calling `this._cache.ensure(startRow, endRow)` every pass to debounce-fetch missing rows.
|
|
109
|
+
|
|
110
|
+
### Windowed mode (`data-ln-list-window="N"`)
|
|
111
|
+
|
|
112
|
+
Owns an `ln-core.createWindowCache` instance (`N` = resident cap, default 1000) instead of the full dataset. `ln-list:request-data` gains `{offset, limit, queryGen}`; `ln-list:set-data` echoes `{offset, queryGen}` back into `cache.ingest()`. Select-all is hidden under `data-ln-list-selectable` + windowed mode together — a windowed list can't select rows it hasn't fetched.
|
|
113
|
+
|
|
114
|
+
Every windowing attribute is live-observable, no re-init required: `data-ln-list-window-page`/`-threshold`/`data-ln-list-count` reconfigure the cache via `configure()`/`setGrandTotal()`. Adding `data-ln-list-window` to an already-initialized, non-windowed, data-driven list seeds the cache from resident items and enables windowing live. Removing it destroys the cache and issues one full (no `offset`/`limit`) `ln-list:request-data` to repopulate before rendering non-windowed again.
|
|
115
|
+
|
|
116
|
+
### `_parseChildren()`
|
|
117
|
+
|
|
118
|
+
Parses static children inside `[data-ln-list-body]` into `this._data` at mount (excluding existing spacers), and measures the first child's height for `this._itemHeight`.
|
|
119
|
+
|
|
120
|
+
### Sort (`ln-sort:change`)
|
|
121
|
+
|
|
122
|
+
Data-driven only — the SSR branch has no sort logic. `field === null` events (index-only, from an
|
|
123
|
+
`ln-sort` instance authored without `data-ln-sort-field`) are ignored; `ln-list` records are
|
|
124
|
+
field-keyed, not positional, so there is nothing to sort by without a field name. On a matching
|
|
125
|
+
event: `preventDefault()`, `currentSort = { field, direction }` (or `null` on `direction: 'none'`),
|
|
126
|
+
then mirrors `ln-table`'s own data-driven sort handling exactly — windowed lists call
|
|
127
|
+
`_requestData()` (cache invalidate + re-fetch, same scheme as `ln-table`'s windowed
|
|
128
|
+
`_requestData()`); non-windowed lists re-sort the already-fetched in-memory data directly
|
|
129
|
+
(`_applyFilterAndSort()` + `_render()`, no server round-trip).
|