@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.
Files changed (232) hide show
  1. package/README.md +177 -0
  2. package/js/COMPONENTS.md +1102 -0
  3. package/js/index.js +41 -0
  4. package/js/ln-accordion/README.md +137 -0
  5. package/js/ln-accordion/ln-accordion.js +1 -0
  6. package/js/ln-accordion/src/ln-accordion.js +41 -0
  7. package/js/ln-ajax/README.md +91 -0
  8. package/js/ln-ajax/ln-ajax.js +1 -0
  9. package/js/ln-ajax/src/ln-ajax.js +277 -0
  10. package/js/ln-api-connector/README.md +150 -0
  11. package/js/ln-api-connector/ln-api-connector.js +1 -0
  12. package/js/ln-api-connector/src/ln-api-connector.js +265 -0
  13. package/js/ln-autoresize/README.md +80 -0
  14. package/js/ln-autoresize/ln-autoresize.js +1 -0
  15. package/js/ln-autoresize/src/ln-autoresize.js +47 -0
  16. package/js/ln-autosave/README.md +92 -0
  17. package/js/ln-autosave/ln-autosave.js +1 -0
  18. package/js/ln-autosave/src/ln-autosave.js +147 -0
  19. package/js/ln-circular-progress/README.md +161 -0
  20. package/js/ln-circular-progress/ln-circular-progress.js +1 -0
  21. package/js/ln-circular-progress/src/ln-circular-progress.js +133 -0
  22. package/js/ln-confirm/README.md +86 -0
  23. package/js/ln-confirm/_ln-confirm.scss +13 -0
  24. package/js/ln-confirm/ln-confirm.js +1 -0
  25. package/js/ln-confirm/src/ln-confirm.js +131 -0
  26. package/js/ln-core/crypto.js +83 -0
  27. package/js/ln-core/helpers.js +411 -0
  28. package/js/ln-core/index.js +5 -0
  29. package/js/ln-core/persist.js +71 -0
  30. package/js/ln-core/positioning.js +207 -0
  31. package/js/ln-core/reactive.js +74 -0
  32. package/js/ln-couchdb-connector/README.md +156 -0
  33. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -0
  34. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +348 -0
  35. package/js/ln-data-coordinator/README.md +165 -0
  36. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -0
  37. package/js/ln-data-coordinator/src/ln-data-coordinator.js +249 -0
  38. package/js/ln-data-store/README.md +94 -0
  39. package/js/ln-data-store/ln-data-store.js +1 -0
  40. package/js/ln-data-store/src/ln-data-store.js +699 -0
  41. package/js/ln-data-table/README.md +110 -0
  42. package/js/ln-data-table/ln-data-table.js +1 -0
  43. package/js/ln-data-table/ln-data-table.scss +10 -0
  44. package/js/ln-data-table/src/ln-data-table.js +1103 -0
  45. package/js/ln-date/README.md +151 -0
  46. package/js/ln-date/ln-date.js +1 -0
  47. package/js/ln-date/src/ln-date.js +442 -0
  48. package/js/ln-dropdown/README.md +117 -0
  49. package/js/ln-dropdown/ln-dropdown.js +1 -0
  50. package/js/ln-dropdown/ln-dropdown.scss +15 -0
  51. package/js/ln-dropdown/src/ln-dropdown.js +174 -0
  52. package/js/ln-external-links/README.md +341 -0
  53. package/js/ln-external-links/ln-external-links.js +1 -0
  54. package/js/ln-external-links/src/ln-external-links.js +116 -0
  55. package/js/ln-filter/README.md +99 -0
  56. package/js/ln-filter/ln-filter.js +1 -0
  57. package/js/ln-filter/ln-filter.scss +7 -0
  58. package/js/ln-filter/src/ln-filter.js +404 -0
  59. package/js/ln-form/README.md +101 -0
  60. package/js/ln-form/ln-form.js +1 -0
  61. package/js/ln-form/src/ln-form.js +199 -0
  62. package/js/ln-http/README.md +89 -0
  63. package/js/ln-http/ln-http.js +1 -0
  64. package/js/ln-http/src/ln-http.js +219 -0
  65. package/js/ln-icons/README.md +88 -0
  66. package/js/ln-icons/ln-icons.js +1 -0
  67. package/js/ln-icons/src/ln-icons.js +169 -0
  68. package/js/ln-link/README.md +303 -0
  69. package/js/ln-link/ln-link.js +1 -0
  70. package/js/ln-link/src/ln-link.js +196 -0
  71. package/js/ln-modal/README.md +154 -0
  72. package/js/ln-modal/ln-modal.js +1 -0
  73. package/js/ln-modal/ln-modal.scss +11 -0
  74. package/js/ln-modal/src/ln-modal.js +201 -0
  75. package/js/ln-nav/README.md +70 -0
  76. package/js/ln-nav/ln-nav.js +1 -0
  77. package/js/ln-nav/src/ln-nav.js +177 -0
  78. package/js/ln-number/README.md +122 -0
  79. package/js/ln-number/ln-number.js +1 -0
  80. package/js/ln-number/src/ln-number.js +302 -0
  81. package/js/ln-popover/README.md +127 -0
  82. package/js/ln-popover/ln-popover.js +1 -0
  83. package/js/ln-popover/src/ln-popover.js +288 -0
  84. package/js/ln-progress/README.md +442 -0
  85. package/js/ln-progress/ln-progress.js +1 -0
  86. package/js/ln-progress/src/ln-progress.js +150 -0
  87. package/js/ln-search/README.md +83 -0
  88. package/js/ln-search/ln-search.js +1 -0
  89. package/js/ln-search/ln-search.scss +7 -0
  90. package/js/ln-search/src/ln-search.js +114 -0
  91. package/js/ln-sortable/README.md +95 -0
  92. package/js/ln-sortable/ln-sortable.js +1 -0
  93. package/js/ln-sortable/src/ln-sortable.js +203 -0
  94. package/js/ln-table/README.md +101 -0
  95. package/js/ln-table/ln-table-sort.js +1 -0
  96. package/js/ln-table/ln-table.js +1 -0
  97. package/js/ln-table/ln-table.scss +11 -0
  98. package/js/ln-table/src/ln-table-sort.js +168 -0
  99. package/js/ln-table/src/ln-table.js +473 -0
  100. package/js/ln-tabs/README.md +137 -0
  101. package/js/ln-tabs/ln-tabs.js +1 -0
  102. package/js/ln-tabs/src/ln-tabs.js +171 -0
  103. package/js/ln-time/README.md +81 -0
  104. package/js/ln-time/ln-time.js +1 -0
  105. package/js/ln-time/src/ln-time.js +192 -0
  106. package/js/ln-toast/README.md +122 -0
  107. package/js/ln-toast/ln-toast.js +15 -0
  108. package/js/ln-toast/src/ln-toast.js +210 -0
  109. package/js/ln-toast/template.html +14 -0
  110. package/js/ln-toggle/README.md +137 -0
  111. package/js/ln-toggle/ln-toggle.js +1 -0
  112. package/js/ln-toggle/src/ln-toggle.js +139 -0
  113. package/js/ln-tooltip/README.md +58 -0
  114. package/js/ln-tooltip/ln-tooltip.js +1 -0
  115. package/js/ln-tooltip/ln-tooltip.scss +9 -0
  116. package/js/ln-tooltip/src/ln-tooltip.js +169 -0
  117. package/js/ln-translations/README.md +96 -0
  118. package/js/ln-translations/ln-translations.js +1 -0
  119. package/js/ln-translations/src/ln-translations.js +275 -0
  120. package/js/ln-upload/README.md +180 -0
  121. package/js/ln-upload/ln-upload.js +1 -0
  122. package/js/ln-upload/ln-upload.scss +20 -0
  123. package/js/ln-upload/src/ln-upload.js +407 -0
  124. package/js/ln-validate/README.md +108 -0
  125. package/js/ln-validate/ln-validate.js +1 -0
  126. package/js/ln-validate/src/ln-validate.js +160 -0
  127. package/package.json +55 -0
  128. package/scss/base/_global.scss +83 -0
  129. package/scss/base/_reset.scss +17 -0
  130. package/scss/base/_typography.scss +125 -0
  131. package/scss/components/_accordion.scss +34 -0
  132. package/scss/components/_ajax.scss +15 -0
  133. package/scss/components/_alert.scss +5 -0
  134. package/scss/components/_app-shell.scss +15 -0
  135. package/scss/components/_avatar.scss +6 -0
  136. package/scss/components/_breadcrumbs.scss +33 -0
  137. package/scss/components/_button.scss +20 -0
  138. package/scss/components/_card.scss +10 -0
  139. package/scss/components/_chip.scss +5 -0
  140. package/scss/components/_circular-progress.scss +29 -0
  141. package/scss/components/_confirm.scss +5 -0
  142. package/scss/components/_data-table.scss +83 -0
  143. package/scss/components/_dropdown.scss +25 -0
  144. package/scss/components/_empty-state.scss +22 -0
  145. package/scss/components/_form.scss +100 -0
  146. package/scss/components/_layout.scss +8 -0
  147. package/scss/components/_link.scss +11 -0
  148. package/scss/components/_ln-table.scss +60 -0
  149. package/scss/components/_loader.scss +6 -0
  150. package/scss/components/_modal.scss +20 -0
  151. package/scss/components/_nav.scss +9 -0
  152. package/scss/components/_page-header.scss +10 -0
  153. package/scss/components/_popover.scss +10 -0
  154. package/scss/components/_progress.scss +17 -0
  155. package/scss/components/_prose.scss +5 -0
  156. package/scss/components/_scrollbar.scss +32 -0
  157. package/scss/components/_sections.scss +12 -0
  158. package/scss/components/_sidebar.scss +5 -0
  159. package/scss/components/_stat-card.scss +5 -0
  160. package/scss/components/_status-badge.scss +4 -0
  161. package/scss/components/_stepper.scss +5 -0
  162. package/scss/components/_table.scss +19 -0
  163. package/scss/components/_tabs.scss +21 -0
  164. package/scss/components/_timeline.scss +14 -0
  165. package/scss/components/_toast.scss +41 -0
  166. package/scss/components/_toggle.scss +81 -0
  167. package/scss/components/_tooltip.scss +18 -0
  168. package/scss/components/_translations.scss +111 -0
  169. package/scss/components/_upload.scss +51 -0
  170. package/scss/config/_breakpoints.scss +72 -0
  171. package/scss/config/_density.scss +117 -0
  172. package/scss/config/_icons.scss +37 -0
  173. package/scss/config/_mixins.scss +13 -0
  174. package/scss/config/_theme.scss +216 -0
  175. package/scss/config/_tokens.scss +419 -0
  176. package/scss/config/mixins/_accordion.scss +52 -0
  177. package/scss/config/mixins/_ajax.scss +39 -0
  178. package/scss/config/mixins/_alert.scss +82 -0
  179. package/scss/config/mixins/_app-shell.scss +312 -0
  180. package/scss/config/mixins/_avatar.scss +109 -0
  181. package/scss/config/mixins/_borders.scss +36 -0
  182. package/scss/config/mixins/_breadcrumbs.scss +72 -0
  183. package/scss/config/mixins/_breakpoints.scss +62 -0
  184. package/scss/config/mixins/_btn.scss +179 -0
  185. package/scss/config/mixins/_card.scss +338 -0
  186. package/scss/config/mixins/_chip.scss +66 -0
  187. package/scss/config/mixins/_circular-progress.scss +71 -0
  188. package/scss/config/mixins/_collapsible.scss +24 -0
  189. package/scss/config/mixins/_colors.scss +46 -0
  190. package/scss/config/mixins/_confirm.scss +31 -0
  191. package/scss/config/mixins/_data-table.scss +346 -0
  192. package/scss/config/mixins/_display.scss +32 -0
  193. package/scss/config/mixins/_dropdown.scss +143 -0
  194. package/scss/config/mixins/_empty-state.scss +30 -0
  195. package/scss/config/mixins/_focus.scss +55 -0
  196. package/scss/config/mixins/_footer.scss +42 -0
  197. package/scss/config/mixins/_form.scss +601 -0
  198. package/scss/config/mixins/_index.scss +58 -0
  199. package/scss/config/mixins/_interaction.scss +15 -0
  200. package/scss/config/mixins/_kbd.scss +22 -0
  201. package/scss/config/mixins/_layout.scss +117 -0
  202. package/scss/config/mixins/_link.scss +55 -0
  203. package/scss/config/mixins/_ln-table.scss +420 -0
  204. package/scss/config/mixins/_loader.scss +26 -0
  205. package/scss/config/mixins/_modal.scss +66 -0
  206. package/scss/config/mixins/_motion.scss +19 -0
  207. package/scss/config/mixins/_nav.scss +273 -0
  208. package/scss/config/mixins/_page-header.scss +69 -0
  209. package/scss/config/mixins/_popover.scss +25 -0
  210. package/scss/config/mixins/_position.scss +32 -0
  211. package/scss/config/mixins/_progress.scss +56 -0
  212. package/scss/config/mixins/_prose.scss +127 -0
  213. package/scss/config/mixins/_shadows.scss +8 -0
  214. package/scss/config/mixins/_sidebar.scss +95 -0
  215. package/scss/config/mixins/_sizing.scss +6 -0
  216. package/scss/config/mixins/_spacing.scss +19 -0
  217. package/scss/config/mixins/_stat-card.scss +68 -0
  218. package/scss/config/mixins/_status-badge.scss +83 -0
  219. package/scss/config/mixins/_stepper.scss +78 -0
  220. package/scss/config/mixins/_table.scss +215 -0
  221. package/scss/config/mixins/_tabs.scss +64 -0
  222. package/scss/config/mixins/_timeline.scss +69 -0
  223. package/scss/config/mixins/_toast.scss +148 -0
  224. package/scss/config/mixins/_tooltip.scss +111 -0
  225. package/scss/config/mixins/_transitions.scss +10 -0
  226. package/scss/config/mixins/_translations.scss +124 -0
  227. package/scss/config/mixins/_typography.scss +57 -0
  228. package/scss/config/mixins/_upload.scss +168 -0
  229. package/scss/ln-ashlar.scss +62 -0
  230. package/scss/tabler-icons.txt +5039 -0
  231. package/scss/utilities/_animations.scss +83 -0
  232. package/scss/utilities/_utilities.scss +49 -0
@@ -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)