@livenetworks/ashlar 1.5.1 → 1.6.1

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 (217) hide show
  1. package/README.md +189 -177
  2. package/js/COMPONENTS.md +55 -49
  3. package/js/index.js +22 -5
  4. package/js/ln-accordion/README.md +186 -137
  5. package/js/ln-accordion/ln-accordion.js +1 -1
  6. package/js/ln-ajax/README.md +73 -4
  7. package/js/ln-ajax/ln-ajax.js +1 -1
  8. package/js/ln-ajax/src/ln-ajax.js +13 -13
  9. package/js/ln-api-connector/README.md +261 -150
  10. package/js/ln-api-connector/ln-api-connector.js +1 -1
  11. package/js/ln-api-connector/src/ln-api-connector.js +368 -265
  12. package/js/ln-api-queue/README.md +219 -0
  13. package/js/ln-api-queue/ln-api-queue.js +1 -0
  14. package/js/ln-api-queue/src/ln-api-queue.js +320 -0
  15. package/js/ln-api-queue/src/queue-storage.js +455 -0
  16. package/js/ln-autoresize/README.md +47 -0
  17. package/js/ln-autoresize/ln-autoresize.js +1 -1
  18. package/js/ln-autosave/README.md +33 -1
  19. package/js/ln-autosave/ln-autosave.js +1 -1
  20. package/js/ln-autosave/src/ln-autosave.js +4 -4
  21. package/js/ln-circular-progress/README.md +27 -13
  22. package/js/ln-circular-progress/ln-circular-progress.js +1 -1
  23. package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
  24. package/js/ln-confirm/README.md +165 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +82 -42
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +92 -2
  66. package/js/ln-filter/ln-filter-dev.scss +8 -0
  67. package/js/ln-filter/ln-filter.js +1 -1
  68. package/js/ln-filter/src/ln-filter.js +22 -7
  69. package/js/ln-form/README.md +212 -49
  70. package/js/ln-form/ln-form.js +1 -1
  71. package/js/ln-form/src/ln-form.js +99 -199
  72. package/js/ln-http/README.md +125 -89
  73. package/js/ln-http/ln-http.js +1 -1
  74. package/js/ln-http/src/ln-http.js +32 -13
  75. package/js/ln-icon/README.md +130 -0
  76. package/js/ln-icon/ln-icon.js +1 -0
  77. package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
  78. package/js/ln-link/README.md +47 -9
  79. package/js/ln-link/ln-link.js +1 -1
  80. package/js/ln-link/src/ln-link.js +22 -6
  81. package/js/ln-list/README.md +129 -0
  82. package/js/ln-list/ln-list.js +1 -0
  83. package/js/ln-list/ln-list.scss +28 -0
  84. package/js/ln-list/src/ln-list.js +1216 -0
  85. package/js/ln-modal/README.md +185 -154
  86. package/js/ln-modal/ln-modal-dev.scss +13 -0
  87. package/js/ln-modal/ln-modal.js +1 -1
  88. package/js/ln-modal/ln-modal.scss +28 -4
  89. package/js/ln-modal/src/ln-modal.js +137 -201
  90. package/js/ln-modal-coordinator/README.md +99 -0
  91. package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
  92. package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
  93. package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
  94. package/js/ln-nav/README.md +46 -3
  95. package/js/ln-nav/ln-nav.js +1 -1
  96. package/js/ln-nav/src/ln-nav.js +92 -127
  97. package/js/ln-number/README.md +28 -1
  98. package/js/ln-number/ln-number-dev.scss +13 -0
  99. package/js/ln-number/ln-number.js +1 -1
  100. package/js/ln-number/src/ln-number.js +443 -350
  101. package/js/ln-options/README.md +66 -0
  102. package/js/ln-options/ln-options.js +1 -0
  103. package/js/ln-options/src/ln-options.js +74 -0
  104. package/js/ln-popover/README.md +40 -5
  105. package/js/ln-popover/ln-popover.js +1 -1
  106. package/js/ln-popover/src/ln-popover.js +38 -11
  107. package/js/ln-progress/README.md +413 -442
  108. package/js/ln-progress/ln-progress.js +1 -1
  109. package/js/ln-progress/src/ln-progress.js +98 -150
  110. package/js/ln-router/README.md +432 -0
  111. package/js/ln-router/ln-router.js +1 -0
  112. package/js/ln-router/src/ln-router.js +539 -0
  113. package/js/ln-search/README.md +158 -83
  114. package/js/ln-search/ln-search-dev.scss +8 -0
  115. package/js/ln-search/ln-search.js +1 -1
  116. package/js/ln-search/src/ln-search.js +12 -9
  117. package/js/ln-slug/README.md +110 -0
  118. package/js/ln-slug/ln-slug.js +1 -0
  119. package/js/ln-slug/src/ln-slug.js +74 -0
  120. package/js/ln-sort/README.md +170 -0
  121. package/js/ln-sort/ln-sort-dev.scss +8 -0
  122. package/js/ln-sort/ln-sort.js +1 -0
  123. package/js/ln-sort/src/ln-sort.js +178 -0
  124. package/js/ln-sortable/README.md +133 -95
  125. package/js/ln-sortable/ln-sortable.js +1 -1
  126. package/js/ln-stat/README.md +55 -0
  127. package/js/ln-stat/ln-stat.js +1 -0
  128. package/js/ln-stat/src/ln-stat.js +55 -0
  129. package/js/ln-table/README.md +285 -101
  130. package/js/ln-table/ln-table-dev.scss +17 -0
  131. package/js/ln-table/ln-table.js +1 -1
  132. package/js/ln-table/ln-table.scss +1 -1
  133. package/js/ln-table/src/ln-table.js +1493 -473
  134. package/js/ln-table-coordinator/README.md +73 -0
  135. package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
  136. package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
  137. package/js/ln-tabs/README.md +221 -137
  138. package/js/ln-tabs/ln-tabs-dev.scss +18 -0
  139. package/js/ln-tabs/ln-tabs.js +1 -1
  140. package/js/ln-tabs/src/ln-tabs.js +41 -24
  141. package/js/ln-time/README.md +14 -0
  142. package/js/ln-time/ln-time.js +1 -1
  143. package/js/ln-time/src/ln-time.js +223 -192
  144. package/js/ln-toast/README.md +178 -122
  145. package/js/ln-toast/ln-toast.js +1 -15
  146. package/js/ln-toast/src/ln-toast.js +232 -210
  147. package/js/ln-toast/template.html +16 -12
  148. package/js/ln-toggle/README.md +61 -2
  149. package/js/ln-toggle/ln-toggle-dev.scss +13 -0
  150. package/js/ln-toggle/ln-toggle.js +1 -1
  151. package/js/ln-toggle/src/ln-toggle.js +56 -43
  152. package/js/ln-tooltip/README.md +95 -58
  153. package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
  154. package/js/ln-tooltip/ln-tooltip.js +1 -1
  155. package/js/ln-tooltip/src/ln-tooltip.js +45 -6
  156. package/js/ln-translations/README.md +118 -96
  157. package/js/ln-translations/ln-translations.js +1 -1
  158. package/js/ln-translations/src/ln-translations.js +10 -5
  159. package/js/ln-upload/README.md +85 -10
  160. package/js/ln-upload/ln-upload.js +1 -1
  161. package/js/ln-upload/src/ln-upload.js +26 -10
  162. package/js/ln-validate/README.md +72 -0
  163. package/js/ln-validate/ln-validate-dev.scss +15 -0
  164. package/js/ln-validate/ln-validate.js +1 -1
  165. package/js/ln-validate/src/ln-validate.js +64 -0
  166. package/package.json +61 -56
  167. package/scss/base/_global.scss +2 -2
  168. package/scss/base/_typography.scss +1 -2
  169. package/scss/components/_date.scss +1 -0
  170. package/scss/components/_dropdown.scss +0 -5
  171. package/scss/components/_editor.scss +5 -0
  172. package/scss/components/_filter.scss +26 -25
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +13 -3
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +36 -23
  177. package/scss/components/_toast.scss +68 -41
  178. package/scss/components/_toggle.scss +16 -0
  179. package/scss/config/_density.scss +205 -117
  180. package/scss/config/_icons.scss +10 -9
  181. package/scss/config/_tokens.scss +69 -47
  182. package/scss/config/mixins/_accordion.scss +2 -7
  183. package/scss/config/mixins/_alert.scss +4 -4
  184. package/scss/config/mixins/_breadcrumbs.scss +2 -2
  185. package/scss/config/mixins/_btn.scss +1 -1
  186. package/scss/config/mixins/_card.scss +89 -14
  187. package/scss/config/mixins/_collapsible.scss +3 -0
  188. package/scss/config/mixins/_colors.scss +1 -1
  189. package/scss/config/mixins/_diagnostics.scss +11 -0
  190. package/scss/config/mixins/_display.scss +1 -0
  191. package/scss/config/mixins/_dropdown.scss +3 -5
  192. package/scss/config/mixins/_editor.scss +124 -0
  193. package/scss/config/mixins/_empty-state.scss +7 -0
  194. package/scss/config/mixins/_filter.scss +3 -5
  195. package/scss/config/mixins/_footer.scss +1 -2
  196. package/scss/config/mixins/_form.scss +207 -21
  197. package/scss/config/mixins/_index.scss +2 -0
  198. package/scss/config/mixins/_ln-table.scss +41 -2
  199. package/scss/config/mixins/_modal.scss +149 -66
  200. package/scss/config/mixins/_nav.scss +3 -4
  201. package/scss/config/mixins/_popover.scss +39 -0
  202. package/scss/config/mixins/_sidebar.scss +1 -1
  203. package/scss/config/mixins/_sort.scss +42 -0
  204. package/scss/config/mixins/_table.scss +85 -60
  205. package/scss/config/mixins/_toast.scss +179 -152
  206. package/scss/config/mixins/_tooltip.scss +17 -7
  207. package/scss/config/mixins/_typography.scss +4 -2
  208. package/scss/ln-ashlar-dev.scss +27 -9
  209. package/scss/ln-ashlar.scss +7 -1
  210. package/js/ln-data-table/README.md +0 -110
  211. package/js/ln-data-table/ln-data-table.js +0 -1
  212. package/js/ln-data-table/ln-data-table.scss +0 -10
  213. package/js/ln-data-table/src/ln-data-table.js +0 -1087
  214. package/js/ln-icons/README.md +0 -88
  215. package/js/ln-icons/ln-icons.js +0 -1
  216. package/js/ln-table/ln-table-sort.js +0 -1
  217. package/js/ln-table/src/ln-table-sort.js +0 -156
@@ -1,83 +1,158 @@
1
- # ln-search
2
-
3
- A zero-dependency, event-driven **Debounced Search Primitive** that intercepts text input, debounces keystrokes, and coordinates list-filtering states.
4
-
5
- It acts as an intent announcer. It does not own data filtering directly; instead, it announces a normalized search query on the target element via a cancelable event, allowing tables, lists, or custom server integrations to intercept and handle the query or fall back to a built-in DOM-walking filter.
6
-
7
- ---
8
-
9
- ## 🧭 Philosophy & Architecture
10
-
11
- 1. **Decoupled Search Intent:** The component does not manipulate collections directly. It debounces keystrokes (150ms trailing-edge) and dispatches a cancelable `ln-search:change` custom event on the **target** element (not the input).
12
- 2. **The Default DOM-Walk Fallback:** If no listener calls `e.preventDefault()`, the component automatically runs a fast DOM-walk on the target's children, applying `data-ln-search-hide="true"` to non-matching elements.
13
- 3. **Reactive Form-Restore Hook:** During page reloads or back-button navigation, browsers auto-fill input fields before scripts initialize. `ln-search` detects this pre-filled state at construction and schedules an initial search dispatch via `queueMicrotask` to ensure all listener components have fully initialized first.
14
-
15
- ---
16
-
17
- ## 📦 Minimal Blueprint
18
-
19
- ### Standard List Filtering (Zero-JS Fallback)
20
- Bind the search input to a list `id` via `data-ln-search`.
21
- ```html
22
- <input type="search" placeholder="Search..." data-ln-search="countries-list">
23
-
24
- <ul id="countries-list">
25
- <li>Argentina</li>
26
- <li>Brazil</li>
27
- <li>Canada</li>
28
- </ul>
29
- ```
30
-
31
- ### Premium Icon Wrapper with Clear Button
32
- When placed on a wrapper container, the component automatically resolves the nested input.
33
- ```html
34
- <label data-ln-search="countries-list">
35
- <svg class="ln-icon ln-icon--sm" aria-hidden="true"><use href="#ln-search"></use></svg>
36
- <input type="search" placeholder="Search countries...">
37
- <button type="button" data-ln-search-clear aria-label="Clear search">
38
- <svg class="ln-icon ln-icon--sm" aria-hidden="true"><use href="#ln-x"></use></svg>
39
- </button>
40
- </label>
41
- ```
42
-
43
- ---
44
-
45
- ## 🛠️ Declarative API Contract
46
-
47
- ### HTML Attributes
48
-
49
- | Attribute | Elements | Description |
50
- | :--- | :--- | :--- |
51
- | `data-ln-search` | `<input>`, `<label>` | Component root and namespace. Value is the `id` of the target to filter. |
52
- | `data-ln-search-items` | Same as root | Opt-in. Deep CSS selector (e.g. `tbody tr`) to query deep matching elements instead of direct children. |
53
- | `data-ln-search-clear` | `<button>` | Identifies the clear button. Click clears input and triggers synchronous search reset. |
54
- | `data-ln-search-hide` | Children of target | *State*. Automatically toggled on non-matching elements (`display: none !important`). |
55
-
56
- ### JavaScript API (`el.lnSearch`)
57
-
58
- | Member | Type | Description |
59
- | :--- | :--- | :--- |
60
- | `targetId` | `string` | The ID of the target element. |
61
- | `input` | `HTMLInputElement` | The resolved input element driving the search. |
62
- | `destroy()` | `() => void` | Removes listeners, cancels debounces, and tears down the instance. |
63
-
64
- ---
65
-
66
- ## ⚡ DOM Events
67
-
68
- ### `ln-search:change`
69
- Dispatched on the **target** element whenever the search term changes.
70
- - **Cancelable**: Yes. Calling `e.preventDefault()` disables the default DOM-walk.
71
- - **Payload (`detail`)**: `{ term: string, targetId: string }` (where `term` is lowercased and trimmed).
72
-
73
- ---
74
-
75
- ## ⚠️ Common Pitfalls
76
-
77
- - **Confusing with `ln-data-table` Search:** `ln-data-table` manages its own internal search input via `data-ln-data-table-search` to join sorting and pagination into unified API request arrays. Do not place `ln-search` in front of `ln-data-table`.
78
- - **Bypassing Debounce via Native Input Listeners:** Listening to native `input` events directly will bypass the 150ms debounce and execute expensive logic on every single keystroke. Always listen to `ln-search:change`.
79
- - **Programmatic Value Mutations:** Assigning `input.value = "text"` programmatically does not trigger search. You must manually dispatch an `input` event:
80
- ```javascript
81
- input.value = 'Argentina';
82
- input.dispatchEvent(new Event('input', { bubbles: true }));
83
- ```
1
+ # ln-search
2
+
3
+ A zero-dependency, event-driven **Debounced Search Primitive** that intercepts text input, debounces keystrokes, and coordinates list-filtering states.
4
+
5
+ It acts as an intent announcer. It does not own data filtering directly; instead, it announces a normalized search query on the target element via a cancelable event, allowing tables, lists, or custom server integrations to intercept and handle the query or fall back to a built-in DOM-walking filter.
6
+
7
+ ---
8
+
9
+ ## ✅ Canonical Markup — Copy This (REQUIRED)
10
+
11
+ **Hard rule — non-negotiable.** Every search or filter text input MUST use the
12
+ full `.search` chrome below: the leading magnifier icon **and** the clear
13
+ ("x") button. This applies everywhere — toolbars, table headers, **and
14
+ popovers/dropdowns**. The previously-accepted "lightweight" bare
15
+ `<input data-ln-search>` (no icon, no clear button) is **no longer acceptable
16
+ for authoring**.
17
+
18
+ Copy this block. Change only the `placeholder`, the
19
+ `data-ln-search="<targetId>"`, and the `aria-label`. If a field needs a
20
+ page-specific tweak, add one extra class beside `search`
21
+ (`class="search my-extra"`) — never alter the structure, the icons, or the
22
+ clear button.
23
+
24
+ ```html
25
+ <label class="search">
26
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-search"></use></svg>
27
+ <input type="search" placeholder="Search …" data-ln-search="<targetId>">
28
+ <button type="button" data-ln-search-clear aria-label="Clear search">
29
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
30
+ </button>
31
+ </label>
32
+ ```
33
+
34
+ For deep targeting (e.g. checkbox lists inside a popover) add
35
+ `data-ln-search-items="<selector>"` to the **same `<input>`** — the chrome is
36
+ identical:
37
+
38
+ ```html
39
+ <input type="search" placeholder="Search …" data-ln-search="<targetId>" data-ln-search-items="label">
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 🧭 Philosophy & Architecture
45
+
46
+ 1. **Decoupled Search Intent:** The component does not manipulate collections directly. It debounces keystrokes (150ms trailing-edge) and dispatches a cancelable `ln-search:change` custom event on the **target** element (not the input).
47
+ 2. **The Default DOM-Walk Fallback:** If no listener calls `e.preventDefault()`, the component automatically runs a fast DOM-walk on the target's children, applying `data-ln-search-hide="true"` to non-matching elements.
48
+ 3. **Reactive Form-Restore Hook:** During page reloads or back-button navigation, browsers auto-fill input fields before scripts initialize. `ln-search` detects this pre-filled state at construction and schedules an initial search dispatch via `queueMicrotask` to ensure all listener components have fully initialized first.
49
+
50
+ ---
51
+
52
+ ## 📦 Minimal Blueprint
53
+
54
+ ### Standard List Filtering (Zero-JS Fallback)
55
+ Bind the search input to a list `id` via `data-ln-search`.
56
+
57
+ > ⚠️ **Not an authoring pattern.** The component still *accepts* a bare input
58
+ > (backward-compat / JS-internal fallback), but you must always author the full
59
+ > `.search` chrome shown in [Canonical Markup](#-canonical-markup--copy-this-required).
60
+
61
+ ```html
62
+ <input type="search" placeholder="Search..." data-ln-search="countries-list">
63
+
64
+ <ul id="countries-list">
65
+ <li>Argentina</li>
66
+ <li>Brazil</li>
67
+ <li>Canada</li>
68
+ </ul>
69
+ ```
70
+
71
+ ### Premium Icon Wrapper with Clear Button
72
+ The canonical input-host form places `data-ln-search` directly on the `<input>`
73
+ and uses `class="search"` for the compact icon-group chrome (leading magnifier,
74
+ recessed fill, clear button):
75
+ ```html
76
+ <label class="search">
77
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-search"></use></svg>
78
+ <input type="search" placeholder="Search countries..." data-ln-search="countries-list">
79
+ <button type="button" data-ln-search-clear aria-label="Clear search">
80
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
81
+ </button>
82
+ </label>
83
+ ```
84
+ `class="search"` binds `@include search` (library `scss/components/_form.scss`).
85
+ Wrapper-host (`data-ln-search` on the `<label>`) is retained only so existing
86
+ markup keeps working — input-host is **required** for all new code.
87
+
88
+ ---
89
+
90
+ ## 🛠️ Declarative API Contract
91
+
92
+ ### HTML Attributes
93
+
94
+ | Attribute | Elements | Description |
95
+ | :--- | :--- | :--- |
96
+ | `data-ln-search` | `<input>` (canonical); `<label>`/wrapper (backward-compat) | Component root and namespace. Value is the `id` of the target to filter. |
97
+ | `data-ln-search-items` | Same as root | Opt-in. Deep CSS selector (e.g. `tbody tr`) to query deep matching elements instead of direct children. |
98
+ | `data-ln-search-clear` | `<button>` | Identifies the clear button. Click clears input and triggers synchronous search reset. |
99
+ | `data-ln-search-hide` | Children of target | *State*. Automatically toggled on non-matching elements (`display: none !important`). |
100
+
101
+ ### JavaScript API (`el.lnSearch`)
102
+
103
+ | Member | Type | Description |
104
+ | :--- | :--- | :--- |
105
+ | `targetId` | `string` | The ID of the target element. |
106
+ | `input` | `HTMLInputElement` | The resolved input element driving the search. |
107
+ | `destroy()` | `() => void` | Removes listeners, cancels debounces, and tears down the instance. |
108
+
109
+ ---
110
+
111
+ ## ⚡ DOM Events
112
+
113
+ ### `ln-search:change`
114
+ Dispatched on the **target** element whenever the search term changes.
115
+ - **Cancelable**: Yes. Calling `e.preventDefault()` disables the default DOM-walk.
116
+ - **Payload (`detail`)**: `{ term: string, targetId: string }` (where `term` is lowercased and trimmed).
117
+
118
+ ---
119
+
120
+ ## ⚠️ Common Pitfalls
121
+
122
+ - **Driving an `ln-table` search box:** placing `data-ln-search="<tableId>"` on the search input (or its label wrapper) is the sole supported pattern, but the consumer differs by mode. **SSR mode** consumes `ln-search:change` directly on the table. **Data-driven mode** requires the table to be wrapped in `[data-ln-table-coordinator]`, which receives `ln-search:change` and translates it into `ln-table:set-search`, joined with sort and column filters into the `ln-table:request-data` payload. Note: `data-ln-table-search` has been removed — use `data-ln-search="<tableId>"` instead.
123
+ - **Bypassing Debounce via Native Input Listeners:** Listening to native `input` events directly will bypass the 150ms debounce and execute expensive logic on every single keystroke. Always listen to `ln-search:change`.
124
+ - **Programmatic Value Mutations:** Assigning `input.value = "text"` programmatically does not trigger search. You must manually dispatch an `input` event:
125
+ ```javascript
126
+ input.value = 'Argentina';
127
+ input.dispatchEvent(new Event('input', { bubbles: true }));
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 🔧 Internals
133
+
134
+ Source: `js/ln-search/ln-search.js` (114 lines). One instance per `[data-ln-search]`, stored at `element.lnSearch`. `targetId` is captured once at construction; `document.getElementById(targetId)` is re-resolved on every search — cheaper than caching a node across AJAX re-renders.
135
+
136
+ ### Debounce shape
137
+
138
+ Trailing-edge, 150ms, cancel-and-reschedule per keystroke — no leading-edge fire (would flicker partial filtering mid-type). The clear button bypasses the debounce entirely, calling `_search('')` synchronously.
139
+
140
+ ### Dispatch flow
141
+
142
+ `_search` resolves the target, dispatches cancelable `ln-search:change` on it (not on the input — so consumers listen on the thing they care about), and only runs the default DOM-walk if `!evt.defaultPrevented`. There is no separate `:before-change` event — the cancelable shape of `:change` IS the before-hook.
143
+
144
+ ### Form-restore detection
145
+
146
+ If the input already has a value at construction (browser autofill / back-button restore), the initial search is scheduled via `queueMicrotask` rather than fired synchronously — other components (`ln-table`) may not have attached their `ln-search:change` listener yet during the same `DOMContentLoaded` burst; the microtask waits for the current task to drain first.
147
+
148
+ ### Clear-button resolution
149
+
150
+ Scope for locating `[data-ln-search-clear]` is `input.parentElement` when `data-ln-search` sits on the input (canonical), or `dom` itself in the legacy wrapper-host form. Null-guarded — a missing button just means no clear wiring.
151
+
152
+ ### Cross-component coordination
153
+
154
+ No imports of other components. In SSR mode, `ln-table` consumes `ln-search:change` via `preventDefault()` and runs its own in-memory filter; in data-driven mode, `ln-table-coordinator` consumes it instead and translates it into `ln-table:set-search`. `ln-filter` runs independently on the same target through a parallel `data-ln-search-hide` / `data-ln-filter-hide` pair — CSS unions both, neither component is aware of the other.
155
+
156
+ ### Destroy
157
+
158
+ Cancels the pending debounce timer, removes both listeners, removes the init marker. Does NOT walk the target to clear `data-ln-search-hide` — that is a rendering side effect, not the search component's own state.
@@ -0,0 +1,8 @@
1
+ @use '../../scss/config/mixins' as *;
2
+
3
+ [data-ln-debug] {
4
+ // 1. Search Trigger Check: data-ln-search must target a component ID and cannot be empty
5
+ [data-ln-search=""] {
6
+ @include dev-inline-error("data-ln-search target is empty");
7
+ }
8
+ }
@@ -1 +1 @@
1
- (function(){"use strict";function T(t,e,r){const u=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:r||{}});return t.dispatchEvent(u),u}function v(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){v(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function y(t,e,r,u){if(t.nodeType!==1)return;const c=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",n=Array.from(t.querySelectorAll(c));t.matches&&t.matches(c)&&n.push(t);for(const i of n)i[r]||(i[r]=new u(i))}function E(t,e,r,u,m={}){const c=m.extraAttributes||[],n=m.onAttributeChange||null,i=m.onInit||null;function d(f){const s=f||document.body;y(s,t,e,r),i&&i(s)}return v(function(){const f=new MutationObserver(function(l){for(let p=0;p<l.length;p++){const o=l[p];if(o.type==="childList"){for(let h=0;h<o.addedNodes.length;h++){const a=o.addedNodes[h];a.nodeType===1&&(y(a,t,e,r),i&&i(a))}for(let h=0;h<o.removedNodes.length;h++){const a=o.removedNodes[h];if(a.nodeType===1){const w=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",g=Array.from(a.querySelectorAll(w));a.matches&&a.matches(w)&&g.push(a);for(let b=0;b<g.length;b++){const A=g[b];if(!document.contains(A)){const _=A[e];_&&typeof _.destroy=="function"&&_.destroy()}}}}}else o.type==="attributes"&&(n&&o.target[e]?n(o.target,o.attributeName):(y(o.target,t,e,r),i&&i(o.target)))}});let s=[];if(t.indexOf("[")!==-1){const l=/\[([\w-]+)/g;let p;for(;(p=l.exec(t))!==null;)s.push(p[1])}else s.push(t);f.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:s.concat(c)})},u),window[e]=d,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){d(document.body)}):d(document.body),d}const C={};function x(t,e){C[t]=e}function I(t){return C[t]||{ingress:e=>e,egress:e=>e}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=x,window.lnCore.getDataMapper=I),(function(){const t="data-ln-search",e="lnSearch",r="data-ln-search-initialized",u="data-ln-search-hide";if(window[e]!==void 0)return;function c(n){if(n.hasAttribute(r))return this;this.dom=n,this.targetId=n.getAttribute(t);const i=n.tagName;if(this.input=i==="INPUT"||i==="TEXTAREA"?n:n.querySelector('[name="search"]')||n.querySelector('input[type="search"]')||n.querySelector('input[type="text"]'),this.itemsSelector=n.getAttribute("data-ln-search-items")||null,this._debounceTimer=null,this._attachHandler(),this.input&&this.input.value.trim()){const d=this;queueMicrotask(function(){d._search(d.input.value.trim().toLowerCase())})}return n.setAttribute(r,""),this}c.prototype._attachHandler=function(){if(!this.input)return;const n=this;this._clearBtn=this.dom.querySelector("[data-ln-search-clear]"),this._clearBtn&&(this._onClear=function(){n.input.value="",n._search(""),n.input.focus()},this._clearBtn.addEventListener("click",this._onClear)),this._onInput=function(){clearTimeout(n._debounceTimer),n._debounceTimer=setTimeout(function(){n._search(n.input.value.trim().toLowerCase())},150)},this.input.addEventListener("input",this._onInput)},c.prototype._search=function(n){const i=document.getElementById(this.targetId);if(!i||T(i,"ln-search:change",{term:n,targetId:this.targetId}).defaultPrevented)return;const f=this.itemsSelector?i.querySelectorAll(this.itemsSelector):i.children;for(let s=0;s<f.length;s++){const l=f[s];l.removeAttribute(u),n&&!l.textContent.replace(/\s+/g," ").toLowerCase().includes(n)&&l.setAttribute(u,"true")}},c.prototype.destroy=function(){this.dom[e]&&(clearTimeout(this._debounceTimer),this.input&&this._onInput&&this.input.removeEventListener("input",this._onInput),this._clearBtn&&this._onClear&&this._clearBtn.removeEventListener("click",this._onClear),this.dom.removeAttribute(r),delete this.dom[e])},E(t,e,c,"ln-search")})()})();
1
+ (function(){"use strict";if(typeof window<"u"){const t=console.warn;console.warn=function(...e){typeof e[0]=="string"&&(e[0].startsWith("[ln-")||e[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||t.apply(console,e)}}const g={};function q(t,e){g[t]||(g[t]=document.querySelector('[data-ln-template="'+t+'"]'));const o=g[t];return o?o.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function I(t,e,o){const a=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:o||{}});return t.dispatchEvent(a),a}function A(t,e){if(!t||!e)return t;const o=t.querySelectorAll("[data-ln-field]");for(let n=0;n<o.length;n++){const r=o[n],l=r.getAttribute("data-ln-field");e[l]!=null&&(r.textContent=e[l])}const a=t.querySelectorAll("[data-ln-attr]");for(let n=0;n<a.length;n++){const r=a[n],l=r.getAttribute("data-ln-attr").split(",");for(let s=0;s<l.length;s++){const u=l[s].trim().split(":");if(u.length!==2)continue;const d=u[0].trim(),f=u[1].trim();e[f]!=null&&r.setAttribute(d,e[f])}}const c=t.querySelectorAll("[data-ln-show]");for(let n=0;n<c.length;n++){const r=c[n],l=r.getAttribute("data-ln-show");l in e&&r.classList.toggle("hidden",!e[l])}const i=t.querySelectorAll("[data-ln-class]");for(let n=0;n<i.length;n++){const r=i[n],l=r.getAttribute("data-ln-class").split(",");for(let s=0;s<l.length;s++){const u=l[s].trim().split(":");if(u.length!==2)continue;const d=u[0].trim(),f=u[1].trim();f in e&&r.classList.toggle(d,!!e[f])}}return t}function D(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const o=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let a=0;a<o.length;a++)o[a].dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));return t}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore._fillBound||(window.lnCore._fillBound=!0,document.addEventListener("ln-fill",function(t){if(!(!t.target.matches||!t.target.matches("[data-ln-fillable]")))if(t.detail)A(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let o=0;o<e.length;o++)e[o].textContent=""}})));function v(t,e){if(!t||!e)return t;const o=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;o.nextNode();){const i=o.currentNode;i.textContent.indexOf("{{")!==-1&&(i.textContent=i.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(n,r){return e[r]!==void 0?e[r]:""}))}const a=function(i,n){return e[n]!==void 0?e[n]:""},c=Array.from(t.querySelectorAll("*"));t.nodeType===1&&c.push(t);for(let i=0;i<c.length;i++){const n=c[i],r=n.attributes;for(let l=0;l<r.length;l++){const s=r[l];s.value.indexOf("{{")!==-1&&n.setAttribute(s.name,s.value.replace(/\{\{\s*(\w+)\s*\}\}/g,a))}}return t}function N(t,e,o,a,c,i){const n={};for(let l=0;l<t.children.length;l++){const s=t.children[l],u=s.getAttribute("data-ln-key");u&&(n[u]=s)}const r=document.createDocumentFragment();for(let l=0;l<e.length;l++){const s=e[l],u=String(a(s));let d=n[u];if(d)c(d,s,l);else{const f=q(o,i);if(!f||(v(f,s),d=f.firstElementChild,!d))continue;d.setAttribute("data-ln-key",u),c(d,s,l)}r.appendChild(d)}t.textContent="",t.appendChild(r)}function S(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){S(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function w(t,e,o,a){if(t.nodeType!==1)return;const i=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",n=Array.from(t.querySelectorAll(i));t.matches&&t.matches(i)&&n.push(t);for(const r of n)r[o]||(r[o]=new a(r))}function B(t,e,o,a,c={}){const i=c.extraAttributes||[],n=c.onAttributeChange||null,r=c.onInit||null;function l(s){const u=s||document.body;w(u,t,e,o),r&&r(u)}return S(function(){const s=new MutationObserver(function(d){for(let f=0;f<d.length;f++){const h=d[f];if(h.type==="childList"){for(let m=0;m<h.addedNodes.length;m++){const p=h.addedNodes[m];p.nodeType===1&&(w(p,t,e,o),r&&r(p))}for(let m=0;m<h.removedNodes.length;m++){const p=h.removedNodes[m];if(p.nodeType===1){const O=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",y=Array.from(p.querySelectorAll(O));p.matches&&p.matches(O)&&y.push(p);for(let C=0;C<y.length;C++){const k=y[C];if(!document.contains(k)){const _=k[e];_&&typeof _.destroy=="function"&&_.destroy()}}}}}else h.type==="attributes"&&(n&&h.target[e]?n(h.target,h.attributeName):(w(h.target,t,e,o),r&&r(h.target)))}});let u=[];if(t.indexOf("[")!==-1){const d=/\[([\w-]+)/g;let f;for(;(f=d.exec(t))!==null;)u.push(f[1])}else u.push(t);s.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:u.concat(i)})},a),window[e]=l,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){l(document.body)}):l(document.body),l}const E={};function M(t,e){E[t]=e}function j(t){return E[t]||{ingress:e=>e,egress:e=>e}}const L={};function T(t,e){if(!t||typeof e!="object")return;const o=t.toLowerCase().split("-")[0];L[o]=e}function U(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return L[e]||null}T("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=M,window.lnCore.getDataMapper=j,window.lnCore.registerLocaleFallback=T,window.lnCore.getLocaleFallback=U,window.lnCore.fillTemplate=v,window.lnCore.fill=A,window.lnCore.lnFill=D,window.lnCore.renderList=N);function x(t){return(t||"").replace(/^#/,"")}function b(t){const e=t===void 0?location.hash:t,o={},a=x(e);if(!a)return o;const c=a.split("&");for(let i=0;i<c.length;i++){const n=c[i];if(!n)continue;const r=n.indexOf(":"),l=r>-1?n.slice(0,r):n,s=r>-1?n.slice(r+1):"";if(l)try{o[l]=decodeURIComponent(s)}catch{o[l]=s}}return o}function W(t){if(!t)return null;const e=b();return t in e?e[t]:null}function F(t,e){if(!t)return;const o=b();e==null?delete o[t]:o[t]=String(e);const c=Object.keys(o).map(function(i){const n=o[i];return n===""?i:i+":"+encodeURIComponent(n)}).join("&");x(location.hash)!==c&&(location.hash=c)}function R(t){return t.button===1||t.ctrlKey||t.metaKey||t.shiftKey?!1:(t.preventDefault(),!0)}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.hashParse=b,window.lnCore.hashGet=W,window.lnCore.hashSet=F,window.lnCore.hashLinkClick=R),(function(){const t="data-ln-search",e="lnSearch",o="data-ln-search-hide";if(window[e]!==void 0)return;function c(i){this.dom=i,this.targetId=i.getAttribute(t);const n=i.tagName;this.input=n==="INPUT"||n==="TEXTAREA"?i:i.querySelector('[name="search"]')||i.querySelector('input[type="search"]')||i.querySelector('input[type="text"]'),this.itemsSelector=i.getAttribute("data-ln-search-items")||null;const r=i.getAttribute("data-ln-search-debounce");if(this.debounceTime=r!==null?parseInt(r,10):500,isNaN(this.debounceTime)&&(this.debounceTime=500),this._debounceTimer=null,this._attachHandler(),this.input&&this.input.value.trim()){const l=this;queueMicrotask(function(){l._search(l.input.value.trim().toLowerCase())})}return this}c.prototype._attachHandler=function(){if(!this.input)return;const i=this,n=this.dom===this.input?this.input.parentElement:this.dom;this._clearBtn=n?n.querySelector("[data-ln-search-clear]"):null,this._clearBtn&&(this._onClear=function(){i.input.value="",i._search(""),i.input.focus()},this._clearBtn.addEventListener("click",this._onClear)),this._onInput=function(){clearTimeout(i._debounceTimer),i._debounceTimer=setTimeout(function(){i._search(i.input.value.trim().toLowerCase())},i.debounceTime)},this.input.addEventListener("input",this._onInput)},c.prototype._search=function(i){const n=document.getElementById(this.targetId);if(!n||I(n,"ln-search:change",{term:i,targetId:this.targetId}).defaultPrevented)return;const l=this.itemsSelector?n.querySelectorAll(this.itemsSelector):n.children;for(let s=0;s<l.length;s++){const u=l[s];u.removeAttribute(o),i&&!u.textContent.replace(/\s+/g," ").toLowerCase().includes(i)&&u.setAttribute(o,"true")}},c.prototype.destroy=function(){this.dom[e]&&(clearTimeout(this._debounceTimer),this.input&&this._onInput&&this.input.removeEventListener("input",this._onInput),this._clearBtn&&this._onClear&&this._clearBtn.removeEventListener("click",this._onClear),delete this.dom[e])},B(t,e,c,"ln-search")})()})();
@@ -3,17 +3,14 @@ import { dispatchCancelable, registerComponent } from '../../ln-core';
3
3
  (function () {
4
4
  const DOM_SELECTOR = 'data-ln-search';
5
5
  const DOM_ATTRIBUTE = 'lnSearch';
6
- const INIT_ATTR = 'data-ln-search-initialized';
7
6
  const HIDE_ATTR = 'data-ln-search-hide';
8
- const DEBOUNCE_MS = 150;
7
+ const DEBOUNCE_MS = 500;
9
8
 
10
9
  if (window[DOM_ATTRIBUTE] !== undefined) return;
11
10
 
12
11
  // ─── Component ─────────────────────────────────────────────
13
12
 
14
13
  function _component(dom) {
15
- if (dom.hasAttribute(INIT_ATTR)) return this;
16
-
17
14
  this.dom = dom;
18
15
  this.targetId = dom.getAttribute(DOM_SELECTOR);
19
16
 
@@ -25,6 +22,11 @@ import { dispatchCancelable, registerComponent } from '../../ln-core';
25
22
  || dom.querySelector('input[type="text"]');
26
23
 
27
24
  this.itemsSelector = dom.getAttribute('data-ln-search-items') || null;
25
+
26
+ const debounceVal = dom.getAttribute('data-ln-search-debounce');
27
+ this.debounceTime = debounceVal !== null ? parseInt(debounceVal, 10) : DEBOUNCE_MS;
28
+ if (isNaN(this.debounceTime)) this.debounceTime = DEBOUNCE_MS;
29
+
28
30
  this._debounceTimer = null;
29
31
 
30
32
  this._attachHandler();
@@ -38,7 +40,6 @@ import { dispatchCancelable, registerComponent } from '../../ln-core';
38
40
  });
39
41
  }
40
42
 
41
- dom.setAttribute(INIT_ATTR, '');
42
43
  return this;
43
44
  }
44
45
 
@@ -48,8 +49,11 @@ import { dispatchCancelable, registerComponent } from '../../ln-core';
48
49
  if (!this.input) return;
49
50
  const self = this;
50
51
 
51
- // Clear button inside the wrapper
52
- this._clearBtn = this.dom.querySelector('[data-ln-search-clear]');
52
+ // Clear button: when data-ln-search is on the input itself, the clear button
53
+ // lives in the input's immediate wrapper (inputs have no element children).
54
+ // Otherwise (wrapper-host) scope is dom — unchanged behavior.
55
+ const scope = (this.dom === this.input) ? this.input.parentElement : this.dom;
56
+ this._clearBtn = scope ? scope.querySelector('[data-ln-search-clear]') : null;
53
57
  if (this._clearBtn) {
54
58
  this._onClear = function () {
55
59
  self.input.value = '';
@@ -63,7 +67,7 @@ import { dispatchCancelable, registerComponent } from '../../ln-core';
63
67
  clearTimeout(self._debounceTimer);
64
68
  self._debounceTimer = setTimeout(function () {
65
69
  self._search(self.input.value.trim().toLowerCase());
66
- }, DEBOUNCE_MS);
70
+ }, self.debounceTime);
67
71
  };
68
72
 
69
73
  this.input.addEventListener('input', this._onInput);
@@ -104,7 +108,6 @@ import { dispatchCancelable, registerComponent } from '../../ln-core';
104
108
  if (this._clearBtn && this._onClear) {
105
109
  this._clearBtn.removeEventListener('click', this._onClear);
106
110
  }
107
- this.dom.removeAttribute(INIT_ATTR);
108
111
  delete this.dom[DOM_ATTRIBUTE];
109
112
  };
110
113
 
@@ -0,0 +1,110 @@
1
+ # ln-slug
2
+
3
+ Auto-slug micro-component — mirrors a slugified version of a source field into a slug field while the slug field is pristine. Stops mirroring once the user types directly into the slug field; resumes if the slug field is cleared.
4
+
5
+ ## Attributes
6
+
7
+ | Attribute | On | Description |
8
+ |-----------|-----|-------------|
9
+ | `data-ln-slug-from="fieldName"` | `<input>` | Activates slug behavior. Value is the `name` of the source field in the same `<form>`. |
10
+
11
+ ## HTML Example
12
+
13
+ ```html
14
+ <form>
15
+ <label>
16
+ Name
17
+ <input type="text" name="name">
18
+ </label>
19
+ <label>
20
+ Slug
21
+ <input type="text" name="slug" data-ln-slug-from="name">
22
+ </label>
23
+ </form>
24
+ ```
25
+
26
+ ## Pristine Rules
27
+
28
+ The component tracks a `_pristine` flag per instance. Mirroring only runs while pristine.
29
+
30
+ | Situation | Result |
31
+ |-----------|--------|
32
+ | Slug field is empty at init | pristine = `true` (mirroring active) |
33
+ | Slug field is non-empty at init (server-rendered) | pristine = `false` (never overwrite) |
34
+ | User types non-empty value into slug field | pristine = `false` (mirroring stops) |
35
+ | User clears slug field | pristine = `true` (mirroring resumes) |
36
+ | `lnForm.fill` sets a non-empty slug (synthetic input) | pristine = `false` (loaded slug preserved) |
37
+ | Form reset clears slug to empty (synthetic input) | pristine = `true` (mirroring resumes) |
38
+
39
+ **Unified rule:** in the slug's own input handler — `if (_mirroring) return; _pristine = (value === '')`. Both trusted and untrusted inputs follow the same rule once the component's own mirror echo is guarded by the `_mirroring` flag.
40
+
41
+ ## Loop Guard
42
+
43
+ When the source field triggers a mirror, the component:
44
+ 1. Sets `_mirroring = true`
45
+ 2. Sets `slug.value = slugify(source.value)`
46
+ 3. Dispatches a synthetic `input` event (bubbles, so `ln-validate`/`ln-form` auto-submit react)
47
+ 4. Sets `_mirroring = false`
48
+
49
+ The slug's own input handler early-returns while `_mirroring` is true, so the synthetic echo does not flip `_pristine` off.
50
+
51
+ ## Slugify Rules
52
+
53
+ ```
54
+ lowercase → replace non-alphanumeric runs with `-` → collapse multiple `-` → strip leading/trailing `-`
55
+ ```
56
+
57
+ Example: `"Hello World!"` → `"hello-world"`, `" test--value "` → `"test-value"`.
58
+
59
+ ## Limitations
60
+
61
+ - **ASCII-only (v1):** non-ASCII characters (Cyrillic, Macedonian, accented Latin, CJK) are stripped entirely — they fall into the `[^a-z0-9]+` replacement and become `-`. No transliteration in v1.
62
+ - **Source read at init:** `data-ln-slug-from` is read once at construction via `form.elements[name]`. If the source field is added dynamically after init, the component will not find it (warn + bail). Re-initialize the slug input after DOM changes.
63
+ - **Single source field:** if multiple fields share the same `name` (RadioNodeList), the component warns and bails. One text field → one slug.
64
+
65
+ ## Events
66
+
67
+ The component dispatches a synthetic `new Event('input', { bubbles: true })` on the slug field after each mirror. This causes `ln-validate` to re-validate and `ln-form`'s auto-submit debounce to fire. No custom `ln-slug:*` events are emitted.
68
+
69
+ ---
70
+
71
+ ## 🔧 Internals
72
+
73
+ Source: `js/ln-slug/ln-slug.js`. Single IIFE, `registerComponent`, per-element instance — same micro-component pattern as `ln-autoresize` (no Proxy/state machine, no templates).
74
+
75
+ ### Instance state
76
+
77
+ | Property | Type | Description |
78
+ |----------|------|-------------|
79
+ | `dom` | `HTMLInputElement` | The slug input element |
80
+ | `source` | `HTMLInputElement` | The resolved source field (`form.elements[name]`) |
81
+ | `_pristine` | `boolean` | Whether the slug field is pristine (mirroring active) |
82
+ | `_mirroring` | `boolean` | Guard flag — true only while the component is dispatching its own synthetic echo |
83
+
84
+ ### Mirror flow
85
+
86
+ ```
87
+ source field 'input' event (isTrusted)
88
+ → _onSource: if (!_pristine) return
89
+ → _mirror():
90
+ _mirroring = true
91
+ slug.value = slugify(source.value)
92
+ slug.dispatchEvent(new Event('input', { bubbles: true }))
93
+ → _onSlug fires → _mirroring true → early return (echo ignored)
94
+ → ln-validate / ln-form auto-submit react (bubbled event)
95
+ _mirroring = false
96
+ ```
97
+
98
+ The `_mirroring` guard is what disambiguates the component's own echo from external programmatic fills — both are `isTrusted === false`, separated only by the flag.
99
+
100
+ ### Initialization guards
101
+
102
+ The constructor warns and bails (returns `this` without attaching listeners) when:
103
+ - `dom.tagName !== 'INPUT'` — component applied to non-input
104
+ - `dom.form` is null — slug input not inside a `<form>`
105
+ - `form.elements[name]` is falsy — source field not found
106
+ - `typeof source.addEventListener !== 'function'` — source is a RadioNodeList (same-name group)
107
+
108
+ ### Destroy
109
+
110
+ `destroy()` removes `_onSource` from the source field and `_onSlug` from the slug field, then deletes `dom[DOM_ATTRIBUTE]`. Guarded by `if (!this.dom[DOM_ATTRIBUTE]) return`, making it idempotent.
@@ -0,0 +1 @@
1
+ (function(){"use strict";if(typeof window<"u"){const t=console.warn;console.warn=function(...e){typeof e[0]=="string"&&(e[0].startsWith("[ln-")||e[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||t.apply(console,e)}}const m={};function N(t,e){m[t]||(m[t]=document.querySelector('[data-ln-template="'+t+'"]'));const r=m[t];return r?r.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function S(t,e){if(!t||!e)return t;const r=t.querySelectorAll("[data-ln-field]");for(let n=0;n<r.length;n++){const i=r[n],o=i.getAttribute("data-ln-field");e[o]!=null&&(i.textContent=e[o])}const c=t.querySelectorAll("[data-ln-attr]");for(let n=0;n<c.length;n++){const i=c[n],o=i.getAttribute("data-ln-attr").split(",");for(let u=0;u<o.length;u++){const a=o[u].trim().split(":");if(a.length!==2)continue;const f=a[0].trim(),d=a[1].trim();e[d]!=null&&i.setAttribute(f,e[d])}}const l=t.querySelectorAll("[data-ln-show]");for(let n=0;n<l.length;n++){const i=l[n],o=i.getAttribute("data-ln-show");o in e&&i.classList.toggle("hidden",!e[o])}const s=t.querySelectorAll("[data-ln-class]");for(let n=0;n<s.length;n++){const i=s[n],o=i.getAttribute("data-ln-class").split(",");for(let u=0;u<o.length;u++){const a=o[u].trim().split(":");if(a.length!==2)continue;const f=a[0].trim(),d=a[1].trim();d in e&&i.classList.toggle(f,!!e[d])}}return t}function q(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const r=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let c=0;c<r.length;c++)r[c].dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));return t}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore._fillBound||(window.lnCore._fillBound=!0,document.addEventListener("ln-fill",function(t){if(!(!t.target.matches||!t.target.matches("[data-ln-fillable]")))if(t.detail)S(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let r=0;r<e.length;r++)e[r].textContent=""}})));function v(t,e){if(!t||!e)return t;const r=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;r.nextNode();){const s=r.currentNode;s.textContent.indexOf("{{")!==-1&&(s.textContent=s.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(n,i){return e[i]!==void 0?e[i]:""}))}const c=function(s,n){return e[n]!==void 0?e[n]:""},l=Array.from(t.querySelectorAll("*"));t.nodeType===1&&l.push(t);for(let s=0;s<l.length;s++){const n=l[s],i=n.attributes;for(let o=0;o<i.length;o++){const u=i[o];u.value.indexOf("{{")!==-1&&n.setAttribute(u.name,u.value.replace(/\{\{\s*(\w+)\s*\}\}/g,c))}}return t}function D(t,e,r,c,l,s){const n={};for(let o=0;o<t.children.length;o++){const u=t.children[o],a=u.getAttribute("data-ln-key");a&&(n[a]=u)}const i=document.createDocumentFragment();for(let o=0;o<e.length;o++){const u=e[o],a=String(c(u));let f=n[a];if(f)l(f,u,o);else{const d=N(r,s);if(!d||(v(d,u),f=d.firstElementChild,!f))continue;f.setAttribute("data-ln-key",a),l(f,u,o)}i.appendChild(f)}t.textContent="",t.appendChild(i)}function A(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){A(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function w(t,e,r,c){if(t.nodeType!==1)return;const s=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",n=Array.from(t.querySelectorAll(s));t.matches&&t.matches(s)&&n.push(t);for(const i of n)i[r]||(i[r]=new c(i))}function M(t,e,r,c,l={}){const s=l.extraAttributes||[],n=l.onAttributeChange||null,i=l.onInit||null;function o(u){const a=u||document.body;w(a,t,e,r),i&&i(a)}return A(function(){const u=new MutationObserver(function(f){for(let d=0;d<f.length;d++){const h=f[d];if(h.type==="childList"){for(let g=0;g<h.addedNodes.length;g++){const p=h.addedNodes[g];p.nodeType===1&&(w(p,t,e,r),i&&i(p))}for(let g=0;g<h.removedNodes.length;g++){const p=h.removedNodes[g];if(p.nodeType===1){const k=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",b=Array.from(p.querySelectorAll(k));p.matches&&p.matches(k)&&b.push(p);for(let C=0;C<b.length;C++){const T=b[C];if(!document.contains(T)){const L=T[e];L&&typeof L.destroy=="function"&&L.destroy()}}}}}else h.type==="attributes"&&(n&&h.target[e]?n(h.target,h.attributeName):(w(h.target,t,e,r),i&&i(h.target)))}});let a=[];if(t.indexOf("[")!==-1){const f=/\[([\w-]+)/g;let d;for(;(d=f.exec(t))!==null;)a.push(d[1])}else a.push(t);u.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:a.concat(s)})},c),window[e]=o,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){o(document.body)}):o(document.body),o}const _={};function j(t,e){_[t]=e}function W(t){return _[t]||{ingress:e=>e,egress:e=>e}}const x={};function E(t,e){if(!t||typeof e!="object")return;const r=t.toLowerCase().split("-")[0];x[r]=e}function F(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return x[e]||null}E("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=j,window.lnCore.getDataMapper=W,window.lnCore.registerLocaleFallback=E,window.lnCore.getLocaleFallback=F,window.lnCore.fillTemplate=v,window.lnCore.fill=S,window.lnCore.lnFill=q,window.lnCore.renderList=D);function O(t){return(t||"").replace(/^#/,"")}function y(t){const e=t===void 0?location.hash:t,r={},c=O(e);if(!c)return r;const l=c.split("&");for(let s=0;s<l.length;s++){const n=l[s];if(!n)continue;const i=n.indexOf(":"),o=i>-1?n.slice(0,i):n,u=i>-1?n.slice(i+1):"";if(o)try{r[o]=decodeURIComponent(u)}catch{r[o]=u}}return r}function I(t){if(!t)return null;const e=y();return t in e?e[t]:null}function B(t,e){if(!t)return;const r=y();e==null?delete r[t]:r[t]=String(e);const l=Object.keys(r).map(function(s){const n=r[s];return n===""?s:s+":"+encodeURIComponent(n)}).join("&");O(location.hash)!==l&&(location.hash=l)}function R(t){return t.button===1||t.ctrlKey||t.metaKey||t.shiftKey?!1:(t.preventDefault(),!0)}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.hashParse=y,window.lnCore.hashGet=I,window.lnCore.hashSet=B,window.lnCore.hashLinkClick=R),(function(){const t="data-ln-slug-from",e="lnSlug";if(window[e]!==void 0)return;function r(l){return String(l).toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"")}function c(l){if(l.tagName!=="INPUT")return console.warn("[ln-slug] Can only be applied to <input>, got:",l.tagName),this;const s=l.form;if(!s)return console.warn("[ln-slug] Slug input is not inside a <form>:",l),this;const n=l.getAttribute(t),i=s.elements[n];if(!i)return console.warn('[ln-slug] Source field "'+n+'" not found in form:',l),this;if(typeof i.addEventListener!="function")return console.warn('[ln-slug] Source field "'+n+'" is a RadioNodeList (same-name group) — single source field required:',l),this;this.dom=l,this.source=i,this._pristine=l.value==="",this._mirroring=!1;const o=this;return this._onSource=function(){o._pristine&&o._mirror()},this._onSlug=function(){o._mirroring||(o._pristine=o.dom.value==="")},i.addEventListener("input",this._onSource),l.addEventListener("input",this._onSlug),this}c.prototype._mirror=function(){this._mirroring=!0,this.dom.value=r(this.source.value),this.dom.dispatchEvent(new Event("input",{bubbles:!0})),this._mirroring=!1},c.prototype.destroy=function(){this.dom[e]&&(this.source.removeEventListener("input",this._onSource),this.dom.removeEventListener("input",this._onSlug),delete this.dom[e])},M(t,e,c,"ln-slug")})()})();
@@ -0,0 +1,74 @@
1
+ import { registerComponent } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-slug-from';
5
+ const DOM_ATTRIBUTE = 'lnSlug';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ function slugify(s) {
10
+ return String(s).toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/-+/g, '-')
13
+ .replace(/^-|-$/g, '');
14
+ }
15
+
16
+ function _component(dom) {
17
+ if (dom.tagName !== 'INPUT') {
18
+ console.warn('[ln-slug] Can only be applied to <input>, got:', dom.tagName);
19
+ return this;
20
+ }
21
+ const form = dom.form;
22
+ if (!form) {
23
+ console.warn('[ln-slug] Slug input is not inside a <form>:', dom);
24
+ return this;
25
+ }
26
+ const sourceName = dom.getAttribute(DOM_SELECTOR);
27
+ const source = form.elements[sourceName];
28
+ if (!source) {
29
+ console.warn('[ln-slug] Source field "' + sourceName + '" not found in form:', dom);
30
+ return this;
31
+ }
32
+ if (typeof source.addEventListener !== 'function') {
33
+ console.warn('[ln-slug] Source field "' + sourceName + '" is a RadioNodeList (same-name group) — single source field required:', dom);
34
+ return this;
35
+ }
36
+
37
+ this.dom = dom;
38
+ this.source = source;
39
+ this._pristine = dom.value === '';
40
+ this._mirroring = false;
41
+
42
+ const self = this;
43
+
44
+ this._onSource = function () {
45
+ if (!self._pristine) return;
46
+ self._mirror();
47
+ };
48
+ this._onSlug = function () {
49
+ if (self._mirroring) return;
50
+ self._pristine = (self.dom.value === '');
51
+ };
52
+
53
+ source.addEventListener('input', this._onSource);
54
+ dom.addEventListener('input', this._onSlug);
55
+
56
+ return this;
57
+ }
58
+
59
+ _component.prototype._mirror = function () {
60
+ this._mirroring = true;
61
+ this.dom.value = slugify(this.source.value);
62
+ this.dom.dispatchEvent(new Event('input', { bubbles: true }));
63
+ this._mirroring = false;
64
+ };
65
+
66
+ _component.prototype.destroy = function () {
67
+ if (!this.dom[DOM_ATTRIBUTE]) return;
68
+ this.source.removeEventListener('input', this._onSource);
69
+ this.dom.removeEventListener('input', this._onSlug);
70
+ delete this.dom[DOM_ATTRIBUTE];
71
+ };
72
+
73
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-slug');
74
+ })();