@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,896 @@
|
|
|
1
|
+
# ln-core
|
|
2
|
+
|
|
3
|
+
Shared helper module imported by all components — re-exported from
|
|
4
|
+
`js/ln-core/index.js`. No DOM attribute, no constructor, no
|
|
5
|
+
MutationObserver. Source: `js/ln-core/`.
|
|
6
|
+
|
|
7
|
+
## Helpers
|
|
8
|
+
|
|
9
|
+
ln-core exposes helpers in these categories:
|
|
10
|
+
|
|
11
|
+
- **Templates** — clone and populate `<template>` fragments
|
|
12
|
+
- **Events** — dispatch standard and cancelable CustomEvents; shared data-request cycle for collection components (`requestData`)
|
|
13
|
+
- **DOM binding** — declarative data-attribute-driven rendering (`fill`, `renderList`, `fillTemplate`)
|
|
14
|
+
- **Forms** — serialize/populate by field `name`
|
|
15
|
+
- **Dictionaries** — extract i18n strings from hidden elements
|
|
16
|
+
- **Discovery** — `registerComponent` for MutationObserver-backed auto-init
|
|
17
|
+
- **Reactivity** — `reactiveState`, `deepReactive`, `createBatcher`
|
|
18
|
+
- **Windowing** — sliding-window sparse cache for server-side virtualization (`createWindowCache`)
|
|
19
|
+
- **Layout** — viewport-aware positioning, teleport, measurement
|
|
20
|
+
- **Persistence** — localStorage wrappers with `ln:` prefix
|
|
21
|
+
- **Hash codec** — namespaced URL fragment state (`hashParse`, `hashGet`, `hashSet`)
|
|
22
|
+
- **Cryptography** — high-performance Web Crypto helpers for encryption at rest (`crypto.js`)
|
|
23
|
+
|
|
24
|
+
Source of truth: `js/ln-core/helpers.js`, `js/ln-core/reactive.js`, and `js/ln-core/crypto.js`. Import from `'../ln-core'` (barrel).
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## helpers.js
|
|
29
|
+
|
|
30
|
+
### cloneTemplate(name, componentTag)
|
|
31
|
+
|
|
32
|
+
Clone a `<template data-ln-template="name">` element. Cached after first lookup.
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const frag = cloneTemplate('track-item', 'ln-playlist');
|
|
36
|
+
const li = frag.querySelector('[data-ln-track]');
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- Returns `DocumentFragment` (result of `tmpl.content.cloneNode(true)`)
|
|
40
|
+
- Returns `null` + console warning if template not found
|
|
41
|
+
- `componentTag` is used only in the warning message
|
|
42
|
+
|
|
43
|
+
### dispatch(element, eventName, detail)
|
|
44
|
+
|
|
45
|
+
Fire a non-cancelable `CustomEvent` that bubbles.
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
dispatch(this.dom, 'ln-toggle:open', { target: this.dom });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `bubbles: true`, `cancelable: false`
|
|
52
|
+
- `detail` defaults to `{}` if omitted
|
|
53
|
+
|
|
54
|
+
### dispatchCancelable(element, eventName, detail)
|
|
55
|
+
|
|
56
|
+
Fire a cancelable `CustomEvent` that bubbles. Returns the event object so the caller can check `defaultPrevented`.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
const before = dispatchCancelable(this.dom, 'ln-toggle:before-open', { target: this.dom });
|
|
60
|
+
if (before.defaultPrevented) return;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `bubbles: true`, `cancelable: true`
|
|
64
|
+
- `detail` defaults to `{}` if omitted
|
|
65
|
+
|
|
66
|
+
### requestData(component, eventName, keyName)
|
|
67
|
+
|
|
68
|
+
Shared data-request cycle for collection components (`ln-table`, `ln-list`). Runs the local render pass over the hydrated rows, then dispatches a `*:request-data` event so the coordinator can fetch the authoritative dataset.
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
requestData(this, 'ln-list:request-data', 'list'); // inside ln-list
|
|
72
|
+
requestData(this, 'ln-table:request-data', 'table'); // inside ln-table
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- Render order: `_applyFilterAndSort()` → reset `_vStart`/`_vEnd` to `-1` → `_render()` → `_updateFooter()` → `dispatch`.
|
|
76
|
+
- `eventName` — the `*:request-data` event the component emits.
|
|
77
|
+
- `keyName` — the payload property carrying the component name (`'list'` / `'table'`). The dispatched `detail` is always `{ sort, filters, search, [keyName]: component.name }` — same shape, only the identifier key varies per component.
|
|
78
|
+
- **Instance contract:** `component` must expose `_applyFilterAndSort()`, `_render()`, `_updateFooter()`, `_vStart`, `_vEnd`, `dom`, `name`, `currentSort`, `currentFilters`, `currentSearch`. Both `ln-table` and `ln-list` satisfy it; each defines its own one-line `_requestData` wrapper around this helper.
|
|
79
|
+
- Dispatches via `dispatch` — the emitted event bubbles and is non-cancelable.
|
|
80
|
+
|
|
81
|
+
### fill(root, data)
|
|
82
|
+
|
|
83
|
+
Declarative DOM binding. Reads `data-ln-*` attributes on descendants and applies values from the `data` object.
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
fill(li, { number: 1, title: 'Track', artist: 'Artist', isPlaying: true });
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Attribute | Behavior | Example |
|
|
90
|
+
|-----------|----------|---------|
|
|
91
|
+
| `data-ln-field="prop"` | `el.textContent = data[prop]` | `<span data-ln-field="title"></span>` |
|
|
92
|
+
| `data-ln-attr="attr:prop, attr:prop"` | `el.setAttribute(attr, data[prop])` | `<input data-ln-attr="value:name, placeholder:hint">` |
|
|
93
|
+
| `data-ln-show="prop"` | `el.classList.toggle('hidden', !data[prop])` | `<span data-ln-show="hasError">` |
|
|
94
|
+
| `data-ln-class="cls:prop"` | `el.classList.toggle(cls, !!data[prop])` | `<li data-ln-class="active:isPlaying">` |
|
|
95
|
+
|
|
96
|
+
> [!IMPORTANT]
|
|
97
|
+
> These four are `fill()` **binding attributes, not components** — there is no `ln-field`, `ln-attr`, `ln-show`, or `ln-class` element. They apply only where a component calls `fill()`.
|
|
98
|
+
|
|
99
|
+
- Returns `root` for chaining
|
|
100
|
+
- Skips `null`/`undefined` values (existing content preserved)
|
|
101
|
+
- Nothing calls `fill()` automatically — a component must call it explicitly, and re-call it to update. Renderer pipelines that clone templates (`ln-table` rows, `renderList`'s clone pass) do not call `fill()`; inside those templates use `{{ field }}` instead. `fill()` does not process `{{ }}` placeholders.
|
|
102
|
+
|
|
103
|
+
### lnFill(container, record)
|
|
104
|
+
|
|
105
|
+
Fan-out helper — dispatches `ln-fill` at every `[data-ln-form]` and
|
|
106
|
+
`[data-ln-fillable]` descendant. Each fillable self-handles: a form fills
|
|
107
|
+
or resets; a display region fills or clears its `[data-ln-field]` elements.
|
|
108
|
+
`record = null` triggers a reset/clear on all targets.
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
// At the modal open boundary — form fills, title heading fills, one call
|
|
112
|
+
window.lnCore.lnFill(modalEl, record); // record → fill; null → reset/clear
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- Also dispatches `ln-fill` at `container` itself when it matches
|
|
116
|
+
`[data-ln-form]` or `[data-ln-fillable]` — so `lnFill(formEl, record)` works
|
|
117
|
+
when called directly on a form element. Source: `js/ln-core/helpers.js` L164–165.
|
|
118
|
+
- `container` — any element; scans its entire subtree.
|
|
119
|
+
- `record` — plain object or `null`.
|
|
120
|
+
- Dispatches `ln-fill` (`bubbles: true`, `detail = record ?? null`) at each target.
|
|
121
|
+
- Returns `container` for chaining.
|
|
122
|
+
- Exported from `js/ln-core/index.js`; available at `window.lnCore.lnFill`.
|
|
123
|
+
- Back-compat: `ln-form` also still accepts `ln-form:fill` / `ln-form:reset` (aliases).
|
|
124
|
+
|
|
125
|
+
### fillTemplate(clone, data)
|
|
126
|
+
|
|
127
|
+
Replace `{{ key }}` text-node placeholders in a cloned template fragment with values from `data`. Flat keys only (no nested `{{ item.text }}`).
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
const frag = cloneTemplate('filter-item', 'ln-filter');
|
|
131
|
+
fillTemplate(frag, { text: 'Engineering' });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```html
|
|
135
|
+
<template data-ln-template="filter-item">
|
|
136
|
+
<label><input type="checkbox"> {{ text }}</label>
|
|
137
|
+
</template>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- Walks text nodes via `TreeWalker(clone, NodeFilter.SHOW_TEXT)` (pass 1)
|
|
141
|
+
- In pass 1: replaces `{{ key }}` with `data[key]` (whitespace inside braces is flexible)
|
|
142
|
+
- **Pass 2: element attributes** — iterates every element in the clone (including the
|
|
143
|
+
root element itself when it is an `Element`, since `querySelectorAll` does not include
|
|
144
|
+
root). For each attribute whose value contains `{{`, replaces tokens via
|
|
145
|
+
`el.setAttribute(attr.name, resolved)`. Uses `setAttribute` — never `innerHTML` —
|
|
146
|
+
so injection risk is the same as `data-ln-attr`. Source: `js/ln-core/helpers.js` L197–233.
|
|
147
|
+
- Missing keys produce empty string in both passes
|
|
148
|
+
- No-op in either pass when no `{{` is present
|
|
149
|
+
- Returns `clone` for chaining
|
|
150
|
+
- **Different system from `fill()` — not interchangeable.** `fillTemplate` consumes `{{ key }}` placeholders once, at clone time (in both text nodes and element attribute values); after that the content is plain and never updates. `data-ln-field` is read only by `fill()`, and `fill()` runs only where a component explicitly calls it. Neither function processes the other's syntax.
|
|
151
|
+
- Called automatically by `renderList` on freshly cloned elements only — on keyed re-renders the placeholders are already consumed, so `{{ key }}` values never update. Values that must update on re-render belong in your `fillFn` (e.g. `fill()` + `data-ln-field`).
|
|
152
|
+
- **`ln-table` row templates never call `fill()`** — rows support `{{ field }}` and `data-ln-table-cell-attr` only; a `data-ln-field` inside a row template is silently ignored. Decision matrix: `docs/architecture/data-flow.md` §5.
|
|
153
|
+
|
|
154
|
+
### renderList(container, items, templateName, keyFn, fillFn, componentTag)
|
|
155
|
+
|
|
156
|
+
Keyed list rendering with DOM reuse. Existing children matched by `data-ln-key` attribute.
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
renderList(
|
|
160
|
+
this.listEl,
|
|
161
|
+
state.tracks,
|
|
162
|
+
'track-item',
|
|
163
|
+
function (track) { return track.id; },
|
|
164
|
+
function (el, track, i) { fill(el, { number: i + 1, title: track.title }); },
|
|
165
|
+
'ln-playlist'
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- `keyFn(item)` returns a unique string key for each item
|
|
170
|
+
- `fillFn(el, item, index)` updates the DOM element with item data
|
|
171
|
+
- Reuses existing elements with matching `data-ln-key` (avoids re-clone)
|
|
172
|
+
- Replaces all container children atomically (`textContent = '' + appendChild(frag)`)
|
|
173
|
+
- Calls `fillTemplate(clone, item)` automatically for newly-cloned elements — `{{ key }}` placeholders in the template resolve from the item data without extra wiring.
|
|
174
|
+
|
|
175
|
+
### guardBody(setupFn, componentTag)
|
|
176
|
+
|
|
177
|
+
Guard against script loading before `<body>` exists. If `document.body` is `null`, defers `setupFn` to `DOMContentLoaded`.
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
guardBody(function () {
|
|
181
|
+
observer.observe(document.body, { ... });
|
|
182
|
+
}, 'ln-toggle');
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- Logs a warning suggesting the `defer` attribute on the `<script>` tag
|
|
186
|
+
- Used by every component's `_domObserver()` function
|
|
187
|
+
|
|
188
|
+
### findElements(root, selector, attribute, ComponentClass)
|
|
189
|
+
|
|
190
|
+
Find all elements with `[selector]` inside `root` (including root itself) and instantiate `ComponentClass` on each one that doesn't already have an instance.
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
findElements(node, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- Checks `el[attribute]` to prevent double-initialization
|
|
197
|
+
- Sets `el[attribute] = new ComponentClass(el)`
|
|
198
|
+
- Used in constructors and MutationObserver callbacks
|
|
199
|
+
|
|
200
|
+
### cloneTemplateScoped(root, name, componentTag)
|
|
201
|
+
|
|
202
|
+
Clone a `<template>` with scoped lookup — searches inside `root` first, falls back to global `cloneTemplate`.
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
const frag = cloneTemplateScoped(this.dom, 'column-filter', 'ln-table');
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- First searches for `<template data-ln-template="name">` inside `root`
|
|
209
|
+
- If not found inside `root`, falls back to global `cloneTemplate(name, componentTag)`
|
|
210
|
+
- Returns `DocumentFragment` (same as `cloneTemplate`)
|
|
211
|
+
- Use when a component supports locally-scoped templates that can be overridden per-instance
|
|
212
|
+
|
|
213
|
+
### buildDict(root, selector)
|
|
214
|
+
|
|
215
|
+
Build a plain object from hidden dictionary elements. Reads all `[selector]` elements once at init, extracts `key → textContent`, removes them from DOM. Returns a plain object for O(1) lookups.
|
|
216
|
+
|
|
217
|
+
```html
|
|
218
|
+
<ul hidden>
|
|
219
|
+
<li data-ln-upload-dict="remove">{{ __('Remove') }}</li>
|
|
220
|
+
<li data-ln-upload-dict="error">{{ __('Error') }}</li>
|
|
221
|
+
</ul>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
const dict = buildDict(container, 'data-ln-upload-dict');
|
|
226
|
+
dict['remove'] // 'Remove'
|
|
227
|
+
dict['error'] // 'Error'
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- One-shot: reads once, removes elements, returns object
|
|
231
|
+
- Missing keys return `undefined` — caller provides fallback: `dict['key'] || 'default text'`
|
|
232
|
+
- Convention: `data-{component}-dict="key"` on `<li>` elements inside a single `<ul hidden>`
|
|
233
|
+
- Server (Blade, Twig, etc.) translates the text — JS never contains display strings
|
|
234
|
+
|
|
235
|
+
### isVisible(el)
|
|
236
|
+
|
|
237
|
+
Boolean — true if the element has non-zero layout box (any of
|
|
238
|
+
`offsetWidth`, `offsetHeight`, or `getClientRects().length`).
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
if (!isVisible(panel)) return;
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- Cheap layout-time check — does NOT compute styles.
|
|
245
|
+
- Returns `false` for elements with `display: none`, detached nodes,
|
|
246
|
+
and zero-sized elements.
|
|
247
|
+
|
|
248
|
+
### serializeForm(form, opts?)
|
|
249
|
+
|
|
250
|
+
Walk `form.elements`, return a plain object keyed by element `name`.
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
const data = serializeForm(this.dom);
|
|
254
|
+
// { username: 'alice', roles: ['admin', 'editor'], country: 'mk' }
|
|
255
|
+
|
|
256
|
+
// Opt-in typed coercion:
|
|
257
|
+
const typed = serializeForm(this.dom, { typed: true });
|
|
258
|
+
// { active: true, count: 5, range: null }
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
- Skips disabled fields, file inputs, submit / button inputs, and
|
|
262
|
+
unnamed elements.
|
|
263
|
+
- Checkboxes collect as `string[]` under the shared `name`.
|
|
264
|
+
- Radios collect as a single `string` (winning value).
|
|
265
|
+
- `<select multiple>` collects as `string[]`.
|
|
266
|
+
- Everything else collects as the raw `el.value`.
|
|
267
|
+
|
|
268
|
+
**`opts.typed = true` — typed coercion rules:**
|
|
269
|
+
|
|
270
|
+
| Input type | Default (no opts) | With `typed: true` |
|
|
271
|
+
|---|---|---|
|
|
272
|
+
| Single checkbox (unique name in form) | `[]` / `['on']` | `true` / `false` |
|
|
273
|
+
| Checkbox group (multiple same name) | array of checked values | array of checked values |
|
|
274
|
+
| `type="number"` / `type="range"` | `"42"` (string) | `42` (Number), or `null` if empty / NaN |
|
|
275
|
+
| `type="hidden"` | string | string — **never coerced** |
|
|
276
|
+
| Everything else | string | string (unchanged) |
|
|
277
|
+
|
|
278
|
+
The `hidden` branch is explicit to protect `ln-number`'s raw-value
|
|
279
|
+
contract: `ln-number` writes the numeric string to a hidden input, and
|
|
280
|
+
typed mode must not convert it silently.
|
|
281
|
+
|
|
282
|
+
### populateForm(form, data)
|
|
283
|
+
|
|
284
|
+
Inverse of `serializeForm`. Walks `form.elements`, assigns from `data`
|
|
285
|
+
keyed by `name`. Returns the array of populated elements (caller can
|
|
286
|
+
re-validate them).
|
|
287
|
+
|
|
288
|
+
```js
|
|
289
|
+
const populated = populateForm(this.dom, { username: 'alice', roles: ['admin'] });
|
|
290
|
+
populated.forEach(function (el) { dispatch(el, 'input'); });
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- Skips elements not present as keys in `data`, file inputs, submit /
|
|
294
|
+
button inputs.
|
|
295
|
+
- **Decoupled fill key (`data-ln-fill-as`)** — match key for the fill direction is
|
|
296
|
+
`el.getAttribute('data-ln-fill-as') || el.name`. When `data-ln-fill-as` is set,
|
|
297
|
+
the record key is the fill-as value and `name` stays as the form submission key.
|
|
298
|
+
Source: `populateForm` in `js/ln-core/helpers.js`.
|
|
299
|
+
- **Checkbox string coercion** — a single checkbox (one element with that `name` in
|
|
300
|
+
the form) whose fill value is a string is coerced via `_coerceBool`: `"false"`,
|
|
301
|
+
`"0"`, `""`, `"off"`, `"no"` (case-insensitive, trimmed) → unchecked; anything
|
|
302
|
+
else → checked. This handles `data-ln-fill-*` values which always arrive as
|
|
303
|
+
strings. Source: `_coerceBool` + `populateForm` in `js/ln-core/helpers.js`.
|
|
304
|
+
- Checkbox + array → `checked` if `el.value` is in the array.
|
|
305
|
+
- Checkbox group (same `name`, 2+ elements) + scalar → treated as comma-separated list (`"admin,editor"` → membership check).
|
|
306
|
+
- Single checkbox + scalar → boolean coercion: `"false"/"0"/"off"/"no"/""` → unchecked; anything else → checked.
|
|
307
|
+
- Radio → `checked` if value matches.
|
|
308
|
+
- `<select multiple>` + array → marks matching options selected.
|
|
309
|
+
|
|
310
|
+
### getLocale(el)
|
|
311
|
+
|
|
312
|
+
Resolve the active locale for an element. Walks ancestors for `[lang]`,
|
|
313
|
+
falls back to `navigator.language`.
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
const locale = getLocale(this.dom); // 'mk', 'en-US', ...
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- Used by date / number / collator-driven components.
|
|
320
|
+
- Walks via `el.closest('[lang]')` — first ancestor with a `lang`
|
|
321
|
+
attribute wins.
|
|
322
|
+
|
|
323
|
+
### readValue(el)
|
|
324
|
+
|
|
325
|
+
Read the raw machine value behind a formatted cell/item display. The single
|
|
326
|
+
read path for value-based sort/filter across components.
|
|
327
|
+
|
|
328
|
+
```js
|
|
329
|
+
const raw = readValue(td); // '1250.50' from data-ln-value, else trimmed text
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- Returns `data-ln-value` attribute value if the element has it.
|
|
333
|
+
- Otherwise returns `el.textContent.trim()`.
|
|
334
|
+
- Used by `ln-table` for sort/filter.
|
|
335
|
+
|
|
336
|
+
### registerComponent(selector, attribute, ComponentFn, componentTag, options)
|
|
337
|
+
|
|
338
|
+
End-to-end component registration. Replaces the hand-rolled IIFE
|
|
339
|
+
boilerplate of `findElements` + `MutationObserver` + `guardBody` +
|
|
340
|
+
DOMContentLoaded + `window[attribute] =` registration with one call.
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
import { registerComponent } from '../ln-core';
|
|
344
|
+
|
|
345
|
+
function _component(dom) { this.dom = dom; /* ... */ }
|
|
346
|
+
|
|
347
|
+
registerComponent('data-ln-example', 'lnExample', _component, 'ln-example', {
|
|
348
|
+
extraAttributes: ['data-ln-example-state'],
|
|
349
|
+
onAttributeChange: function (target, name) { /* attribute → state bridge */ },
|
|
350
|
+
onInit: function (root) { /* post-init hook, per added subtree */ }
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
- `selector` — attribute name (`'data-ln-foo'`) OR a full CSS selector
|
|
355
|
+
if it contains `[`, `.`, or `#` (e.g. `'[data-ln-foo]:not([disabled])'`).
|
|
356
|
+
- `attribute` — JS-side key used both as `window[attribute]` (the
|
|
357
|
+
constructor function) and `el[attribute]` (the per-element instance).
|
|
358
|
+
- `options.extraAttributes` — additional attribute names to include in
|
|
359
|
+
the MutationObserver's `attributeFilter` (e.g. state attributes set
|
|
360
|
+
by coordinator).
|
|
361
|
+
|
|
362
|
+
> **Coverage rule:** `extraAttributes` must include every attribute your `onAttributeChange` handler — and any helper it calls synchronously — reads from the element as an input to what it renders or derives. The observed set is *primary attribute + `extraAttributes`*; an attribute read but not observed will not trigger a re-sync when it changes. Behaviour flags checked only at a transition (e.g. `data-ln-persist`, read at open/close to decide a side-effect) are exempt — a mid-life change to them is not meant to re-render.
|
|
363
|
+
- `options.onAttributeChange(target, attrName)` — called when a
|
|
364
|
+
filtered attribute changes on an already-initialized element. The
|
|
365
|
+
attribute → state bridge hook.
|
|
366
|
+
- `options.onInit(root)` — called after `findElements` per subtree
|
|
367
|
+
(initial DOM, added childList nodes, attribute-mutated subtrees).
|
|
368
|
+
- Returns the constructor function (also stored at `window[attribute]`).
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## reactive.js
|
|
373
|
+
|
|
374
|
+
### reactiveState(initial, onChange)
|
|
375
|
+
|
|
376
|
+
Shallow `Proxy` — fires `onChange(prop, value, old)` on every property set. No-op if value hasn't changed.
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
const state = reactiveState({ searchTerm: '', sortCol: -1 }, function (prop, value, old) {
|
|
380
|
+
batchRender();
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- Returns a Proxy wrapping a shallow copy of `initial`
|
|
385
|
+
- Only tracks direct property assignments (not nested objects)
|
|
386
|
+
- Used by `ln-filter` for flat state
|
|
387
|
+
|
|
388
|
+
### deepReactive(obj, onChange)
|
|
389
|
+
|
|
390
|
+
Deep `Proxy` — recursively wraps nested objects and arrays. Fires `onChange()` (no args) on any nested mutation.
|
|
391
|
+
|
|
392
|
+
```js
|
|
393
|
+
const state = deepReactive({ toasts: [], files: [] }, function () {
|
|
394
|
+
batchRender();
|
|
395
|
+
});
|
|
396
|
+
state.toasts.push({ title: 'Hello' }); // triggers onChange
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
- Wraps existing nested objects on creation
|
|
400
|
+
- Auto-wraps newly assigned objects/arrays
|
|
401
|
+
- `deleteProperty` also triggers `onChange`
|
|
402
|
+
- Internal `Symbol('deepReactive')` flag prevents double-wrapping
|
|
403
|
+
- Used by `ln-toast`, `ln-upload` for complex state
|
|
404
|
+
|
|
405
|
+
### createBatcher(renderFn, afterRender)
|
|
406
|
+
|
|
407
|
+
Coalesce multiple synchronous state changes into a single `renderFn` call via `queueMicrotask`.
|
|
408
|
+
|
|
409
|
+
```js
|
|
410
|
+
const batchRender = createBatcher(
|
|
411
|
+
function () { _render(); },
|
|
412
|
+
function () { dispatch(dom, 'ln-component:rendered'); }
|
|
413
|
+
);
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
- Multiple calls to `schedule()` within the same microtask result in one `renderFn` call
|
|
417
|
+
- Optional `afterRender` callback fires after each render
|
|
418
|
+
- Pattern: `deepReactive(state, batchRender)` — every state mutation schedules a batched render
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Reactive Rendering Pattern
|
|
423
|
+
|
|
424
|
+
The three reactive primitives (`reactiveState` / `deepReactive` +
|
|
425
|
+
`createBatcher` + `fill`) are designed to be used together. They are
|
|
426
|
+
the canonical ln-ashlar render loop:
|
|
427
|
+
|
|
428
|
+
1. **State mutation.** Component code assigns to a property on a
|
|
429
|
+
reactive proxy (`this.state.value = 'x'`). The proxy fires its
|
|
430
|
+
onChange callback.
|
|
431
|
+
2. **Microtask queue.** The onChange is wired to a `createBatcher`
|
|
432
|
+
schedule function. Any number of synchronous assignments in the
|
|
433
|
+
same sync block schedule a single microtask.
|
|
434
|
+
3. **One render.** When the microtask fires, the batcher calls
|
|
435
|
+
`renderFn` exactly once. `renderFn` typically calls `fill(this.dom,
|
|
436
|
+
this.state)` (or `renderList` for keyed collections) to project
|
|
437
|
+
state into the DOM, then the optional `afterRender` callback fires
|
|
438
|
+
(commonly used to dispatch a `ln-{component}:rendered` event).
|
|
439
|
+
|
|
440
|
+
Two assignments on the same tick produce one DOM update. External
|
|
441
|
+
callers that need to set several properties at once do not need a
|
|
442
|
+
`batch()` helper — the batcher handles it.
|
|
443
|
+
|
|
444
|
+
The pattern is: **state mutation → microtask queue → one render**.
|
|
445
|
+
Never wire a reactive onChange directly to `_render()` without a
|
|
446
|
+
batcher between them — you will get one render per assignment, and
|
|
447
|
+
the DOM will thrash.
|
|
448
|
+
|
|
449
|
+
For the full decision matrix (`reactiveState` vs `deepReactive`, when
|
|
450
|
+
to use this pattern at all, anti-patterns), see
|
|
451
|
+
[Reactive Rendering Pattern in js/COMPONENTS.md](../../js/COMPONENTS.md#reactive-rendering-pattern).
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## window-cache.js
|
|
456
|
+
|
|
457
|
+
### createWindowCache(config)
|
|
458
|
+
|
|
459
|
+
DOM-agnostic sliding-window data engine for server-side virtualization. Owns a sparse cache keyed by logical index, logical/grand totals, request dedup, a stale-response guard (`queryGen`), LRU eviction to a bounded cap, and a debounced fetch trigger.
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
const cache = createWindowCache({
|
|
463
|
+
windowSize: 1000,
|
|
464
|
+
pageSize: 200,
|
|
465
|
+
fetchDebounce: 120,
|
|
466
|
+
overscan: 15,
|
|
467
|
+
requestPage: function (query, offset, limit) {
|
|
468
|
+
dispatch(dom, 'ln-table:request-data', {
|
|
469
|
+
table: self.name, sort: query.sort, filters: query.filters,
|
|
470
|
+
search: query.search, offset: offset, limit: limit,
|
|
471
|
+
queryGen: cache.queryGen
|
|
472
|
+
});
|
|
473
|
+
},
|
|
474
|
+
onChange: function () { self._render(); }
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
cache.requestInitial({ sort: null, filters: {}, search: '' });
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
**Config:**
|
|
481
|
+
|
|
482
|
+
| Key | Default | Purpose |
|
|
483
|
+
|---|---|---|
|
|
484
|
+
| `windowSize` | `1000` | Resident-row cap. LRU-evicted once exceeded. |
|
|
485
|
+
| `pageSize` | `200` | Rows fetched per page. |
|
|
486
|
+
| `fetchDebounce` | `120` | Milliseconds to coalesce `ensure()` calls before firing a fetch. |
|
|
487
|
+
| `overscan` | `15` | Rows padded onto the fetch range (not the render range) on either side. |
|
|
488
|
+
| `requestPage(query, offset, limit)` | no-op | Callback — kick a fetch over any transport. |
|
|
489
|
+
| `onChange()` | no-op | Callback — cache mutated, re-render. |
|
|
490
|
+
|
|
491
|
+
**Returns:**
|
|
492
|
+
|
|
493
|
+
| Method | Signature | Purpose |
|
|
494
|
+
|---|---|---|
|
|
495
|
+
| `get` | `(i) → record \| undefined` | Read a resident row by logical index. |
|
|
496
|
+
| `has` | `(i) → Boolean` | Whether a logical index is resident. |
|
|
497
|
+
| `peek` | `() → record \| undefined` | Any one resident record — lets the render client measure row height before geometry is known. |
|
|
498
|
+
| `logicalTotal` *(getter)* | `Number` | Filtered/queried row count — spacer geometry basis. |
|
|
499
|
+
| `grandTotal` *(getter)* | `Number` | Unfiltered dataset count. |
|
|
500
|
+
| `queryGen` *(getter)* | `Number` | Current query generation — bumped by `invalidate()`. |
|
|
501
|
+
| `size` *(getter)* | `Number` | Resident row count. |
|
|
502
|
+
| `ensure` | `(startRow, endRow)` | Render client hands its visible logical range; stamps in-range resident rows as freshly touched, debounce-fires a fetch for whatever is missing (padded by `overscan`). |
|
|
503
|
+
| `ingest` | `(detail)` | Splice a fetched page into the cache. Drops responses whose `detail.queryGen` doesn't match the current generation. On the first response of a new generation (a pending `invalidate()`/`revalidate()` swap), clears the stale cache contents before splicing. Triggers `onChange()`. |
|
|
504
|
+
| `requestInitial` | `(query)` | First load — fetches page 0 at the current generation (no bump). |
|
|
505
|
+
| `invalidate` | `(query)` | Query change — bumps `queryGen`, refetches page 0. Stale rows and totals stay resident until the new generation's first page lands in `ingest()` (stale-while-revalidate). |
|
|
506
|
+
| `revalidate` | `()` | Post-mutation refresh — bumps `queryGen`, re-requests the page containing the last `ensure()`-reported visible range instead of jumping back to page 0. Same stale-while-revalidate swap as `invalidate()`. |
|
|
507
|
+
| `release` | `(offset)` | Drops `offset` from the in-flight set after a failed page fetch, so the next `ensure()` can re-request it. No re-render, no auto-retry. |
|
|
508
|
+
| `configure` | `(partial)` | Live-mutate any subset of `{windowSize, pageSize, threshold, fetchDebounce}`. Shrinking `windowSize` runs `evict()` then `onChange()`; `pageSize`/`threshold`/`fetchDebounce` apply to the next `ensure()` with no immediate render. |
|
|
509
|
+
| `setGrandTotal` | `(n)` | Sets `grandTotal`; moves `logicalTotal` to `n` too, but only when no `search`/`filters` are active on the current query (a filtered total is server-owned via `ingest`). Fires `onChange()`. |
|
|
510
|
+
| `destroy` | `()` | Clears timers and cache contents. |
|
|
511
|
+
|
|
512
|
+
- LRU eviction drops the least-recently-touched rows first once `map.size` exceeds `windowSize` — a pure data policy, not a viewport-distance heuristic.
|
|
513
|
+
- `queryGen` is the stale-response guard: `ingest()` silently drops any page whose `detail.queryGen` doesn't match the cache's current generation, so an out-of-order response from a superseded query never corrupts the window.
|
|
514
|
+
- Pages splice at their own `offset`, so out-of-order arrivals are safe — order is irrelevant.
|
|
515
|
+
- Pure function core, no DOM — shared by the `ln-table` and `ln-list` windowed paths.
|
|
516
|
+
- `configure`/`setGrandTotal` make the cache's own config live-observable; the owning component (`ln-table`/`ln-list`) wires them to `data-ln-*-window-page` / `-window-threshold` / `-count` attribute changes so no re-init is required.
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
## positioning.js
|
|
521
|
+
|
|
522
|
+
### computePlacement(anchorRect, floatingSize, preferred, offset)
|
|
523
|
+
|
|
524
|
+
Compute viewport coordinates for a floating element (popover, tooltip,
|
|
525
|
+
dropdown) relative to an anchor rectangle.
|
|
526
|
+
|
|
527
|
+
```js
|
|
528
|
+
import { computePlacement } from '../ln-core';
|
|
529
|
+
|
|
530
|
+
const rect = trigger.getBoundingClientRect();
|
|
531
|
+
const size = measureHidden(panel);
|
|
532
|
+
const { top, left, placement } = computePlacement(rect, size, 'bottom-end', 8);
|
|
533
|
+
panel.style.top = top + 'px';
|
|
534
|
+
panel.style.left = left + 'px';
|
|
535
|
+
panel.setAttribute('data-ln-placement', placement);
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
- `anchorRect` — `DOMRect` or any rect-shaped object.
|
|
539
|
+
- `floatingSize` — `{ width, height }`. Use `measureHidden` when the
|
|
540
|
+
panel is currently hidden.
|
|
541
|
+
- `preferred` — `'top' | 'bottom' | 'left' | 'right'` with optional
|
|
542
|
+
`-start` / `-end` alignment (floating-ui-style). Default `'bottom'`.
|
|
543
|
+
- `offset` — gap in pixels between anchor and floating element.
|
|
544
|
+
Default `4`.
|
|
545
|
+
- Returns `{ top, left, placement }`. `placement` is the side that
|
|
546
|
+
won (alignment suffix is preserved internally but not reported in
|
|
547
|
+
the return value).
|
|
548
|
+
- Fallback chain: tries preferred side → opposite side → perpendicular
|
|
549
|
+
pair → clamps to viewport edge if nothing fits.
|
|
550
|
+
- Pure function. No DOM side effects.
|
|
551
|
+
|
|
552
|
+
### teleportToBody(el)
|
|
553
|
+
|
|
554
|
+
Move an element into `<body>`, leaving a comment placeholder so it can
|
|
555
|
+
be restored to its original parent.
|
|
556
|
+
|
|
557
|
+
```js
|
|
558
|
+
const restore = teleportToBody(panel);
|
|
559
|
+
// ... later
|
|
560
|
+
restore();
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
- Returns a cleanup function that restores the element to its origin.
|
|
564
|
+
- No-op + no-op cleanup if the element is already in `<body>` or
|
|
565
|
+
parent is missing.
|
|
566
|
+
- Used for floating UI that needs to escape `overflow: hidden`
|
|
567
|
+
ancestors (popovers, dropdowns).
|
|
568
|
+
- Does NOT set inline styles — the caller's CSS rule (e.g.
|
|
569
|
+
`[data-ln-popover] { position: fixed }`) is responsible for
|
|
570
|
+
positioning context.
|
|
571
|
+
|
|
572
|
+
### measureHidden(el)
|
|
573
|
+
|
|
574
|
+
Read `offsetWidth` / `offsetHeight` of an element that may currently
|
|
575
|
+
be hidden via `display: none`.
|
|
576
|
+
|
|
577
|
+
```js
|
|
578
|
+
const { width, height } = measureHidden(panel);
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
- Temporarily applies `visibility: hidden; display: block; position:
|
|
582
|
+
fixed` to force layout, reads dimensions, then restores the inline
|
|
583
|
+
style values.
|
|
584
|
+
- Returns `{ width: 0, height: 0 }` for a falsy element.
|
|
585
|
+
- Brief inline-style mutation is restored before the function returns
|
|
586
|
+
— the visible DOM never reflects the temporary state.
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## persist.js
|
|
591
|
+
|
|
592
|
+
### persistGet(component, el)
|
|
593
|
+
|
|
594
|
+
Read persisted value for an element. Returns the parsed JSON value or `null` (not found, or storage unavailable).
|
|
595
|
+
|
|
596
|
+
- `component` — component name string (e.g. `'toggle'`, `'tabs'`, `'filter'`)
|
|
597
|
+
- `el` — DOM element with `data-ln-persist` attribute
|
|
598
|
+
|
|
599
|
+
Storage key resolved from `el`: uses `data-ln-persist` value if non-empty, otherwise element's `id`. If neither exists, emits `console.warn` and returns `null`.
|
|
600
|
+
|
|
601
|
+
```js
|
|
602
|
+
import { persistGet } from '../ln-core';
|
|
603
|
+
const saved = persistGet('toggle', el); // null | 'open' | 'close'
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### persistSet(component, el, value)
|
|
607
|
+
|
|
608
|
+
Write a value to localStorage. Value is JSON-serialized. Silently no-ops if localStorage is unavailable or full.
|
|
609
|
+
|
|
610
|
+
```js
|
|
611
|
+
import { persistSet } from '../ln-core';
|
|
612
|
+
persistSet('toggle', el, 'open');
|
|
613
|
+
persistSet('sort', el, { field: 'name', column: null, direction: 'desc' });
|
|
614
|
+
persistSet('filter', el, null); // explicitly clears
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### persistRemove(component, el)
|
|
618
|
+
|
|
619
|
+
Remove a single persisted value for a specific element.
|
|
620
|
+
|
|
621
|
+
```js
|
|
622
|
+
import { persistRemove } from '../ln-core';
|
|
623
|
+
persistRemove('tabs', el);
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### persistClear(component)
|
|
627
|
+
|
|
628
|
+
Remove ALL persisted values for a given component type. Scans all localStorage keys matching `ln:{component}:*`.
|
|
629
|
+
|
|
630
|
+
```js
|
|
631
|
+
import { persistClear } from '../ln-core';
|
|
632
|
+
persistClear('toggle'); // removes all ln:toggle:* keys
|
|
633
|
+
persistClear('sort'); // removes all ln:sort:* keys
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
Useful for "reset to defaults" functionality.
|
|
637
|
+
|
|
638
|
+
### Storage key format
|
|
639
|
+
|
|
640
|
+
```
|
|
641
|
+
ln:{component}:{pagePath}:{id}
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
- `pagePath` — `location.pathname`, lowercase, trailing slash stripped, or `/` for root
|
|
645
|
+
- `id` — element's `id` attribute, or the explicit `data-ln-persist="custom-key"` value
|
|
646
|
+
|
|
647
|
+
Examples:
|
|
648
|
+
```
|
|
649
|
+
ln:toggle:/admin/users:sidebar
|
|
650
|
+
ln:tabs:/settings:settings-tabs
|
|
651
|
+
ln:sort:/admin/orders:orders-table-name
|
|
652
|
+
ln:filter:/admin/users:status-filter
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Opt-in HTML attribute
|
|
656
|
+
|
|
657
|
+
```html
|
|
658
|
+
<!-- Uses element id as storage key -->
|
|
659
|
+
<section id="sidebar" data-ln-toggle="close" data-ln-persist>
|
|
660
|
+
|
|
661
|
+
<!-- Explicit key (no id needed) -->
|
|
662
|
+
<section data-ln-toggle="close" data-ln-persist="sidebar-section">
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Persistence is always opt-in. Elements without `data-ln-persist` are never touched.
|
|
666
|
+
|
|
667
|
+
Components that support `data-ln-persist` document the stored value
|
|
668
|
+
shape in their own READMEs (ln-toggle, ln-accordion, ln-tabs,
|
|
669
|
+
ln-sort, ln-filter).
|
|
670
|
+
|
|
671
|
+
### Graceful degradation
|
|
672
|
+
|
|
673
|
+
- localStorage disabled (private browsing) → silent no-op, components work normally without persistence
|
|
674
|
+
- localStorage full → silent no-op on write, existing data preserved
|
|
675
|
+
- Stale data (panel removed from DOM, column index out of range) → orphan key ignored gracefully
|
|
676
|
+
- Missing `id` + no explicit key → `console.warn` once, persistence skipped for that element
|
|
677
|
+
|
|
678
|
+
---
|
|
679
|
+
|
|
680
|
+
## hash.js
|
|
681
|
+
|
|
682
|
+
Shared URL-fragment codec imported by ln-tabs and ln-modal. All three
|
|
683
|
+
functions are exposed at `window.lnCore.{hashParse, hashGet, hashSet}` for
|
|
684
|
+
coordinators.
|
|
685
|
+
|
|
686
|
+
### Grammar
|
|
687
|
+
|
|
688
|
+
Fragments use the format `#nsA:valA&nsB:valB` — one namespace per component
|
|
689
|
+
instance, `&`-separated. Foreign segments (belonging to other components) are
|
|
690
|
+
preserved on every write. A bare `#ns` (no `:`) encodes an empty value.
|
|
691
|
+
|
|
692
|
+
### hashParse(str = location.hash) → { ns: value }
|
|
693
|
+
|
|
694
|
+
Parse a hash string into a plain object of `{ namespace: value }` pairs.
|
|
695
|
+
|
|
696
|
+
- Strips the leading `#` before processing.
|
|
697
|
+
- Tolerates empty or malformed input (returns `{}`).
|
|
698
|
+
- Each value is `decodeURIComponent`-decoded.
|
|
699
|
+
- Bare `#ns` (no `:`) → `{ ns: '' }` (empty string, not `null`).
|
|
700
|
+
|
|
701
|
+
### hashGet(ns) → string | null
|
|
702
|
+
|
|
703
|
+
Read the current value for a namespace from `location.hash`. Returns the
|
|
704
|
+
decoded string value if the segment is present (including `''` for bare `#ns`),
|
|
705
|
+
or `null` if the namespace is absent.
|
|
706
|
+
|
|
707
|
+
### hashSet(ns, value)
|
|
708
|
+
|
|
709
|
+
Read-modify-write `location.hash`, updating ONLY `ns` and preserving all other
|
|
710
|
+
segments. Uses `location.hash =` (not `pushState`) so the browser adds a history
|
|
711
|
+
entry and fires `hashchange`.
|
|
712
|
+
|
|
713
|
+
THREE-STATE write:
|
|
714
|
+
|
|
715
|
+
| `value` | Effect |
|
|
716
|
+
|---|---|
|
|
717
|
+
| `null` | Removes the `ns` segment entirely |
|
|
718
|
+
| `''` (empty string) | Writes a bare `#ns` (no `:`) |
|
|
719
|
+
| any other string | Writes `#ns:encodeURIComponent(value)` |
|
|
720
|
+
|
|
721
|
+
**Identical value is a no-op** — if the resulting hash string equals the
|
|
722
|
+
current `location.hash`, the assignment is skipped. This prevents spurious
|
|
723
|
+
`hashchange` events and the loops they could cause.
|
|
724
|
+
|
|
725
|
+
### Usage note
|
|
726
|
+
|
|
727
|
+
Both ln-tabs (anchor-trigger groups) and ln-modal (hash-bound modals) use this
|
|
728
|
+
codec. Because `hashSet` is a read-modify-write that preserves foreign segments,
|
|
729
|
+
switching a tab never clobbers an open modal's hash segment and vice versa —
|
|
730
|
+
codec isolation is guaranteed by design.
|
|
731
|
+
|
|
732
|
+
See also: [Hash-state doctrine](../../docs/architecture/hash-state.md) — the five rules governing namespace ownership, foreign-segment preservation, anchor interception, coordinator wiring, and the router fragment guard.
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
## crypto.js
|
|
737
|
+
|
|
738
|
+
Built-in cryptographic utilities using the native browser **Web Crypto API** (AES-GCM 256-bit envelope encryption). These are transparently integrated into the data store for encryption at rest.
|
|
739
|
+
|
|
740
|
+
### setCryptoKey(secretString)
|
|
741
|
+
|
|
742
|
+
Derive a 256-bit cryptographically strong key from a password or token and activate it framework-wide.
|
|
743
|
+
|
|
744
|
+
```js
|
|
745
|
+
import { setCryptoKey } from '../ln-core';
|
|
746
|
+
|
|
747
|
+
// Typically called on user login or application bootstrap
|
|
748
|
+
await setCryptoKey('my-super-secret-user-passphrase-or-session-token');
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
- **`secretString`** — raw string value. Passing `null`, `undefined`, or an empty string deactivates encryption.
|
|
752
|
+
- **Returns** `Promise<void>`. Resolves when the key is successfully derived and imported.
|
|
753
|
+
|
|
754
|
+
### getCryptoKey()
|
|
755
|
+
|
|
756
|
+
Retrieve the currently active `CryptoKey` object instance.
|
|
757
|
+
|
|
758
|
+
```js
|
|
759
|
+
import { getCryptoKey } from '../ln-core';
|
|
760
|
+
|
|
761
|
+
const key = getCryptoKey(); // null or CryptoKey
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
- **Returns** `CryptoKey` or `null` if no key has been derived yet.
|
|
765
|
+
|
|
766
|
+
### encryptData(plainData, key = null)
|
|
767
|
+
|
|
768
|
+
Encrypt any serializable value (objects/arrays are converted to JSON strings).
|
|
769
|
+
|
|
770
|
+
```js
|
|
771
|
+
import { encryptData } from '../ln-core';
|
|
772
|
+
|
|
773
|
+
const result = await encryptData({ text: 'Sensitive message' });
|
|
774
|
+
// {
|
|
775
|
+
// encrypted: true,
|
|
776
|
+
// iv: 'base64-iv-string...',
|
|
777
|
+
// data: 'base64-ciphertext...'
|
|
778
|
+
// }
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
- **`plainData`** — any primitive value, array, or object.
|
|
782
|
+
- **`key`** — optional custom `CryptoKey`. Defaults to the active global key.
|
|
783
|
+
- **Returns** `Promise<Object>` containing `encrypted: true`, base64 `iv`, and base64 `data`. Returns raw `plainData` if no active key is available.
|
|
784
|
+
|
|
785
|
+
### decryptData(encryptedObject, key = null)
|
|
786
|
+
|
|
787
|
+
Decrypt an encrypted object back into its original form (automatically parsing JSON strings back to objects).
|
|
788
|
+
|
|
789
|
+
```js
|
|
790
|
+
import { decryptData } from '../ln-core';
|
|
791
|
+
|
|
792
|
+
const decrypted = await decryptData(result);
|
|
793
|
+
console.log(decrypted.text); // 'Sensitive message'
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
- **`encryptedObject`** — an object generated by `encryptData`.
|
|
797
|
+
- **`key`** — optional custom `CryptoKey`. Defaults to the active global key.
|
|
798
|
+
- **Returns** `Promise<any>`. The original plain data, or `{ ...encryptedObject, decryptionError: true }` if decryption failed (e.g., incorrect key).
|
|
799
|
+
|
|
800
|
+
---
|
|
801
|
+
|
|
802
|
+
## Typical Usage Pattern
|
|
803
|
+
|
|
804
|
+
```js
|
|
805
|
+
import { dispatch, dispatchCancelable, guardBody, findElements, deepReactive, createBatcher, fill, renderList } from '../ln-core';
|
|
806
|
+
|
|
807
|
+
(function () {
|
|
808
|
+
const DOM_SELECTOR = 'data-ln-example';
|
|
809
|
+
const DOM_ATTRIBUTE = 'lnExample';
|
|
810
|
+
if (window[DOM_ATTRIBUTE] !== undefined) return;
|
|
811
|
+
|
|
812
|
+
function constructor(domRoot) {
|
|
813
|
+
findElements(domRoot, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function _component(dom) {
|
|
817
|
+
this.dom = dom;
|
|
818
|
+
const self = this;
|
|
819
|
+
|
|
820
|
+
// Reactive state + batched rendering
|
|
821
|
+
const batchRender = createBatcher(function () { self._render(); });
|
|
822
|
+
this.state = deepReactive({ items: [] }, batchRender);
|
|
823
|
+
|
|
824
|
+
return this;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
_component.prototype._render = function () {
|
|
828
|
+
renderList(this.dom, this.state.items, 'example-item',
|
|
829
|
+
function (item) { return item.id; },
|
|
830
|
+
function (el, item) { fill(el, item); },
|
|
831
|
+
'ln-example'
|
|
832
|
+
);
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// ... observer, init ...
|
|
836
|
+
window[DOM_ATTRIBUTE] = constructor;
|
|
837
|
+
})();
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
---
|
|
841
|
+
|
|
842
|
+
## The `data-ln-value` Primitive
|
|
843
|
+
|
|
844
|
+
A locale-formatted cell/item shows a human-readable string; the raw machine
|
|
845
|
+
value lives in `data-ln-value`. Sort and filter operate on the raw value —
|
|
846
|
+
**never** on the formatted text.
|
|
847
|
+
|
|
848
|
+
```html
|
|
849
|
+
<td data-ln-value="1250.50">$1,250.50</td>
|
|
850
|
+
<td data-ln-value="1700000000">15 Nov 2023</td>
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
For details on using `data-ln-value` alongside semantic HTML5 elements like `<data>` and `<time>`, see the [HTML Markup Rules](file:///c:/laragon/www/ln-ashlar/docs-mcp/doctrine/html-markup-rules.md#L65-L79).
|
|
854
|
+
|
|
855
|
+
### Raw format
|
|
856
|
+
|
|
857
|
+
- **Amounts** — plain number, dot decimal, NO thousands grouping: `1250.50`.
|
|
858
|
+
- **Dates** — numeric Unix timestamp, consistent per column (demo uses seconds,
|
|
859
|
+
e.g. `1700000000`).
|
|
860
|
+
|
|
861
|
+
### Why (the gotcha)
|
|
862
|
+
|
|
863
|
+
Sort coerces with `parseFloat(raw) || 0`. Formatted display text breaks it:
|
|
864
|
+
|
|
865
|
+
| Formatted text | `parseFloat` sees | Correct raw |
|
|
866
|
+
|----------------|-------------------|-------------|
|
|
867
|
+
| `1.250` (EU grouping) | `1.25` ❌ | `1250` |
|
|
868
|
+
| `15.11.2023` | `15.11` ❌ | `1700000000` (timestamp) |
|
|
869
|
+
| `2026-04-12` | `2026` ❌ | timestamp |
|
|
870
|
+
|
|
871
|
+
### The split
|
|
872
|
+
|
|
873
|
+
- `data-ln-value` — the VALUE. Universal. Read by `ln-core.readValue`.
|
|
874
|
+
- **Type is inferred, not declared.** `ln-core.detectValueType` scans the current value set once
|
|
875
|
+
per sort operation — no `data-ln-*-sort` type attribute exists anymore. All non-empty values
|
|
876
|
+
finite numbers → numeric compare; otherwise `ln-core.compareValues` + `Intl.Collator` string
|
|
877
|
+
compare. See `js/ln-sort/README.md`.
|
|
878
|
+
|
|
879
|
+
### The reader
|
|
880
|
+
|
|
881
|
+
`ln-core.readValue(el)` is the single extraction path — that is what makes the
|
|
882
|
+
attribute cross-component. `ln-table` reads through it today.
|
|
883
|
+
|
|
884
|
+
### Formatting responsibility
|
|
885
|
+
|
|
886
|
+
The client never re-formats — it only reorders/filters by raw. Formatting is
|
|
887
|
+
the server's job: Blade/PHP in SSR mode, or the API payload in data-driven mode.
|
|
888
|
+
`<html lang>` drives `Intl.Collator` for string sort.
|
|
889
|
+
|
|
890
|
+
### Per mode
|
|
891
|
+
|
|
892
|
+
- **SSR** — the raw value lives in `data-ln-value` on the element. `readValue`
|
|
893
|
+
pulls it during `_parseRows`.
|
|
894
|
+
- **Data-driven** — the raw value lives in the record field (API payload); the
|
|
895
|
+
rendered element may also carry `data-ln-value`. Sorting runs on the record
|
|
896
|
+
field. Never sort formatted text.
|