@livenetworks/ashlar 1.3.2
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 +177 -0
- package/js/COMPONENTS.md +1102 -0
- package/js/index.js +41 -0
- package/js/ln-accordion/README.md +137 -0
- package/js/ln-accordion/ln-accordion.js +1 -0
- package/js/ln-accordion/src/ln-accordion.js +41 -0
- package/js/ln-ajax/README.md +91 -0
- package/js/ln-ajax/ln-ajax.js +1 -0
- package/js/ln-ajax/src/ln-ajax.js +277 -0
- package/js/ln-api-connector/README.md +150 -0
- package/js/ln-api-connector/ln-api-connector.js +1 -0
- package/js/ln-api-connector/src/ln-api-connector.js +265 -0
- package/js/ln-autoresize/README.md +80 -0
- package/js/ln-autoresize/ln-autoresize.js +1 -0
- package/js/ln-autoresize/src/ln-autoresize.js +47 -0
- package/js/ln-autosave/README.md +92 -0
- package/js/ln-autosave/ln-autosave.js +1 -0
- package/js/ln-autosave/src/ln-autosave.js +147 -0
- package/js/ln-circular-progress/README.md +161 -0
- package/js/ln-circular-progress/ln-circular-progress.js +1 -0
- package/js/ln-circular-progress/src/ln-circular-progress.js +133 -0
- package/js/ln-confirm/README.md +86 -0
- package/js/ln-confirm/_ln-confirm.scss +13 -0
- package/js/ln-confirm/ln-confirm.js +1 -0
- package/js/ln-confirm/src/ln-confirm.js +131 -0
- package/js/ln-core/crypto.js +83 -0
- package/js/ln-core/helpers.js +411 -0
- package/js/ln-core/index.js +5 -0
- package/js/ln-core/persist.js +71 -0
- package/js/ln-core/positioning.js +207 -0
- package/js/ln-core/reactive.js +74 -0
- package/js/ln-couchdb-connector/README.md +156 -0
- package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -0
- package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +348 -0
- package/js/ln-data-coordinator/README.md +165 -0
- package/js/ln-data-coordinator/ln-data-coordinator.js +1 -0
- package/js/ln-data-coordinator/src/ln-data-coordinator.js +249 -0
- package/js/ln-data-store/README.md +94 -0
- package/js/ln-data-store/ln-data-store.js +1 -0
- package/js/ln-data-store/src/ln-data-store.js +699 -0
- package/js/ln-data-table/README.md +110 -0
- package/js/ln-data-table/ln-data-table.js +1 -0
- package/js/ln-data-table/ln-data-table.scss +10 -0
- package/js/ln-data-table/src/ln-data-table.js +1103 -0
- package/js/ln-date/README.md +151 -0
- package/js/ln-date/ln-date.js +1 -0
- package/js/ln-date/src/ln-date.js +442 -0
- package/js/ln-dropdown/README.md +117 -0
- package/js/ln-dropdown/ln-dropdown.js +1 -0
- package/js/ln-dropdown/ln-dropdown.scss +15 -0
- package/js/ln-dropdown/src/ln-dropdown.js +174 -0
- package/js/ln-external-links/README.md +341 -0
- package/js/ln-external-links/ln-external-links.js +1 -0
- package/js/ln-external-links/src/ln-external-links.js +116 -0
- package/js/ln-filter/README.md +99 -0
- package/js/ln-filter/ln-filter.js +1 -0
- package/js/ln-filter/ln-filter.scss +7 -0
- package/js/ln-filter/src/ln-filter.js +404 -0
- package/js/ln-form/README.md +101 -0
- package/js/ln-form/ln-form.js +1 -0
- package/js/ln-form/src/ln-form.js +199 -0
- package/js/ln-http/README.md +89 -0
- package/js/ln-http/ln-http.js +1 -0
- package/js/ln-http/src/ln-http.js +219 -0
- package/js/ln-icons/README.md +88 -0
- package/js/ln-icons/ln-icons.js +1 -0
- package/js/ln-icons/src/ln-icons.js +169 -0
- package/js/ln-link/README.md +303 -0
- package/js/ln-link/ln-link.js +1 -0
- package/js/ln-link/src/ln-link.js +196 -0
- package/js/ln-modal/README.md +154 -0
- package/js/ln-modal/ln-modal.js +1 -0
- package/js/ln-modal/ln-modal.scss +11 -0
- package/js/ln-modal/src/ln-modal.js +201 -0
- package/js/ln-nav/README.md +70 -0
- package/js/ln-nav/ln-nav.js +1 -0
- package/js/ln-nav/src/ln-nav.js +177 -0
- package/js/ln-number/README.md +122 -0
- package/js/ln-number/ln-number.js +1 -0
- package/js/ln-number/src/ln-number.js +302 -0
- package/js/ln-popover/README.md +127 -0
- package/js/ln-popover/ln-popover.js +1 -0
- package/js/ln-popover/src/ln-popover.js +288 -0
- package/js/ln-progress/README.md +442 -0
- package/js/ln-progress/ln-progress.js +1 -0
- package/js/ln-progress/src/ln-progress.js +150 -0
- package/js/ln-search/README.md +83 -0
- package/js/ln-search/ln-search.js +1 -0
- package/js/ln-search/ln-search.scss +7 -0
- package/js/ln-search/src/ln-search.js +114 -0
- package/js/ln-sortable/README.md +95 -0
- package/js/ln-sortable/ln-sortable.js +1 -0
- package/js/ln-sortable/src/ln-sortable.js +203 -0
- package/js/ln-table/README.md +101 -0
- package/js/ln-table/ln-table-sort.js +1 -0
- package/js/ln-table/ln-table.js +1 -0
- package/js/ln-table/ln-table.scss +11 -0
- package/js/ln-table/src/ln-table-sort.js +168 -0
- package/js/ln-table/src/ln-table.js +473 -0
- package/js/ln-tabs/README.md +137 -0
- package/js/ln-tabs/ln-tabs.js +1 -0
- package/js/ln-tabs/src/ln-tabs.js +171 -0
- package/js/ln-time/README.md +81 -0
- package/js/ln-time/ln-time.js +1 -0
- package/js/ln-time/src/ln-time.js +192 -0
- package/js/ln-toast/README.md +122 -0
- package/js/ln-toast/ln-toast.js +15 -0
- package/js/ln-toast/src/ln-toast.js +210 -0
- package/js/ln-toast/template.html +14 -0
- package/js/ln-toggle/README.md +137 -0
- package/js/ln-toggle/ln-toggle.js +1 -0
- package/js/ln-toggle/src/ln-toggle.js +139 -0
- package/js/ln-tooltip/README.md +58 -0
- package/js/ln-tooltip/ln-tooltip.js +1 -0
- package/js/ln-tooltip/ln-tooltip.scss +9 -0
- package/js/ln-tooltip/src/ln-tooltip.js +169 -0
- package/js/ln-translations/README.md +96 -0
- package/js/ln-translations/ln-translations.js +1 -0
- package/js/ln-translations/src/ln-translations.js +275 -0
- package/js/ln-upload/README.md +180 -0
- package/js/ln-upload/ln-upload.js +1 -0
- package/js/ln-upload/ln-upload.scss +20 -0
- package/js/ln-upload/src/ln-upload.js +407 -0
- package/js/ln-validate/README.md +108 -0
- package/js/ln-validate/ln-validate.js +1 -0
- package/js/ln-validate/src/ln-validate.js +160 -0
- package/package.json +55 -0
- package/scss/base/_global.scss +83 -0
- package/scss/base/_reset.scss +17 -0
- package/scss/base/_typography.scss +125 -0
- package/scss/components/_accordion.scss +34 -0
- package/scss/components/_ajax.scss +15 -0
- package/scss/components/_alert.scss +5 -0
- package/scss/components/_app-shell.scss +15 -0
- package/scss/components/_avatar.scss +6 -0
- package/scss/components/_breadcrumbs.scss +33 -0
- package/scss/components/_button.scss +20 -0
- package/scss/components/_card.scss +10 -0
- package/scss/components/_chip.scss +5 -0
- package/scss/components/_circular-progress.scss +29 -0
- package/scss/components/_confirm.scss +5 -0
- package/scss/components/_data-table.scss +83 -0
- package/scss/components/_dropdown.scss +25 -0
- package/scss/components/_empty-state.scss +22 -0
- package/scss/components/_form.scss +100 -0
- package/scss/components/_layout.scss +8 -0
- package/scss/components/_link.scss +11 -0
- package/scss/components/_ln-table.scss +60 -0
- package/scss/components/_loader.scss +6 -0
- package/scss/components/_modal.scss +20 -0
- package/scss/components/_nav.scss +9 -0
- package/scss/components/_page-header.scss +10 -0
- package/scss/components/_popover.scss +10 -0
- package/scss/components/_progress.scss +17 -0
- package/scss/components/_prose.scss +5 -0
- package/scss/components/_scrollbar.scss +32 -0
- package/scss/components/_sections.scss +12 -0
- package/scss/components/_sidebar.scss +5 -0
- package/scss/components/_stat-card.scss +5 -0
- package/scss/components/_status-badge.scss +4 -0
- package/scss/components/_stepper.scss +5 -0
- package/scss/components/_table.scss +19 -0
- package/scss/components/_tabs.scss +21 -0
- package/scss/components/_timeline.scss +14 -0
- package/scss/components/_toast.scss +41 -0
- package/scss/components/_toggle.scss +81 -0
- package/scss/components/_tooltip.scss +18 -0
- package/scss/components/_translations.scss +111 -0
- package/scss/components/_upload.scss +51 -0
- package/scss/config/_breakpoints.scss +72 -0
- package/scss/config/_density.scss +117 -0
- package/scss/config/_icons.scss +37 -0
- package/scss/config/_mixins.scss +13 -0
- package/scss/config/_theme.scss +216 -0
- package/scss/config/_tokens.scss +419 -0
- package/scss/config/mixins/_accordion.scss +52 -0
- package/scss/config/mixins/_ajax.scss +39 -0
- package/scss/config/mixins/_alert.scss +82 -0
- package/scss/config/mixins/_app-shell.scss +312 -0
- package/scss/config/mixins/_avatar.scss +109 -0
- package/scss/config/mixins/_borders.scss +36 -0
- package/scss/config/mixins/_breadcrumbs.scss +72 -0
- package/scss/config/mixins/_breakpoints.scss +62 -0
- package/scss/config/mixins/_btn.scss +179 -0
- package/scss/config/mixins/_card.scss +338 -0
- package/scss/config/mixins/_chip.scss +66 -0
- package/scss/config/mixins/_circular-progress.scss +71 -0
- package/scss/config/mixins/_collapsible.scss +24 -0
- package/scss/config/mixins/_colors.scss +46 -0
- package/scss/config/mixins/_confirm.scss +31 -0
- package/scss/config/mixins/_data-table.scss +346 -0
- package/scss/config/mixins/_display.scss +32 -0
- package/scss/config/mixins/_dropdown.scss +143 -0
- package/scss/config/mixins/_empty-state.scss +30 -0
- package/scss/config/mixins/_focus.scss +55 -0
- package/scss/config/mixins/_footer.scss +42 -0
- package/scss/config/mixins/_form.scss +601 -0
- package/scss/config/mixins/_index.scss +58 -0
- package/scss/config/mixins/_interaction.scss +15 -0
- package/scss/config/mixins/_kbd.scss +22 -0
- package/scss/config/mixins/_layout.scss +117 -0
- package/scss/config/mixins/_link.scss +55 -0
- package/scss/config/mixins/_ln-table.scss +420 -0
- package/scss/config/mixins/_loader.scss +26 -0
- package/scss/config/mixins/_modal.scss +66 -0
- package/scss/config/mixins/_motion.scss +19 -0
- package/scss/config/mixins/_nav.scss +273 -0
- package/scss/config/mixins/_page-header.scss +69 -0
- package/scss/config/mixins/_popover.scss +25 -0
- package/scss/config/mixins/_position.scss +32 -0
- package/scss/config/mixins/_progress.scss +56 -0
- package/scss/config/mixins/_prose.scss +127 -0
- package/scss/config/mixins/_shadows.scss +8 -0
- package/scss/config/mixins/_sidebar.scss +95 -0
- package/scss/config/mixins/_sizing.scss +6 -0
- package/scss/config/mixins/_spacing.scss +19 -0
- package/scss/config/mixins/_stat-card.scss +68 -0
- package/scss/config/mixins/_status-badge.scss +83 -0
- package/scss/config/mixins/_stepper.scss +78 -0
- package/scss/config/mixins/_table.scss +215 -0
- package/scss/config/mixins/_tabs.scss +64 -0
- package/scss/config/mixins/_timeline.scss +69 -0
- package/scss/config/mixins/_toast.scss +148 -0
- package/scss/config/mixins/_tooltip.scss +111 -0
- package/scss/config/mixins/_transitions.scss +10 -0
- package/scss/config/mixins/_translations.scss +124 -0
- package/scss/config/mixins/_typography.scss +57 -0
- package/scss/config/mixins/_upload.scss +168 -0
- package/scss/ln-ashlar.scss +62 -0
- package/scss/tabler-icons.txt +5039 -0
- package/scss/utilities/_animations.scss +83 -0
- package/scss/utilities/_utilities.scss +49 -0
package/js/COMPONENTS.md
ADDED
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
# JS Components — Conventions and Patterns
|
|
2
|
+
|
|
3
|
+
## ln-ashlar Project Architecture (mandatory)
|
|
4
|
+
|
|
5
|
+
Every project using ln-ashlar JS components **MUST** follow three layers:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────┐
|
|
9
|
+
│ Coordinator (project-specific) │
|
|
10
|
+
│ Catches UI actions → dispatches request events → │
|
|
11
|
+
│ reacts to notification events with UI feedback │
|
|
12
|
+
├─────────────────────────────────────────────────────┤
|
|
13
|
+
│ Components (reusable) │
|
|
14
|
+
│ State + CRUD + request listeners + notifications │
|
|
15
|
+
├─────────────────────────────────────────────────────┤
|
|
16
|
+
│ ln-ashlar (library) │
|
|
17
|
+
│ ln-toggle, ln-accordion, ln-modal, ln-toast... │
|
|
18
|
+
└─────────────────────────────────────────────────────┘
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Three Rules
|
|
22
|
+
|
|
23
|
+
1. **Component = data layer.** Manages state, CRUD, its own DOM. Does NOT open modals, does NOT show toast, does NOT read external forms.
|
|
24
|
+
2. **Coordinator = UI wiring.** Catches buttons/forms, dispatches request events to components, reacts to notification events with UI feedback (toast, modal, highlight).
|
|
25
|
+
3. **Commands → request events. Queries → direct API.** The coordinator NEVER calls prototype methods for state mutations (`el.lnProfile.create()`). ALWAYS dispatches a request event (`ln-profile:request-create`). Reading state directly is allowed (`el.lnProfile.currentId`).
|
|
26
|
+
|
|
27
|
+
### Event flow
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
[User clicks button]
|
|
31
|
+
↓
|
|
32
|
+
[Coordinator] catches click on [data-ln-action="new-profile"]
|
|
33
|
+
↓
|
|
34
|
+
[Coordinator] reads input, dispatches request event:
|
|
35
|
+
nav.dispatchEvent('ln-profile:request-create', { detail: { name } })
|
|
36
|
+
↓
|
|
37
|
+
[Component ln-profile] listens for request-create, calls self.create(name)
|
|
38
|
+
↓
|
|
39
|
+
[Component] changes state, renders DOM, dispatches notification:
|
|
40
|
+
_dispatch(dom, 'ln-profile:created', { profileId, profile })
|
|
41
|
+
↓
|
|
42
|
+
[Coordinator] listens for ln-profile:created → shows toast, closes modal
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Workflow for new functionality
|
|
46
|
+
|
|
47
|
+
1. **Component**: add prototype method (clean — accept params, change state, dispatch notification event)
|
|
48
|
+
2. **Component**: add request listener in `_bindEvents` (calls the same prototype method)
|
|
49
|
+
3. **Coordinator**: add UI trigger (click / form submit → dispatch request event to component)
|
|
50
|
+
4. **Coordinator**: add UI reaction (listen to notification event → toast / modal / highlight)
|
|
51
|
+
|
|
52
|
+
### Test: "Is it a component or coordinator?"
|
|
53
|
+
|
|
54
|
+
| Question | If YES → | If NO → |
|
|
55
|
+
|---------|----------|----------|
|
|
56
|
+
| Changes its own state (CRUD)? | component | coordinator |
|
|
57
|
+
| Renders its own DOM (list, buttons)? | component | coordinator |
|
|
58
|
+
| Opens modal / shows toast? | coordinator | component |
|
|
59
|
+
| Reads input from form? | coordinator | component |
|
|
60
|
+
| Listens to `[data-ln-action="..."]` click? | coordinator | component |
|
|
61
|
+
| Listens to click on **its own child** element? | component | coordinator |
|
|
62
|
+
| Bridges two components (A:event → B:attribute)? | coordinator | — |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## IIFE Pattern (mandatory)
|
|
67
|
+
|
|
68
|
+
Every component is an IIFE (Immediately Invoked Function Expression). Shared helpers are imported from `ln-core`; the IIFE body has no exports.
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
import { dispatch } from '../ln-core';
|
|
72
|
+
import { deepReactive, createBatcher } from '../ln-core';
|
|
73
|
+
|
|
74
|
+
(function () {
|
|
75
|
+
const DOM_SELECTOR = 'data-ln-{name}';
|
|
76
|
+
const DOM_ATTRIBUTE = 'ln{Name}';
|
|
77
|
+
|
|
78
|
+
// Guard against double loading
|
|
79
|
+
if (window[DOM_ATTRIBUTE] !== undefined) return;
|
|
80
|
+
|
|
81
|
+
// ... component ...
|
|
82
|
+
|
|
83
|
+
window[DOM_ATTRIBUTE] = constructor;
|
|
84
|
+
})();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### ln-core shared helpers
|
|
88
|
+
|
|
89
|
+
| Import | Purpose |
|
|
90
|
+
|--------|---------|
|
|
91
|
+
| `findElements(root, selector, attr, Constructor)` | Find all `[selector]` under root, skip initialized, instantiate |
|
|
92
|
+
| `dispatch(el, name, detail)` | Fire non-cancelable CustomEvent |
|
|
93
|
+
| `dispatchCancelable(el, name, detail)` | Fire cancelable CustomEvent, returns event |
|
|
94
|
+
| `cloneTemplate(name, tag)` | Clone `<template data-ln-template="name">`, cached |
|
|
95
|
+
| `fill(root, data)` | Declarative DOM binding via `data-ln-field`, `data-ln-attr`, `data-ln-show`, `data-ln-class` |
|
|
96
|
+
| `fillTemplate(clone, data)` | Replace `{{ key }}` text-node placeholders in cloned template with `data[key]` values |
|
|
97
|
+
| `buildDict(root, selector)` | Read hidden i18n elements once at init, return plain object, remove from DOM |
|
|
98
|
+
| `renderList(container, items, tpl, keyFn, fillFn, tag)` | Keyed list rendering with DOM reuse |
|
|
99
|
+
| `reactiveState(initial, onChange)` | Shallow Proxy — onChange(prop, value, old) per set |
|
|
100
|
+
| `deepReactive(obj, onChange)` | Deep Proxy — onChange() on any nested change |
|
|
101
|
+
| `createBatcher(renderFn, afterRender)` | Coalesce multiple sync state changes into one render |
|
|
102
|
+
|
|
103
|
+
Import only what the component needs. Vite tree-shakes unused exports.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Instance-based Pattern (recommended)
|
|
108
|
+
|
|
109
|
+
The component is **attached to a DOM element**. The API lives on the element, NOT on `window`.
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
import { findElements } from '../ln-core';
|
|
113
|
+
|
|
114
|
+
// window[DOM_ATTRIBUTE] is just the constructor function
|
|
115
|
+
function constructor(domRoot) {
|
|
116
|
+
findElements(domRoot, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _component(dom) {
|
|
120
|
+
this.dom = dom;
|
|
121
|
+
// ... init ...
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Prototype methods = public API
|
|
125
|
+
_component.prototype.open = function () { ... };
|
|
126
|
+
_component.prototype.close = function () { ... };
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Usage:**
|
|
130
|
+
```javascript
|
|
131
|
+
// Attribute is the contract — write data-ln-toggle to change state
|
|
132
|
+
document.getElementById('sidebar').setAttribute('data-ln-toggle', 'open');
|
|
133
|
+
document.getElementById('sidebar').setAttribute('data-ln-toggle', 'close');
|
|
134
|
+
|
|
135
|
+
// Constructor — only for non-standard cases (Shadow DOM, iframe)
|
|
136
|
+
// Dynamic AJAX HTML does NOT require manual init — MutationObserver handles it automatically
|
|
137
|
+
window.lnToggle(container);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Attribute Bridge (mandatory)
|
|
143
|
+
|
|
144
|
+
Instance methods that change component state **must only call `setAttribute`**. The MutationObserver observes the attribute change and applies the actual state. Direct DOM manipulation inside instance methods is forbidden.
|
|
145
|
+
|
|
146
|
+
```javascript
|
|
147
|
+
// CORRECT — method is a thin setAttribute wrapper
|
|
148
|
+
_component.prototype.open = function () {
|
|
149
|
+
if (this.isOpen) return;
|
|
150
|
+
this.dom.setAttribute(DOM_SELECTOR, 'open');
|
|
151
|
+
// MutationObserver fires → _syncAttribute() applies state
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// WRONG — method bypasses the attribute and manipulates state directly
|
|
155
|
+
_component.prototype.open = function () {
|
|
156
|
+
this.isOpen = true;
|
|
157
|
+
this.dom.classList.add('open');
|
|
158
|
+
document.addEventListener('keydown', this._onEscape);
|
|
159
|
+
dispatch(this.dom, 'ln-component:open', { target: this.dom });
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Why:** The attribute is the single source of truth. Any external code can trigger the same state change with `el.setAttribute(...)` and get identical behaviour. The component never has two code paths to the same state.
|
|
164
|
+
|
|
165
|
+
**Rule:** If a prototype method changes state, its entire body is `this.dom.setAttribute(...)`. All state logic lives in `_syncAttribute()` (or equivalent), called only by the MutationObserver.
|
|
166
|
+
|
|
167
|
+
### Off-Doctrine: Checkbox State (The "Checkbox Hack")
|
|
168
|
+
|
|
169
|
+
While utilizing an `<input type="checkbox">` and the CSS sibling selector (`:checked ~ .menu`) is a common "pure CSS" pattern for toggling dropdowns or modals, it is **off-doctrine** for `ln-ashlar` components and must not be used:
|
|
170
|
+
|
|
171
|
+
1. **Non-Observable Programmatic Updates:** Changing `.checked = true` programmatically via JavaScript **does not** dispatch native browser `'change'` or `'input'` events. It also **does not** trigger the `MutationObserver` (which only watches attribute modifications in the HTML markup). This completely breaks the reactive attribute-driven bridge.
|
|
172
|
+
2. **Teleportation Conflict:** `ln-ashlar` components regularly teleport overlays to `document.body` (e.g., dropdown menus, modals) to prevent parent `z-index` and `overflow: hidden` issues. Teleportation immediately breaks the CSS sibling/child relationships required for CSS-only checkbox selectors.
|
|
173
|
+
3. **Broken Encapsulation:** A checkbox lives inside or beside the component. To read or write state, external coordinators or other components would have to query the internal DOM structure (e.g., `modal.querySelector('input[type="checkbox"]').checked`), exposing private details.
|
|
174
|
+
4. **Accessibility (ARIA) Semantic Mismatch:** Modals, dropdown triggers, and popovers require specific ARIA roles (e.g., `role="dialog"`, `aria-haspopup="menu"`, `aria-expanded`). A checkbox natively announces itself as a checkbox, which confuses screen readers and causes accessibility regressions.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## MutationObserver (mandatory)
|
|
179
|
+
|
|
180
|
+
Every component must watch for **two types** of changes:
|
|
181
|
+
|
|
182
|
+
1. **`childList`** — new element added to DOM (AJAX, `innerHTML`, `appendChild`)
|
|
183
|
+
2. **`attributes`** — `data-ln-*` attribute added to existing element (Inspector, `setAttribute`)
|
|
184
|
+
|
|
185
|
+
```javascript
|
|
186
|
+
function _domObserver() {
|
|
187
|
+
const observer = new MutationObserver(function (mutations) {
|
|
188
|
+
for (const mutation of mutations) {
|
|
189
|
+
if (mutation.type === 'childList') {
|
|
190
|
+
for (const node of mutation.addedNodes) {
|
|
191
|
+
if (node.nodeType === 1) {
|
|
192
|
+
findElements(node, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else if (mutation.type === 'attributes') {
|
|
196
|
+
findElements(mutation.target, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
observer.observe(document.body, {
|
|
202
|
+
childList: true,
|
|
203
|
+
subtree: true,
|
|
204
|
+
attributes: true,
|
|
205
|
+
attributeFilter: [DOM_SELECTOR]
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Rules:**
|
|
211
|
+
- `attributeFilter` always includes `DOM_SELECTOR` (and optionally trigger attributes like `'data-ln-toggle-for'`)
|
|
212
|
+
- `attributeFilter` is mandatory — without it the observer fires on EVERY attribute change (performance issue)
|
|
213
|
+
- On `attributes` mutation, `mutation.target` is the element whose attribute changed — call `findElements` directly on it
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Reactive Rendering Pattern
|
|
218
|
+
|
|
219
|
+
When a component has **multiple internal state properties** that together drive DOM updates, use `reactiveState` + `createBatcher` + `fill()` from ln-core. This eliminates manual querySelector chains and coalesces multiple synchronous state changes into a single DOM update.
|
|
220
|
+
|
|
221
|
+
### The three primitives
|
|
222
|
+
|
|
223
|
+
| Helper | Role |
|
|
224
|
+
|--------|------|
|
|
225
|
+
| `reactiveState(initial, onChange)` | Proxy — calls `onChange(prop, value, old)` on every top-level property set |
|
|
226
|
+
| `createBatcher(renderFn, afterRender)` | Coalesces sync state changes into one `renderFn` call via `queueMicrotask` |
|
|
227
|
+
| `fill(root, data)` | Writes state to DOM via `data-ln-field`, `data-ln-attr`, `data-ln-show`, `data-ln-class` |
|
|
228
|
+
|
|
229
|
+
### Pattern
|
|
230
|
+
|
|
231
|
+
```javascript
|
|
232
|
+
import { reactiveState, createBatcher, fill, dispatch } from '../ln-core';
|
|
233
|
+
|
|
234
|
+
function _component(dom) {
|
|
235
|
+
this.dom = dom;
|
|
236
|
+
const self = this;
|
|
237
|
+
|
|
238
|
+
const queueRender = createBatcher(
|
|
239
|
+
function () { self._render(); }, // runs after current sync block
|
|
240
|
+
function () { self._afterRender(); } // runs after render — dispatch events here
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
this.state = reactiveState({ key: null, value: null }, queueRender);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_component.prototype._render = function () {
|
|
247
|
+
fill(this.dom, this.state);
|
|
248
|
+
// additional DOM logic derived from state
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
_component.prototype._afterRender = function () {
|
|
252
|
+
dispatch(this.dom, 'ln-component:changed', { key: this.state.key });
|
|
253
|
+
};
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
When multiple properties change in the same sync block:
|
|
257
|
+
|
|
258
|
+
```javascript
|
|
259
|
+
this.state.key = 'status'; // queues render
|
|
260
|
+
this.state.value = 'active'; // queues again — same microtask, no duplicate
|
|
261
|
+
// → microtask fires → _render() once → _afterRender() once
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### `reactiveState` vs `deepReactive`
|
|
265
|
+
|
|
266
|
+
| | `reactiveState` | `deepReactive` |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| **Depth** | Shallow — top-level sets only | Deep — nested object/array mutations |
|
|
269
|
+
| **onChange args** | `(prop, value, old)` | `()` — no args |
|
|
270
|
+
| **Use when** | Flat state with primitive values | State contains nested objects or arrays |
|
|
271
|
+
|
|
272
|
+
### `fill()` — limitations
|
|
273
|
+
|
|
274
|
+
`fill()` sets `textContent` for `data-ln-field` — it does **not** set `.value` on `<input>`, `<textarea>`, or `<select>`. For form elements, set `.value` manually after `fill()`.
|
|
275
|
+
|
|
276
|
+
### When to use
|
|
277
|
+
|
|
278
|
+
Use reactive rendering when a component has **2+ state properties** that together drive DOM, and multiple changes happen in the same sync block.
|
|
279
|
+
|
|
280
|
+
**Don't use it for:**
|
|
281
|
+
- Single boolean state — `this.isOpen = true; dom.classList.toggle(...)` is simpler
|
|
282
|
+
- Event-driven rendering — components that receive data via `set-data` events (ln-data-table)
|
|
283
|
+
- One-shot renders — clone template + fill once, no ongoing state
|
|
284
|
+
|
|
285
|
+
### Used in
|
|
286
|
+
|
|
287
|
+
*No library component currently uses this pattern. It remains documented for project-level components that need multi-property reactive state.*
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## CustomEvent Communication
|
|
292
|
+
|
|
293
|
+
Components do NOT know about each other. Communication ONLY via CustomEvent.
|
|
294
|
+
|
|
295
|
+
```javascript
|
|
296
|
+
import { dispatch } from '../ln-core';
|
|
297
|
+
|
|
298
|
+
// Dispatch
|
|
299
|
+
dispatch(this.dom, 'ln-toggle:open', { target: this.dom });
|
|
300
|
+
|
|
301
|
+
// Listen (in another component or integration code)
|
|
302
|
+
document.addEventListener('ln-toggle:open', function (e) {
|
|
303
|
+
console.log('Opened:', e.detail.target);
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Lifecycle Events
|
|
310
|
+
|
|
311
|
+
Every component with actions must emit **paired events**: `before-{action}` (cancelable) + `{action}` (post).
|
|
312
|
+
|
|
313
|
+
```javascript
|
|
314
|
+
import { dispatch, dispatchCancelable } from '../ln-core';
|
|
315
|
+
|
|
316
|
+
_component.prototype.open = function () {
|
|
317
|
+
if (this.isOpen) return;
|
|
318
|
+
const before = dispatchCancelable(this.dom, 'ln-component:before-open', { target: this.dom });
|
|
319
|
+
if (before.defaultPrevented) return; // external code can cancel
|
|
320
|
+
this.isOpen = true;
|
|
321
|
+
this.dom.classList.add('open');
|
|
322
|
+
dispatch(this.dom, 'ln-component:open', { target: this.dom });
|
|
323
|
+
};
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Rules:**
|
|
327
|
+
- `before-{action}` — `cancelable: true`, fires **before** state change
|
|
328
|
+
- `{action}` — `cancelable: false`, fires **after** state change (fact, not prediction)
|
|
329
|
+
- Naming: `ln-{component}:before-{action}` and `ln-{component}:{action}`
|
|
330
|
+
- `detail` always contains `{ target: HTMLElement }` — the element where the action occurred
|
|
331
|
+
|
|
332
|
+
**Usage:**
|
|
333
|
+
```javascript
|
|
334
|
+
// Cancel conditionally
|
|
335
|
+
element.addEventListener('ln-toggle:before-open', function (e) {
|
|
336
|
+
if (!userHasPermission()) e.preventDefault();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// React after the fact
|
|
340
|
+
document.addEventListener('ln-toggle:open', function (e) {
|
|
341
|
+
analytics.track('panel-opened', e.detail.target.id);
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### CustomEvent detail null checks
|
|
346
|
+
|
|
347
|
+
Components that dispatch their own events control the `detail` payload — accessing
|
|
348
|
+
`e.detail.x` directly is correct because `detail` is always set by the dispatcher.
|
|
349
|
+
|
|
350
|
+
When listening to events where `detail` might be absent (external events, request
|
|
351
|
+
events where callers may omit detail), use the guard pattern:
|
|
352
|
+
|
|
353
|
+
```javascript
|
|
354
|
+
// Guarded access — when detail may be null/undefined
|
|
355
|
+
const loading = e.detail && e.detail.x;
|
|
356
|
+
|
|
357
|
+
// Direct access — when this component dispatched the event
|
|
358
|
+
const target = e.detail.target;
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The `e.detail && e.detail.x` pattern is the project convention. Do not use
|
|
362
|
+
`(e.detail || {}).x` or optional chaining (`e.detail?.x`).
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Trigger Re-init Guard
|
|
367
|
+
|
|
368
|
+
When a component listens for click events on trigger elements, it must set a guard to prevent duplicate listeners on repeated DOM scans (MutationObserver):
|
|
369
|
+
|
|
370
|
+
```javascript
|
|
371
|
+
function _attachTriggers(root) {
|
|
372
|
+
const triggers = Array.from(root.querySelectorAll('[data-ln-{name}-for]'));
|
|
373
|
+
triggers.forEach(function (btn) {
|
|
374
|
+
if (btn[DOM_ATTRIBUTE + 'Trigger']) return; // already initialized
|
|
375
|
+
const handler = function (e) {
|
|
376
|
+
if (e.ctrlKey || e.metaKey || e.button === 1) return; // allow browser shortcuts
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
// ...
|
|
379
|
+
};
|
|
380
|
+
btn.addEventListener('click', handler);
|
|
381
|
+
btn[DOM_ATTRIBUTE + 'Trigger'] = handler; // store handler ref for later removeEventListener
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
In `destroy()`, remove the trigger listeners using the stored handler reference:
|
|
387
|
+
|
|
388
|
+
```javascript
|
|
389
|
+
_component.prototype.destroy = function () {
|
|
390
|
+
// ... other cleanup ...
|
|
391
|
+
const triggers = document.querySelectorAll('[data-ln-{name}-for="' + this.dom.id + '"]');
|
|
392
|
+
for (const btn of triggers) {
|
|
393
|
+
if (btn[DOM_ATTRIBUTE + 'Trigger']) {
|
|
394
|
+
btn.removeEventListener('click', btn[DOM_ATTRIBUTE + 'Trigger']);
|
|
395
|
+
delete btn[DOM_ATTRIBUTE + 'Trigger'];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Rules:**
|
|
402
|
+
- Guard: `btn[DOM_ATTRIBUTE + 'Trigger']` — truthy check works for both old boolean (`true`) and new handler reference (function)
|
|
403
|
+
- Store the handler function (not `true`) so `destroy()` can call `removeEventListener` with the exact reference
|
|
404
|
+
- Always allow ctrl/meta/middle-click before `e.preventDefault()`
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Component Dependencies
|
|
409
|
+
|
|
410
|
+
When a component depends on another (e.g. ln-accordion → ln-toggle):
|
|
411
|
+
|
|
412
|
+
1. **Listen only to post-action events** (`ln-toggle:open`) — not before-events, unless you need to cancel
|
|
413
|
+
2. **Communicate only via events or attributes** — dispatch `request-*` events to the target element, or write the target's `data-ln-*` attribute. NEVER reach into another component's instance to mutate state.
|
|
414
|
+
3. **Emit your own events** for your own actions (`ln-accordion:change`)
|
|
415
|
+
4. **Never import** another component — CustomEvent communication only
|
|
416
|
+
|
|
417
|
+
```javascript
|
|
418
|
+
// Correct — listens to post-action, sets attribute on siblings, emits own event
|
|
419
|
+
dom.addEventListener('ln-toggle:open', function (e) {
|
|
420
|
+
dom.querySelectorAll('[data-ln-toggle]').forEach(function (el) {
|
|
421
|
+
if (el !== e.detail.target && el.getAttribute('data-ln-toggle') === 'open') {
|
|
422
|
+
el.setAttribute('data-ln-toggle', 'close');
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
_dispatch(dom, 'ln-accordion:change', { target: e.detail.target }); // own event
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Coordinator/Mediator Pattern — Canonical Example
|
|
432
|
+
|
|
433
|
+
The architecture follows the **Mediator pattern** (GoF): components do not communicate with each other. The coordinator mediates all cross-component interactions.
|
|
434
|
+
|
|
435
|
+
### Canonical Example: ln-accordion / ln-toggle
|
|
436
|
+
|
|
437
|
+
The ln-ashlar library already implements this:
|
|
438
|
+
|
|
439
|
+
- **ln-toggle** is a component (state layer): `data-ln-toggle` attribute is the single source of truth. MutationObserver detects attribute changes → applies `.open` class, emits `ln-toggle:open` / `ln-toggle:close`. The component exposes no state-mutating method; consumers write the attribute.
|
|
440
|
+
- **ln-accordion** is a coordinator (mediator): listens to `ln-toggle:open` from children, sets `data-ln-toggle="close"` on siblings. **Never** reaches into a sibling's instance — writes the attribute and lets each toggle's own observer run its own close pipeline. Emits its own `ln-accordion:change`.
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
[Toggle A opens — attribute set to "open"]
|
|
444
|
+
↓
|
|
445
|
+
MutationObserver → .open class + ln-toggle:open event (bubbles up)
|
|
446
|
+
↓
|
|
447
|
+
[Accordion] catches it, sets data-ln-toggle="close" on B and C
|
|
448
|
+
↓
|
|
449
|
+
[Toggle B] observer detects attribute change → if was open → closes
|
|
450
|
+
[Toggle C] observer detects attribute change → already closed → no-op
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Toggle **doesn't know** that other toggles exist. Accordion **doesn't know** about toggle's internal state. Communication = attribute changes + events only.
|
|
454
|
+
|
|
455
|
+
### Scaling to Project Level
|
|
456
|
+
|
|
457
|
+
The same pattern scales from library to application:
|
|
458
|
+
|
|
459
|
+
| ln-ashlar (library) | Project (application) | Role |
|
|
460
|
+
|---|---|---|
|
|
461
|
+
| ln-toggle | ln-profile, ln-playlist, ln-deck | Component (state + events) |
|
|
462
|
+
| ln-accordion | ln-mixer (coordinator) | Mediator (event wiring) |
|
|
463
|
+
| `ln-toggle:open` | `ln-profile:switched` | Notification event (fact) |
|
|
464
|
+
| `setAttribute('data-ln-toggle', 'close')` | `ln-deck:request-load` | Attribute change / Request event (command) |
|
|
465
|
+
| `ln-accordion:change` | toast / modal close | Coordinator reaction |
|
|
466
|
+
|
|
467
|
+
### Isolation Rules
|
|
468
|
+
|
|
469
|
+
1. **Component → sibling component: FORBIDDEN.** A component NEVER queries another component (`lnSettings.getApiUrl()`, `nav.lnProfile.getProfile()`). Only the coordinator knows about all of them.
|
|
470
|
+
2. **Component → storage/DB: FORBIDDEN.** A component does NOT call `lnDb.put()` or any storage backend. The coordinator decides which storage backend to call.
|
|
471
|
+
3. **Coordinator → component query: ALLOWED.** The coordinator reads state directly (`el.lnProfile.currentId`).
|
|
472
|
+
4. **Coordinator → component command: ONLY request events.** The coordinator dispatches `request-*` events, the component decides independently.
|
|
473
|
+
|
|
474
|
+
**Why?** Components become storage-agnostic and sibling-agnostic. Changing the backend (IndexedDB → API → localStorage) requires changes ONLY in the coordinator. Adding a new component requires changes ONLY in the coordinator.
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## Auto-init on DOMContentLoaded
|
|
479
|
+
|
|
480
|
+
```javascript
|
|
481
|
+
window[DOM_ATTRIBUTE] = constructor;
|
|
482
|
+
_domObserver();
|
|
483
|
+
|
|
484
|
+
if (document.readyState === 'loading') {
|
|
485
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
486
|
+
constructor(document.body);
|
|
487
|
+
});
|
|
488
|
+
} else {
|
|
489
|
+
constructor(document.body);
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## API Export Patterns
|
|
496
|
+
|
|
497
|
+
Components expose their API in one of three patterns, chosen based on how the component is used.
|
|
498
|
+
|
|
499
|
+
### 1. Global Constructor (default)
|
|
500
|
+
|
|
501
|
+
Most components: the constructor is registered on `window`. Instances live on DOM elements.
|
|
502
|
+
|
|
503
|
+
```javascript
|
|
504
|
+
window[DOM_ATTRIBUTE] = constructor; // window.lnToggle, window.lnModal, ...
|
|
505
|
+
// Instance: el.lnToggle.destroy(), el.lnModal.destroy()
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Use when: component has DOM instances, each element gets its own API.
|
|
509
|
+
|
|
510
|
+
### 2. Element API (per-instance closure)
|
|
511
|
+
|
|
512
|
+
ln-upload: a plain object with methods is attached to the container element. The closure captures instance-specific state (file map, DOM references).
|
|
513
|
+
|
|
514
|
+
```javascript
|
|
515
|
+
container.lnUploadAPI = { getFileIds, getFiles, clear, destroy };
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Use when: instance state is complex (closures, Maps) and doesn't fit the prototype pattern. Multiple instances on the same page each get their own API object.
|
|
519
|
+
|
|
520
|
+
### 3. Global event listener (singleton)
|
|
521
|
+
|
|
522
|
+
ln-toast: no window registration. Pure event listener — the contract is two window events (`ln-toast:enqueue`, `ln-toast:clear`). Project code dispatches; ln-toast listens.
|
|
523
|
+
|
|
524
|
+
```javascript
|
|
525
|
+
// In ln-toast.js — no window[DOM_ATTRIBUTE] = api anywhere
|
|
526
|
+
window.addEventListener('ln-toast:enqueue', _onEnqueue);
|
|
527
|
+
window.addEventListener('ln-toast:clear', _onClear);
|
|
528
|
+
|
|
529
|
+
// Usage from any component
|
|
530
|
+
window.dispatchEvent(new CustomEvent('ln-toast:enqueue', {
|
|
531
|
+
detail: { type: 'success', message: 'Saved' }
|
|
532
|
+
}));
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Use when: the component is a global service that other components should be able to invoke without having a reference to it. Pure event dispatch is portable across iframes, web components, and any script-loading order.
|
|
536
|
+
|
|
537
|
+
### Choosing a pattern
|
|
538
|
+
|
|
539
|
+
| Situation | Pattern |
|
|
540
|
+
|-----------|---------|
|
|
541
|
+
| Multiple instances, each with own DOM + state | Global constructor |
|
|
542
|
+
| Multiple instances, complex closure state | Element API |
|
|
543
|
+
| Single global service, no per-element instances | Global event listener |
|
|
544
|
+
|
|
545
|
+
Do NOT standardize to one pattern — each exists for architectural reasons.
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## Naming
|
|
550
|
+
|
|
551
|
+
| Element | Convention | Example |
|
|
552
|
+
|---------|-----------|--------|
|
|
553
|
+
| Data attribute | `data-ln-{name}` | `data-ln-toggle` |
|
|
554
|
+
| Window constructor | `window.ln{Name}` | `window.lnToggle` |
|
|
555
|
+
| DOM instance | `element.ln{Name}` | `el.lnToggle` |
|
|
556
|
+
| Custom event | `ln-{name}:{action}` | `ln-toggle:open` |
|
|
557
|
+
| CSS class | `.ln-{name}__{element}` | `.ln-toggle__backdrop` |
|
|
558
|
+
| Initialized flag | `data-ln-{name}-initialized` | `data-ln-toggle-for-initialized` |
|
|
559
|
+
| Private function | `_functionName` | `_render`, `_attachTriggers` |
|
|
560
|
+
|
|
561
|
+
---
|
|
562
|
+
|
|
563
|
+
## Co-located SCSS
|
|
564
|
+
|
|
565
|
+
If a component needs CSS, create `js/ln-{name}/ln-{name}.scss`:
|
|
566
|
+
|
|
567
|
+
```scss
|
|
568
|
+
@use '../../scss/config/mixins' as *;
|
|
569
|
+
|
|
570
|
+
// Use @include mixins and var(--token) values. Any transition
|
|
571
|
+
// that animates transform/opacity/width/height MUST be wrapped
|
|
572
|
+
// in @include motion-safe { ... }.
|
|
573
|
+
.ln-{name}__element {
|
|
574
|
+
@include fixed;
|
|
575
|
+
z-index: var(--z-overlay);
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Add it to `js/index.js`:
|
|
580
|
+
```javascript
|
|
581
|
+
import './ln-{name}/ln-{name}.js';
|
|
582
|
+
import './ln-{name}/ln-{name}.scss';
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## Request Events — Details
|
|
588
|
+
|
|
589
|
+
> Architecture is defined in [ln-ashlar Project Architecture](#ln-ashlar-project-architecture-mandatory). This section covers technical details only.
|
|
590
|
+
|
|
591
|
+
### Implementation in Component
|
|
592
|
+
|
|
593
|
+
```javascript
|
|
594
|
+
// In _bindEvents — listen for request events on self
|
|
595
|
+
this.dom.addEventListener('ln-profile:request-create', function (e) {
|
|
596
|
+
self.create(e.detail.name); // calls the same prototype method
|
|
597
|
+
});
|
|
598
|
+
this.dom.addEventListener('ln-profile:request-remove', function (e) {
|
|
599
|
+
self.remove(e.detail.id);
|
|
600
|
+
});
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### Dispatching from Coordinator
|
|
604
|
+
|
|
605
|
+
```javascript
|
|
606
|
+
// Coordinator — dispatches request event (NOT direct API call)
|
|
607
|
+
nav.dispatchEvent(new CustomEvent('ln-profile:request-create', {
|
|
608
|
+
detail: { name: 'My Profile' }
|
|
609
|
+
}));
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### Naming
|
|
613
|
+
|
|
614
|
+
| Type | Format | Example | Bubbles |
|
|
615
|
+
|-----|--------|--------|---------|
|
|
616
|
+
| Request (incoming) | `ln-{name}:request-{action}` | `ln-profile:request-create` | `false` |
|
|
617
|
+
| Notification (outgoing) | `ln-{name}:{past-tense}` | `ln-profile:created` | `true` |
|
|
618
|
+
| Lifecycle before | `ln-{name}:before-{action}` | `ln-toggle:before-open` | `true`, cancelable |
|
|
619
|
+
| Lifecycle after | `ln-{name}:{action}` | `ln-toggle:open` | `true` |
|
|
620
|
+
|
|
621
|
+
### Commands vs Queries
|
|
622
|
+
|
|
623
|
+
| Type | Mechanism | Example |
|
|
624
|
+
|-----|-----------|--------|
|
|
625
|
+
| **Command** (changes state) | request event | `nav.dispatchEvent(new CustomEvent('ln-profile:request-remove', { detail: { id } }))` |
|
|
626
|
+
| **Query** (reads state) | direct access | `nav.lnProfile.currentId`, `sidebar.lnPlaylist.getTrack(idx)` |
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## Coordinator — Full Example
|
|
631
|
+
|
|
632
|
+
> The coordinator is a thin IIFE, project-specific. No own state, no own DOM. Just wiring.
|
|
633
|
+
|
|
634
|
+
```javascript
|
|
635
|
+
(function () {
|
|
636
|
+
'use strict';
|
|
637
|
+
if (window.myCoordinator !== undefined) return;
|
|
638
|
+
window.myCoordinator = true;
|
|
639
|
+
|
|
640
|
+
function _getNav() { return document.querySelector('[data-ln-profile]'); }
|
|
641
|
+
function _getSidebar() { return document.querySelector('[data-ln-playlist]'); }
|
|
642
|
+
|
|
643
|
+
function _init() {
|
|
644
|
+
// 1. UI trigger → request event
|
|
645
|
+
document.addEventListener('click', function (e) {
|
|
646
|
+
if (e.target.closest('[data-ln-action="delete-profile"]')) {
|
|
647
|
+
const nav = _getNav();
|
|
648
|
+
if (nav && nav.lnProfile) {
|
|
649
|
+
nav.dispatchEvent(new CustomEvent('ln-profile:request-remove', {
|
|
650
|
+
detail: { id: nav.lnProfile.currentId } // query is OK
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// 2. Form submit → request event
|
|
657
|
+
document.addEventListener('ln-form:submit', function (e) {
|
|
658
|
+
if (e.target.getAttribute('data-ln-form') !== 'new-profile') return;
|
|
659
|
+
const input = document.querySelector('[data-ln-field="new-profile-name"]');
|
|
660
|
+
const name = input ? input.value.trim() : '';
|
|
661
|
+
if (!name) return;
|
|
662
|
+
|
|
663
|
+
const nav = _getNav();
|
|
664
|
+
if (nav) {
|
|
665
|
+
nav.dispatchEvent(new CustomEvent('ln-profile:request-create', {
|
|
666
|
+
detail: { name: name }
|
|
667
|
+
}));
|
|
668
|
+
}
|
|
669
|
+
input.value = '';
|
|
670
|
+
document.getElementById('modal-new-profile').setAttribute('data-ln-modal', 'close');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// 3. Notification → UI feedback
|
|
674
|
+
document.addEventListener('ln-profile:created', function () {
|
|
675
|
+
window.dispatchEvent(new CustomEvent('ln-toast:enqueue', {
|
|
676
|
+
detail: { type: 'success', message: 'Profile created' }
|
|
677
|
+
}));
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// 4. Bridge: component A event → component B attribute
|
|
681
|
+
document.addEventListener('ln-profile:switched', function (e) {
|
|
682
|
+
const sidebar = _getSidebar();
|
|
683
|
+
if (sidebar) {
|
|
684
|
+
sidebar.setAttribute('data-ln-playlist-profile', e.detail.profileId);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (document.readyState === 'loading') {
|
|
690
|
+
document.addEventListener('DOMContentLoaded', _init);
|
|
691
|
+
} else {
|
|
692
|
+
_init();
|
|
693
|
+
}
|
|
694
|
+
})();
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
**The four jobs of a coordinator:**
|
|
698
|
+
1. **UI trigger → request event** — click/submit → dispatch request to component
|
|
699
|
+
2. **Form processing** — read input, validate, clear, close modal
|
|
700
|
+
3. **Notification → UI feedback** — toast, modal close, highlight
|
|
701
|
+
4. **Bridge** — event from component A → attribute/request on component B
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
705
|
+
## Template System — DOM Structure in HTML, Not in JS
|
|
706
|
+
|
|
707
|
+
**NEVER** build DOM structure with `createElement` chains in JS. Use the native HTML `<template>` element.
|
|
708
|
+
|
|
709
|
+
### Principle
|
|
710
|
+
|
|
711
|
+
DOM structure is an **HTML decision**, not a JS decision. The component only fills in the data.
|
|
712
|
+
|
|
713
|
+
### Zero Display Text in JS (mandatory)
|
|
714
|
+
|
|
715
|
+
JS components **NEVER** contain hardcoded strings intended for user display — no labels, messages, button text, status text, relative time words, or any translatable content. All display text lives in HTML templates where the server (Blade, backend) can translate it.
|
|
716
|
+
|
|
717
|
+
```
|
|
718
|
+
WRONG: el.textContent = 'No results found';
|
|
719
|
+
WRONG: el.textContent = '3 minutes ago';
|
|
720
|
+
WRONG: const label = count === 1 ? 'item' : 'items';
|
|
721
|
+
|
|
722
|
+
RIGHT: text comes from <template> → cloneTemplate → fill
|
|
723
|
+
RIGHT: text comes from hidden dict elements → buildDict (error messages, labels)
|
|
724
|
+
RIGHT: text comes from data-ln-* attribute set by server
|
|
725
|
+
RIGHT: text comes from Intl API (dates, numbers — browser-native i18n)
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
**Intl APIs are the exception** — `Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat` produce locale-aware output from the browser's own translations. These are acceptable because the browser handles i18n, not the component.
|
|
729
|
+
|
|
730
|
+
If a component needs display text that Intl can't provide, the text must come from HTML (template or data attribute) so the server can translate it.
|
|
731
|
+
|
|
732
|
+
```
|
|
733
|
+
HTML: <template> defines structures (inert, not rendered)
|
|
734
|
+
HTML: <ul hidden> with <li data-{component}-dict="key"> defines translatable strings
|
|
735
|
+
JS: clone → fill (structures) / buildDict (strings)
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### Dictionary pattern (i18n strings)
|
|
739
|
+
|
|
740
|
+
For error messages, labels, and other translatable strings that aren't part of a template structure, use `buildDict` from ln-core:
|
|
741
|
+
|
|
742
|
+
```html
|
|
743
|
+
<!-- Hidden dict elements — server translates, JS reads once at init -->
|
|
744
|
+
<ul hidden>
|
|
745
|
+
<li data-ln-upload-dict="remove">{{ __('Remove') }}</li>
|
|
746
|
+
<li data-ln-upload-dict="error">{{ __('Error') }}</li>
|
|
747
|
+
<li data-ln-upload-dict="invalid-type">{{ __('This file type is not allowed') }}</li>
|
|
748
|
+
</ul>
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
```js
|
|
752
|
+
import { buildDict } from '../ln-core';
|
|
753
|
+
|
|
754
|
+
// Init — reads all elements, builds object, removes from DOM
|
|
755
|
+
const dict = buildDict(container, 'data-ln-upload-dict');
|
|
756
|
+
|
|
757
|
+
// Usage — O(1) property access
|
|
758
|
+
dict['remove'] // 'Remove'
|
|
759
|
+
dict['invalid-type'] // 'This file type is not allowed'
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
**Convention:** `data-{component}-dict="key"` on `<li>` elements inside a single `<ul hidden>` in the component root. The `hidden` attribute on the `<ul>` hides the entire group. `buildDict` removes the elements after reading. Attribute name matches the component's naming pattern.
|
|
763
|
+
|
|
764
|
+
### HTML — define templates at the end of `<body>`, before `<script>` tags
|
|
765
|
+
|
|
766
|
+
```html
|
|
767
|
+
<!-- Templates -->
|
|
768
|
+
<template data-ln-template="track-item">
|
|
769
|
+
<li data-ln-track>
|
|
770
|
+
<span class="track-number" data-ln-drag-handle></span>
|
|
771
|
+
<article class="track-info">
|
|
772
|
+
<p class="track-name"></p>
|
|
773
|
+
<p class="track-artist"></p>
|
|
774
|
+
</article>
|
|
775
|
+
<nav class="track-actions">
|
|
776
|
+
<button type="button" data-ln-load-to="a">A</button>
|
|
777
|
+
<button type="button" data-ln-load-to="b">B</button>
|
|
778
|
+
</nav>
|
|
779
|
+
</li>
|
|
780
|
+
</template>
|
|
781
|
+
|
|
782
|
+
<template data-ln-template="profile-btn">
|
|
783
|
+
<button type="button" class="profile-btn" data-ln-profile-id></button>
|
|
784
|
+
</template>
|
|
785
|
+
|
|
786
|
+
<script src="..."></script>
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
### JS — `cloneTemplate` + `fill` / `fillTemplate` from ln-core
|
|
790
|
+
|
|
791
|
+
```javascript
|
|
792
|
+
import { cloneTemplate, fill, fillTemplate } from '../ln-core';
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
`cloneTemplate(name, componentTag)` caches the lookup and returns `tmpl.content.cloneNode(true)`.
|
|
796
|
+
|
|
797
|
+
### Usage — clone + fill (declarative)
|
|
798
|
+
|
|
799
|
+
Use `data-ln-field` for text, `data-ln-attr` for attributes:
|
|
800
|
+
|
|
801
|
+
```html
|
|
802
|
+
<template data-ln-template="track-item">
|
|
803
|
+
<li data-ln-track>
|
|
804
|
+
<span class="track-number" data-ln-field="number"></span>
|
|
805
|
+
<article class="track-info">
|
|
806
|
+
<p data-ln-field="title"></p>
|
|
807
|
+
<p data-ln-field="artist"></p>
|
|
808
|
+
</article>
|
|
809
|
+
</li>
|
|
810
|
+
</template>
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
```javascript
|
|
814
|
+
_component.prototype._buildTrackItem = function (track, idx) {
|
|
815
|
+
const frag = cloneTemplate('track-item', 'ln-playlist');
|
|
816
|
+
const li = frag.querySelector('[data-ln-track]');
|
|
817
|
+
fill(li, { number: idx + 1, title: track.title, artist: track.artist });
|
|
818
|
+
return li;
|
|
819
|
+
};
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
### Advanced `fill()` — attributes and state classes
|
|
823
|
+
|
|
824
|
+
Beyond text, `fill()` binds attributes (`data-ln-attr`) and toggles classes (`data-ln-class`). ln-upload exercises all three in a single call:
|
|
825
|
+
|
|
826
|
+
```html
|
|
827
|
+
<template data-ln-template="ln-upload-item">
|
|
828
|
+
<li class="ln-upload__item"
|
|
829
|
+
data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">
|
|
830
|
+
<svg class="ln-icon" aria-hidden="true">
|
|
831
|
+
<use data-ln-attr="href:iconHref" href="#ln-file"></use>
|
|
832
|
+
</svg>
|
|
833
|
+
<span class="ln-upload__name" data-ln-field="name"></span>
|
|
834
|
+
<span class="ln-upload__size" data-ln-field="sizeText"></span>
|
|
835
|
+
<button type="button" class="ln-upload__remove"
|
|
836
|
+
data-ln-upload-action="remove"
|
|
837
|
+
data-ln-attr="aria-label:removeLabel, title:removeLabel">
|
|
838
|
+
<svg class="ln-icon" aria-hidden="true"><use href="#ln-x"></use></svg>
|
|
839
|
+
</button>
|
|
840
|
+
<div class="ln-upload__progress">
|
|
841
|
+
<div class="ln-upload__progress-bar"></div>
|
|
842
|
+
</div>
|
|
843
|
+
</li>
|
|
844
|
+
</template>
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
```javascript
|
|
848
|
+
const fragment = cloneTemplateScoped(container, 'ln-upload-item', 'ln-upload');
|
|
849
|
+
const li = fragment.firstElementChild;
|
|
850
|
+
li.setAttribute('data-file-id', localId);
|
|
851
|
+
fill(li, {
|
|
852
|
+
name: file.name,
|
|
853
|
+
sizeText: '0%',
|
|
854
|
+
iconHref: '#' + iconId, // → <use href="#lnc-file-pdf">
|
|
855
|
+
removeLabel: dict.remove, // → aria-label + title on button
|
|
856
|
+
uploading: true, // → adds ln-upload__item--uploading
|
|
857
|
+
error: false,
|
|
858
|
+
deleting: false
|
|
859
|
+
});
|
|
860
|
+
list.appendChild(li);
|
|
861
|
+
|
|
862
|
+
// Later, on XHR progress — update only what changed:
|
|
863
|
+
fill(li, { sizeText: percent + '%' });
|
|
864
|
+
|
|
865
|
+
// On upload success — flip state classes:
|
|
866
|
+
fill(li, { sizeText: formatSize(size), uploading: false });
|
|
867
|
+
|
|
868
|
+
// On upload error:
|
|
869
|
+
fill(li, { sizeText: dict.error, uploading: false, error: true });
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
One `fill()` call handles all three binding types at once:
|
|
873
|
+
|
|
874
|
+
- **`data-ln-field="name"`** → `textContent` assignment
|
|
875
|
+
- **`data-ln-attr="href:iconHref"`** → one or more attributes per element (`href` on `<use>`, `aria-label` + `title` on the button)
|
|
876
|
+
- **`data-ln-class="ln-upload__item--uploading:uploading, ..."`** → conditional class toggles driven by booleans
|
|
877
|
+
|
|
878
|
+
State transitions are just successive `fill()` calls with different values — no imperative `classList.add/remove`, no manual `setAttribute`. The icon swap via `data-ln-attr="href:iconHref"` on a `<use>` element works because `ln-icons` runs a MutationObserver on `<use href>` changes and auto-fetches the new sprite — the component never touches the icon loader directly.
|
|
879
|
+
|
|
880
|
+
**Behavioral hooks live on attributes, not classes** — `data-ln-upload-action="remove"` is a JS query hook, not a fill slot. Delegated click handlers on `.ln-upload__list` use `e.target.closest('[data-ln-upload-action="remove"]')` to locate the button, which also matches when the click lands on a nested `<svg>` / `<use>`. Keeping behavioral hooks off CSS class names means a project can rename or restyle classes without breaking JS.
|
|
881
|
+
|
|
882
|
+
**Imperative escape hatches** — two things `fill()` can't express cleanly:
|
|
883
|
+
|
|
884
|
+
1. `removeBtn.disabled = !uploaded` — the `disabled` boolean attribute doesn't round-trip through `data-ln-attr` (setting vs. removing).
|
|
885
|
+
2. `progressBar.style.width = percent + '%'` — a continuous animation value, not discrete state. Same precedent as `ln-data-table` virtual-scroll spacer heights.
|
|
886
|
+
|
|
887
|
+
Both are one-line assignments right after the `fill()` call and are the only non-declarative touches in the render path.
|
|
888
|
+
|
|
889
|
+
### Scoped templates — per-instance override
|
|
890
|
+
|
|
891
|
+
`cloneTemplate(name)` looks up a single global `<template>` at document root. For customizable components, use `cloneTemplateScoped(root, name, componentTag)` instead — it checks inside `root` first, then falls back to the global lookup. This lets projects override the layout per instance without forking the component:
|
|
892
|
+
|
|
893
|
+
```html
|
|
894
|
+
<!-- Default instance — uses the global or auto-injected template -->
|
|
895
|
+
<div data-ln-upload="/files/upload"></div>
|
|
896
|
+
|
|
897
|
+
<!-- Customized instance — scoped <template> inside the container -->
|
|
898
|
+
<div data-ln-upload="/files/upload">
|
|
899
|
+
<template data-ln-template="ln-upload-item">
|
|
900
|
+
<li class="ln-upload__item"
|
|
901
|
+
data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">
|
|
902
|
+
<svg class="ln-icon ln-icon--lg" aria-hidden="true">
|
|
903
|
+
<use data-ln-attr="href:iconHref" href="#ln-file"></use>
|
|
904
|
+
</svg>
|
|
905
|
+
<article>
|
|
906
|
+
<p class="ln-upload__name" data-ln-field="name"></p>
|
|
907
|
+
<small class="ln-upload__size" data-ln-field="sizeText"></small>
|
|
908
|
+
</article>
|
|
909
|
+
<button type="button" class="ln-upload__remove"
|
|
910
|
+
data-ln-upload-action="remove"
|
|
911
|
+
data-ln-attr="aria-label:removeLabel, title:removeLabel">
|
|
912
|
+
<svg class="ln-icon" aria-hidden="true"><use href="#ln-x"></use></svg>
|
|
913
|
+
</button>
|
|
914
|
+
<div class="ln-upload__progress"><div class="ln-upload__progress-bar"></div></div>
|
|
915
|
+
</li>
|
|
916
|
+
</template>
|
|
917
|
+
<div class="ln-upload__zone"><p>Drop files</p></div>
|
|
918
|
+
<ul class="ln-upload__list"></ul>
|
|
919
|
+
</div>
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
Lookup order for `cloneTemplateScoped`:
|
|
923
|
+
|
|
924
|
+
1. **Scoped** — `<template>` inside the container element (per-instance)
|
|
925
|
+
2. **Global** — `<template>` anywhere at document root (shared default)
|
|
926
|
+
3. **Auto-injected default** *(optional)* — if the component ships a built-in default, inject it into `<body>` on first init when neither scoped nor global is present. This keeps zero-config usage working without forcing every project to copy-paste markup.
|
|
927
|
+
|
|
928
|
+
ln-upload implements all three tiers. The auto-inject runs once per init via a helper:
|
|
929
|
+
|
|
930
|
+
```javascript
|
|
931
|
+
const DEFAULT_ITEM_TEMPLATE_HTML =
|
|
932
|
+
'<template data-ln-template="ln-upload-item">' +
|
|
933
|
+
/* ...default <li> markup... */
|
|
934
|
+
'</template>';
|
|
935
|
+
|
|
936
|
+
function _ensureDefaultItemTemplate() {
|
|
937
|
+
if (document.querySelector('[data-ln-template="ln-upload-item"]')) return;
|
|
938
|
+
const wrapper = document.createElement('div');
|
|
939
|
+
wrapper.innerHTML = DEFAULT_ITEM_TEMPLATE_HTML;
|
|
940
|
+
document.body.appendChild(wrapper.firstElementChild);
|
|
941
|
+
}
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
The component calls `_ensureDefaultItemTemplate()` at the top of `_initUpload()`, before any `cloneTemplateScoped` call. `cloneTemplate`'s internal cache only stores truthy lookups, so inserting a template after a previous `null` lookup is safe — the next call picks it up.
|
|
945
|
+
|
|
946
|
+
**When to use which:**
|
|
947
|
+
|
|
948
|
+
| Component shape | Use |
|
|
949
|
+
|---|---|
|
|
950
|
+
| Single template reused across all instances, never customized | `cloneTemplate(name, tag)` |
|
|
951
|
+
| Instance layout may vary per project or per page | `cloneTemplateScoped(container, name, tag)` |
|
|
952
|
+
| Zero-config out of the box + optional per-instance override | scoped lookup + auto-injected default |
|
|
953
|
+
|
|
954
|
+
### Components using templates
|
|
955
|
+
|
|
956
|
+
| Component | Template(s) | Lookup | Notes |
|
|
957
|
+
|---|---|---|---|
|
|
958
|
+
| **ln-upload** | `ln-upload-item` | `cloneTemplateScoped` | Scoped → global → auto-injected default on first init |
|
|
959
|
+
| **ln-data-table** | `{name}-row`, `{name}-empty`, `{name}-empty-filtered`, `column-filter` (also falls back to `{name}-column-filter`) | `cloneTemplateScoped` | Scoped-first per table instance |
|
|
960
|
+
| **ln-translations** | `ln-translations-menu-item`, `ln-translations-badge` | `cloneTemplate` | Single global template, no per-instance override |
|
|
961
|
+
|
|
962
|
+
### Rules
|
|
963
|
+
|
|
964
|
+
1. **`<template data-ln-template="name">`** — naming convention
|
|
965
|
+
2. **Placement:** at the end of `<body>`, before `<script>` tags, in a `TEMPLATES` comment block
|
|
966
|
+
3. **`content.cloneNode(true)`** returns a DocumentFragment — query the root element with `querySelector`
|
|
967
|
+
4. **JS only fills:** `textContent`, `setAttribute`, `classList` — does NOT create structure
|
|
968
|
+
5. **Conditions:** small conditional elements (1-2 spans for indicators) are OK as `createElement`
|
|
969
|
+
6. **One template, one function:** if the same structure is created in 2+ places, it must be a `<template>` + shared function
|
|
970
|
+
|
|
971
|
+
### Why
|
|
972
|
+
|
|
973
|
+
| createElement chains | `<template>` |
|
|
974
|
+
|---------------------|--------------|
|
|
975
|
+
| 60+ lines of JS for one `<li>` | 10 lines HTML + 8 lines JS |
|
|
976
|
+
| Structure hidden in JS | Structure visible in HTML |
|
|
977
|
+
| Duplication between methods | One definition, one function |
|
|
978
|
+
| Hard for designers | HTML — easy to change |
|
|
979
|
+
|
|
980
|
+
---
|
|
981
|
+
|
|
982
|
+
## Components (reference)
|
|
983
|
+
|
|
984
|
+
| Component | Pattern | Data Attr | Description |
|
|
985
|
+
|-----------|---------|-----------|------|
|
|
986
|
+
| ln-core | Shared module | — | cloneTemplate, cloneTemplateScoped, dispatch, dispatchCancelable, fill, fillTemplate, renderList, buildDict, guardBody, findElements, reactiveState, deepReactive, createBatcher |
|
|
987
|
+
| ln-toggle | Instance | `data-ln-toggle` | Generic toggle (sidebar, collapse) |
|
|
988
|
+
| ln-accordion | Instance | `data-ln-accordion` | Wrapper — only one toggle open at a time |
|
|
989
|
+
| ln-tabs | Instance | `data-ln-tabs` | Hash-aware tab navigation |
|
|
990
|
+
| ln-nav | Instance | `data-ln-nav` | Active link highlighter |
|
|
991
|
+
| ln-modal | Instance | `data-ln-modal` | Modal dialog |
|
|
992
|
+
| ln-toast | Functional | `data-ln-toast` | Toast notifications |
|
|
993
|
+
| ln-upload | Functional | `data-ln-upload` | File upload |
|
|
994
|
+
| ln-ajax | Functional | `data-ln-ajax` | AJAX navigation |
|
|
995
|
+
| ln-progress | Functional | `data-ln-progress` | Progress bar |
|
|
996
|
+
| ln-circular-progress | Instance | `data-ln-circular-progress` | Circular (ring) progress indicator |
|
|
997
|
+
| ln-search | Instance | `data-ln-search` | Generic search (textContent filter) |
|
|
998
|
+
| ln-filter | Instance | `data-ln-filter` | Generic filter (data attribute filter) |
|
|
999
|
+
| ln-table | Instance | `data-ln-table` | Data table (search, filter, sort, virtual scroll) |
|
|
1000
|
+
| ln-table-sort | Instance | `data-ln-sort` | Sort header handler (companion to ln-table) |
|
|
1001
|
+
| ln-sortable | Instance | `data-ln-sortable` | Drag & drop reorder |
|
|
1002
|
+
| ln-dropdown | Instance | `data-ln-dropdown` | Positioned dropdown menu (wraps ln-toggle) |
|
|
1003
|
+
| ln-popover | Instance | `data-ln-popover` | Rich popover with viewport-aware positioning and ESC-stack management |
|
|
1004
|
+
| ln-link | Instance | `data-ln-link` | Clickable rows/containers |
|
|
1005
|
+
| ln-confirm | Instance | `data-ln-confirm` | Two-click confirmation for destructive actions |
|
|
1006
|
+
| ln-autosave | Instance | `data-ln-autosave` | Auto-save form to localStorage on blur/change |
|
|
1007
|
+
| ln-autoresize | Instance | `data-ln-autoresize` | Auto-resize textarea height |
|
|
1008
|
+
| ln-date | Instance | `data-ln-date` | Locale-aware date formatter with native picker and typing support |
|
|
1009
|
+
| ln-number | Instance | `data-ln-number` | Locale-aware number formatter (decimal, currency, percent) |
|
|
1010
|
+
| ln-time | Instance | `data-ln-time` | Relative and absolute time formatter, auto-updates on shared 60s tick |
|
|
1011
|
+
| ln-translations | Instance | `data-ln-translations` | Multi-language form field management |
|
|
1012
|
+
| ln-external-links | Utility | — | External links handler |
|
|
1013
|
+
| ln-http | Global service | — | Event-driven JSON fetch with abort support |
|
|
1014
|
+
|
|
1015
|
+
---
|
|
1016
|
+
|
|
1017
|
+
## Component Relationships
|
|
1018
|
+
|
|
1019
|
+
Components do not import each other. Every relationship below is
|
|
1020
|
+
through one of two channels:
|
|
1021
|
+
|
|
1022
|
+
- **Events** — one component dispatches `ln-{a}:{action}`, another
|
|
1023
|
+
listens for it (`ln-accordion` listens to `ln-toggle:open`).
|
|
1024
|
+
- **Shared DOM state** — one component sets a `data-ln-*` attribute,
|
|
1025
|
+
another's MutationObserver reacts (ln-accordion setting
|
|
1026
|
+
`data-ln-toggle="close"` on sibling toggles).
|
|
1027
|
+
|
|
1028
|
+
No component ever does `import '../ln-toggle'` or reaches into
|
|
1029
|
+
`otherEl.lnToggle` to mutate its state. If you catch yourself reaching
|
|
1030
|
+
for an import or for a sibling's instance, re-read the Mediator section
|
|
1031
|
+
above — write the attribute instead.
|
|
1032
|
+
|
|
1033
|
+
### ln-toggle — the state primitive
|
|
1034
|
+
|
|
1035
|
+
`ln-toggle` is the only open/close state primitive. Components that
|
|
1036
|
+
need the "coordinator collapses siblings" pattern build on it:
|
|
1037
|
+
|
|
1038
|
+
```
|
|
1039
|
+
ln-toggle (state: data-ln-toggle attribute, ln-toggle:open/close events)
|
|
1040
|
+
├── ln-accordion coordinator — listens to ln-toggle:open on children,
|
|
1041
|
+
│ sets data-ln-toggle="close" on siblings
|
|
1042
|
+
└── ln-dropdown wraps a child [data-ln-toggle] and adds viewport-aware
|
|
1043
|
+
positioning on ln-toggle:open
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
`ln-modal`, `ln-popover`, and `ln-tabs` are **not** built on `ln-toggle`.
|
|
1047
|
+
They each own an independent open/close state machine backed by their
|
|
1048
|
+
own attribute (`data-ln-modal`, `data-ln-popover`, `data-ln-tabs-active`).
|
|
1049
|
+
The "attribute is the single source of truth" pattern is shared; the
|
|
1050
|
+
specific attribute is not.
|
|
1051
|
+
|
|
1052
|
+
### Form family — ln-form coordinates siblings
|
|
1053
|
+
|
|
1054
|
+
```
|
|
1055
|
+
ln-form (catches submit, reads ln-validate events)
|
|
1056
|
+
├── ln-validate dispatches ln-validate:valid / ln-validate:invalid,
|
|
1057
|
+
│ which ln-form listens for
|
|
1058
|
+
└── ln-autosave stores form state in localStorage; writes on blur/change
|
|
1059
|
+
independently of ln-form
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
ln-form never calls `lnValidate.isValid()` or reads TomSelect state
|
|
1063
|
+
directly. It listens to the validation events and lets each sibling
|
|
1064
|
+
manage its own internals.
|
|
1065
|
+
|
|
1066
|
+
### Data family — ln-data-table ↔ ln-store through the coordinator
|
|
1067
|
+
|
|
1068
|
+
```
|
|
1069
|
+
ln-data-table ln-store
|
|
1070
|
+
│ │
|
|
1071
|
+
│ ln-data-table:request-data ─────────▶│
|
|
1072
|
+
│ (sort, filters, search) │ (reads IndexedDB)
|
|
1073
|
+
│ │
|
|
1074
|
+
│◀─────── ln-data-table:set-data │
|
|
1075
|
+
│ (rows + totals) │
|
|
1076
|
+
│ │
|
|
1077
|
+
│◀─────── ln-store:synced │
|
|
1078
|
+
│ (coordinator re-queries) │
|
|
1079
|
+
▲
|
|
1080
|
+
│
|
|
1081
|
+
project coordinator
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
`ln-data-table` never imports or queries `ln-store`. It emits a request
|
|
1085
|
+
event; the project coordinator catches it, calls `storeEl.lnStore.getAll(...)`,
|
|
1086
|
+
and dispatches `ln-data-table:set-data` back. See
|
|
1087
|
+
[docs/js/component-guide.md](../docs/js/component-guide.md#data-flow-with-ln-store)
|
|
1088
|
+
for the full handshake.
|
|
1089
|
+
|
|
1090
|
+
### Rule
|
|
1091
|
+
|
|
1092
|
+
> **Relationships are through events, never imports.**
|
|
1093
|
+
|
|
1094
|
+
If two components need to know about each other's state, introduce a
|
|
1095
|
+
coordinator between them. Components stay storage-agnostic and
|
|
1096
|
+
sibling-agnostic; the coordinator is the only piece that knows the
|
|
1097
|
+
whole stack.
|
|
1098
|
+
|
|
1099
|
+
---
|
|
1100
|
+
|
|
1101
|
+
> For the latest component skeleton and checklist →
|
|
1102
|
+
> see [docs/js/component-guide.md](../docs/js/component-guide.md)
|