@livenetworks/ashlar 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) 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 +162 -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 +77 -41
  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 +189 -99
  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 -55
  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 -0
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +14 -5
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +68 -1
  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 +48 -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 +47 -0
  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 +4 -1
  198. package/scss/config/mixins/_ln-table.scss +51 -93
  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 +232 -38
  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 +39 -0
  209. package/scss/ln-ashlar.scss +8 -2
  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 -1103
  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 -168
  218. package/scss/components/_data-table.scss +0 -83
  219. package/scss/config/mixins/_data-table.scss +0 -346
@@ -1,96 +1,118 @@
1
- # ln-translations
2
-
3
- A zero-dependency, event-driven **Form Translation Coordinator** that manages inline multi-lingual inputs by dynamically cloning translatable fields on-demand.
4
-
5
- Instead of writing custom layout handlers to manage multi-language fields, this component intercepts locale additions and removals, duplicates translatable fields with appropriate naming conventions, and handles pre-rendered server payloads automatically.
6
-
7
- ---
8
-
9
- ## 🧭 Philosophy & Architecture
10
-
11
- 1. **Inline Field Cloning:** Translatable containers carrying `data-ln-translatable="field"` are treated as templates. When a language is activated, the coordinator clones the inner input/textarea, binds the target language value, and appends it beneath the original.
12
- 2. **Deterministic Name Generation:** To support clean form submission, cloned inputs automatically update their name attribute following standard nested arrays:
13
- - **Default Name**: `scope` becomes `trans[en][scope]` for English.
14
- - **Nested Name**: `items[5][title]` with prefix `items[5]` becomes `items[5][trans][en][title]`.
15
- 3. **Menu & Badge Coordination:** It coordinates locale-selector dropdowns and active language indicator badges using native HTML `<template>` nodes, keeping visual UI elements synchronized in real-time.
16
- 4. **Server-Rendered Auto-Detection:** If the server pre-renders localized fields with `data-ln-translatable-lang="{lang}"` on page load, the coordinator auto-detects and integrates them instantly.
17
-
18
- ---
19
-
20
- ## 📦 Minimal Blueprint
21
-
22
- ### Translatable Form Structure
23
- ```html
24
- <form data-ln-translations data-ln-translations-default="en">
25
- <header class="ln-translations__header">
26
- <h3>Form Content</h3>
27
- <!-- Active Language Badges -->
28
- <ul data-ln-translations-active></ul>
29
- <!-- Locale Add Menu (Dropdown coordinated) -->
30
- <div data-ln-dropdown>
31
- <button type="button" data-ln-translations-add data-ln-toggle-for="trans-menu">
32
- <svg class="ln-icon"><use href="#ln-world"></use></svg>
33
- </button>
34
- <ul id="trans-menu" data-ln-toggle data-ln-dropdown-menu></ul>
35
- </div>
36
- </header>
37
-
38
- <main>
39
- <div data-ln-translatable="description">
40
- <label>Description <textarea name="description">Acme scope...</textarea></label>
41
- </div>
42
- </main>
43
- </form>
44
-
45
- <!-- REQUIRED GLOBAL TEMPLATES (Declared once before body closing) -->
46
- <template data-ln-template="ln-translations-badge">
47
- <li>
48
- <p data-ln-translations-lang>
49
- <span></span>
50
- <button type="button" aria-label="Remove"><svg class="ln-icon ln-icon--sm" aria-hidden="true"><use href="#ln-x"></use></svg></button>
51
- </p>
52
- </li>
53
- </template>
54
- <template data-ln-template="ln-translations-menu-item">
55
- <li><button type="button" data-ln-translations-lang></button></li>
56
- </template>
57
- ```
58
-
59
- ---
60
-
61
- ## 🛠️ Declarative API Contract
62
-
63
- ### HTML Attributes
64
-
65
- | Attribute | Elements | Description |
66
- | :--- | :--- | :--- |
67
- | `data-ln-translations` | `<form>` | Component root. Initializes the translations coordinator. |
68
- | `data-ln-translations-default` | `<form>` | Default language code (e.g. `"en"`). Sets a flag on the original inputs. |
69
- | `data-ln-translations-locales` | `<form>` | Opt-in. Custom locales JSON list (e.g. `'{"en":"English", "de":"German"}'`). |
70
- | `data-ln-translations-add` | `<button>` | Trigger button inside `data-ln-dropdown` to open language menu. |
71
- | `data-ln-translations-active` | `<ul>` | Mount container where active language badges will be rendered. |
72
- | `data-ln-translatable="field"` | Form field wrapper | Marks a translatable group. Value is the entity's field name. |
73
- | `data-ln-translations-prefix` | Form field wrapper | Opt-in. Naming prefix (e.g. `"items[1]"`) for nested form layouts. |
74
- | `data-ln-translatable-lang` | `<input>`, `<textarea>` | Language code identifying a cloned or pre-rendered translation input. |
75
-
76
- ---
77
-
78
- ## ⚡ DOM Events
79
-
80
- ### Telemetry (Dispatched by component)
81
- - **`ln-translations:before-add`** / **`ln-translations:before-remove`** (Cancelable)
82
- - Detail: `{ target, lang, langName }`
83
- - **`ln-translations:added`** / **`ln-translations:removed`**
84
- - Detail: `{ target, lang }`
85
-
86
- ### Commands (Dispatched to component)
87
- - **`ln-translations:request-add`** / **`ln-translations:request-remove`**
88
- - Detail: `{ lang: string }` (dispatched on the `<form>` to programmatically toggle languages).
89
-
90
- ---
91
-
92
- ## ⚠️ Common Pitfalls
93
-
94
- - **Forgetting Global Templates:** The coordinator will fail to render badges or locale dropdowns if `ln-translations-badge` and `ln-translations-menu-item` templates are missing from the page.
95
- - **Incorrect Translatable Wrappers:** `data-ln-translatable` must sit on the parent container (e.g. `<div data-ln-translatable="title">`) wrapping the default `<input>`/`<label>`, not on the input itself.
96
- - **Mismatched Prefixes:** When nesting entities (e.g., repeating list items), ensure the translatable wrapper declares the correct scope prefix: `data-ln-translations-prefix="items[index]"` to generate valid submission structures.
1
+ # ln-translations
2
+
3
+ A zero-dependency, event-driven **Form Translation Coordinator** that manages inline multi-lingual inputs by dynamically cloning translatable fields on-demand.
4
+
5
+ Instead of writing custom layout handlers to manage multi-language fields, this component intercepts locale additions and removals, duplicates translatable fields with appropriate naming conventions, and handles pre-rendered server payloads automatically.
6
+
7
+ ---
8
+
9
+ ## 🧭 Philosophy & Architecture
10
+
11
+ 1. **Inline Field Cloning:** Translatable containers carrying `data-ln-translatable="field"` are treated as templates. When a language is activated, the coordinator clones the inner input/textarea/select, binds the target language value, and appends it beneath the original.
12
+ 2. **Deterministic Name Generation:** To support clean form submission, cloned inputs automatically update their name attribute following standard nested arrays:
13
+ - **Default Name**: `scope` becomes `trans[en][scope]` for English.
14
+ - **Nested Name**: `items[5][title]` with prefix `items[5]` becomes `items[5][trans][en][title]`.
15
+ 3. **Menu & Badge Coordination:** It coordinates locale-selector dropdowns and active language indicator badges using native HTML `<template>` nodes, keeping visual UI elements synchronized in real-time.
16
+ 4. **Server-Rendered Auto-Detection:** If the server pre-renders localized fields with `data-ln-translatable-lang="{lang}"` on page load, the coordinator auto-detects and integrates them instantly.
17
+
18
+ ---
19
+
20
+ ## 📦 Minimal Blueprint
21
+
22
+ ### Translatable Form Structure
23
+ ```html
24
+ <form data-ln-translations data-ln-translations-default="en">
25
+ <header class="ln-translations__header">
26
+ <h3>Form Content</h3>
27
+ <!-- Active Language Badges -->
28
+ <ul data-ln-translations-active></ul>
29
+ <!-- Locale Add Menu (Dropdown coordinated) -->
30
+ <div data-ln-dropdown>
31
+ <button type="button" data-ln-translations-add data-ln-toggle-for="trans-menu">
32
+ <svg class="ln-icon"><use href="#ln-icon-world"></use></svg>
33
+ </button>
34
+ <ul id="trans-menu" data-ln-toggle data-ln-dropdown-menu></ul>
35
+ </div>
36
+ </header>
37
+
38
+ <main>
39
+ <div data-ln-translatable="description">
40
+ <label>Description <textarea name="description">Acme scope...</textarea></label>
41
+ </div>
42
+ </main>
43
+ </form>
44
+
45
+ <!-- REQUIRED GLOBAL TEMPLATES (Declared once before body closing) -->
46
+ <template data-ln-template="ln-translations-badge">
47
+ <li>
48
+ <p data-ln-translations-lang>
49
+ <span></span>
50
+ <button type="button" aria-label="Remove"><svg class="ln-icon ln-icon--sm" aria-hidden="true"><use href="#ln-icon-x"></use></svg></button>
51
+ </p>
52
+ </li>
53
+ </template>
54
+ <template data-ln-template="ln-translations-menu-item">
55
+ <li><button type="button" data-ln-translations-lang></button></li>
56
+ </template>
57
+ ```
58
+
59
+ ---
60
+
61
+ ## 🛠️ Declarative API Contract
62
+
63
+ ### HTML Attributes
64
+
65
+ | Attribute | Elements | Description |
66
+ | :--- | :--- | :--- |
67
+ | `data-ln-translations` | `<form>` | Component root. Initializes the translations coordinator. |
68
+ | `data-ln-translations-default` | `<form>` | Default language code (e.g. `"en"`). Sets a flag on the original inputs. |
69
+ | `data-ln-translations-locales` | `<form>` | Opt-in. Custom locales JSON list (e.g. `'{"en":"English", "de":"German"}'`). |
70
+ | `data-ln-translations-add` | `<button>` | Trigger button inside `data-ln-dropdown` to open language menu. |
71
+ | `data-ln-translations-active` | `<ul>` | Mount container where active language badges will be rendered. |
72
+ | `data-ln-translations-placeholder` | `<form>` | Opt-in. Placeholder template for cloned inputs, e.g. `"{lang} translation"`. `{lang}` is replaced with the language display name. Default: `"{lang} translation"`. |
73
+ | `data-ln-translations-remove-label` | `<form>` | Opt-in. `aria-label` template for the badge remove button, e.g. `"Remove {lang}"`. `{lang}` is replaced with the language display name. Default: `"Remove {lang}"`. |
74
+ | `data-ln-translatable="field"` | Form field wrapper | Marks a translatable group. Value is the entity's field name. |
75
+ | `data-ln-translations-prefix` | Form field wrapper | Opt-in. Naming prefix (e.g. `"items[1]"`) for nested form layouts. |
76
+ | `data-ln-translatable-lang` | `<input>`, `<textarea>`, `<select>` | Language code identifying a cloned or pre-rendered translation input. |
77
+
78
+ ---
79
+
80
+ ## ⚡ DOM Events
81
+
82
+ ### Telemetry (Dispatched by component)
83
+ - **`ln-translations:before-add`** / **`ln-translations:before-remove`** (Cancelable)
84
+ - Detail: `{ target, lang, langName }`
85
+ - **`ln-translations:added`** / **`ln-translations:removed`**
86
+ - Detail: `{ target, lang }`
87
+
88
+ ### Commands (Dispatched to component)
89
+ - **`ln-translations:request-add`** / **`ln-translations:request-remove`**
90
+ - Detail: `{ lang: string }` (dispatched on the `<form>` to programmatically toggle languages).
91
+
92
+ ---
93
+
94
+ ## ⚠️ Common Pitfalls
95
+
96
+ - **Forgetting Global Templates:** The coordinator will fail to render badges or locale dropdowns if `ln-translations-badge` and `ln-translations-menu-item` templates are missing from the page.
97
+ - **Incorrect Translatable Wrappers:** `data-ln-translatable` must sit on the parent container (e.g. `<div data-ln-translatable="title">`) wrapping the default `<input>`/`<label>`, not on the input itself.
98
+ - **Mismatched Prefixes:** When nesting entities (e.g., repeating list items), ensure the translatable wrapper declares the correct scope prefix: `data-ln-translations-prefix="items[index]"` to generate valid submission structures.
99
+
100
+ ---
101
+
102
+ ## 🔧 Internals
103
+
104
+ Source: `js/ln-translations/ln-translations.js`. Instance state per `[data-ln-translations]` form (`form.lnTranslations`): `activeLanguages` (Set), `defaultLang`, `locales` map, `badgesEl`/`menuEl` refs, and the `placeholderLabel`/`removeLabel` templates (`{lang}` token).
105
+
106
+ ### Add-language flow
107
+
108
+ `addLanguage(lang, values)`: cancelable `:before-add` → add to `activeLanguages` → for every `[data-ln-translatable]` wrapper, clone the original input (deep clone for `<select>` to preserve its `<option>`s, shallow otherwise), rename to `prefix[trans][lang][field]` or `trans[lang][field]`, strip `id`, apply the placeholder template, tag `data-ln-translatable-lang`, insert after the last existing translation input for that field → rebuild the dropdown (only unactivated languages) and badges → `:added`.
109
+
110
+ Original (default-language) inputs are tagged `data-ln-translatable-lang="{defaultLang}"` at init specifically so `removeLanguage` never matches and removes them.
111
+
112
+ ### Remove-language flow
113
+
114
+ `removeLanguage(lang)`: cancelable `:before-remove` → `querySelectorAll('[data-ln-translatable-lang="{lang}"]')` removed from DOM → delete from `activeLanguages` → rebuild dropdown/badges → `:removed`.
115
+
116
+ ### MutationObserver
117
+
118
+ A single shared observer (via `registerComponent`) watches `childList` for new `[data-ln-translations]` forms and `attributes` for the attribute landing on an existing element — no per-instance observer.
@@ -1 +1 @@
1
- (function(){"use strict";const b={};function A(e,a){b[e]||(b[e]=document.querySelector('[data-ln-template="'+e+'"]'));const d=b[e];return d?d.content.cloneNode(!0):(console.warn("["+a+'] Template "'+e+'" not found'),null)}function w(e,a,d){e.dispatchEvent(new CustomEvent(a,{bubbles:!0,detail:d||{}}))}function q(e,a,d){const r=new CustomEvent(a,{bubbles:!0,cancelable:!0,detail:d||{}});return e.dispatchEvent(r),r}function E(e,a){if(!document.body){document.addEventListener("DOMContentLoaded",function(){E(e,a)}),console.warn("["+a+'] Script loaded before <body> — add "defer" to your <script> tag');return}e()}function m(e,a,d,r){if(e.nodeType!==1)return;const s=a.indexOf("[")!==-1||a.indexOf(".")!==-1||a.indexOf("#")!==-1?a:"["+a+"]",o=Array.from(e.querySelectorAll(s));e.matches&&e.matches(s)&&o.push(e);for(const n of o)n[d]||(n[d]=new r(n))}function _(e,a,d,r,t={}){const s=t.extraAttributes||[],o=t.onAttributeChange||null,n=t.onInit||null;function c(u){const l=u||document.body;m(l,e,a,d),n&&n(l)}return E(function(){const u=new MutationObserver(function(p){for(let h=0;h<p.length;h++){const i=p[h];if(i.type==="childList"){for(let g=0;g<i.addedNodes.length;g++){const f=i.addedNodes[g];f.nodeType===1&&(m(f,e,a,d),n&&n(f))}for(let g=0;g<i.removedNodes.length;g++){const f=i.removedNodes[g];if(f.nodeType===1){const C=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",y=Array.from(f.querySelectorAll(C));f.matches&&f.matches(C)&&y.push(f);for(let v=0;v<y.length;v++){const x=y[v];if(!document.contains(x)){const L=x[a];L&&typeof L.destroy=="function"&&L.destroy()}}}}}else i.type==="attributes"&&(o&&i.target[a]?o(i.target,i.attributeName):(m(i.target,e,a,d),n&&n(i.target)))}});let l=[];if(e.indexOf("[")!==-1){const p=/\[([\w-]+)/g;let h;for(;(h=p.exec(e))!==null;)l.push(h[1])}else l.push(e);u.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:l.concat(s)})},r),window[a]=c,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){c(document.body)}):c(document.body),c}const S={};function D(e,a){S[e]=a}function O(e){return S[e]||{ingress:a=>a,egress:a=>a}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=D,window.lnCore.getDataMapper=O),(function(){const e="data-ln-translations",a="lnTranslations";if(window[a]!==void 0)return;const d={en:"English",sq:"Shqip",sr:"Srpski"};function r(t){this.dom=t,this.activeLanguages=new Set,this.defaultLang=t.getAttribute(e+"-default")||"",this.badgesEl=t.querySelector("["+e+"-active]"),this.menuEl=t.querySelector("[data-ln-dropdown] > [data-ln-toggle]");const s=t.getAttribute(e+"-locales");if(this.locales=d,s)try{this.locales=JSON.parse(s)}catch{console.warn("[ln-translations] Invalid JSON in data-ln-translations-locales")}this._applyDefaultLang(),this._updateDropdown();const o=this;return this._onRequestAdd=function(n){n.detail&&n.detail.lang&&o.addLanguage(n.detail.lang)},this._onRequestRemove=function(n){n.detail&&n.detail.lang&&o.removeLanguage(n.detail.lang)},t.addEventListener("ln-translations:request-add",this._onRequestAdd),t.addEventListener("ln-translations:request-remove",this._onRequestRemove),this._detectExisting(),this}r.prototype._applyDefaultLang=function(){if(!this.defaultLang)return;const t=this.dom.querySelectorAll("[data-ln-translatable]");for(const s of t){const o=s.querySelectorAll("input:not([data-ln-translatable-lang]), textarea:not([data-ln-translatable-lang]), select:not([data-ln-translatable-lang])");for(const n of o)n.setAttribute("data-ln-translatable-lang",this.defaultLang)}},r.prototype._detectExisting=function(){const t=this.dom.querySelectorAll("[data-ln-translatable-lang]");for(const s of t){const o=s.getAttribute("data-ln-translatable-lang");o&&o!==this.defaultLang&&this.activeLanguages.add(o)}this.activeLanguages.size>0&&(this._updateBadges(),this._updateDropdown())},r.prototype._updateDropdown=function(){if(!this.menuEl)return;this.menuEl.textContent="";const t=this;let s=0;for(const n in this.locales){if(!this.locales.hasOwnProperty(n)||this.activeLanguages.has(n))continue;s++;const c=A("ln-translations-menu-item","ln-translations");if(!c)return;const u=c.querySelector("[data-ln-translations-lang]");u.setAttribute("data-ln-translations-lang",n),u.textContent=this.locales[n],u.addEventListener("click",function(l){l.ctrlKey||l.metaKey||l.button===1||(l.preventDefault(),l.stopPropagation(),t.menuEl.getAttribute("data-ln-toggle")==="open"&&t.menuEl.setAttribute("data-ln-toggle","close"),t.addLanguage(n))}),this.menuEl.appendChild(c)}const o=this.dom.querySelector("["+e+"-add]");o&&(o.style.display=s===0?"none":"")},r.prototype._updateBadges=function(){if(!this.badgesEl)return;this.badgesEl.textContent="";const t=this;this.activeLanguages.forEach(function(s){const o=A("ln-translations-badge","ln-translations");if(!o)return;const n=o.querySelector("[data-ln-translations-lang]");n.setAttribute("data-ln-translations-lang",s);const c=n.querySelector("span");c.textContent=t.locales[s]||s.toUpperCase();const u=n.querySelector("button");u.setAttribute("aria-label","Remove "+(t.locales[s]||s.toUpperCase())),u.addEventListener("click",function(l){l.ctrlKey||l.metaKey||l.button===1||(l.preventDefault(),l.stopPropagation(),t.removeLanguage(s))}),t.badgesEl.appendChild(o)})},r.prototype.addLanguage=function(t,s){if(this.activeLanguages.has(t))return;const o=this.locales[t]||t;if(q(this.dom,"ln-translations:before-add",{target:this.dom,lang:t,langName:o}).defaultPrevented)return;this.activeLanguages.add(t),s=s||{};const c=this.dom.querySelectorAll("[data-ln-translatable]");for(const u of c){const l=u.getAttribute("data-ln-translatable"),p=u.getAttribute("data-ln-translations-prefix")||"",h=u.querySelector(this.defaultLang?'[data-ln-translatable-lang="'+this.defaultLang+'"]':"input:not([data-ln-translatable-lang]), textarea:not([data-ln-translatable-lang]), select:not([data-ln-translatable-lang])");if(!h)continue;const i=h.cloneNode(!1);p?i.name=p+"[trans]["+t+"]["+l+"]":i.name="trans["+t+"]["+l+"]",i.value=s[l]!==void 0?s[l]:"",i.removeAttribute("id"),i.placeholder=o+" translation",i.setAttribute("data-ln-translatable-lang",t);const g=u.querySelectorAll('[data-ln-translatable-lang]:not([data-ln-translatable-lang="'+this.defaultLang+'"])'),f=g.length>0?g[g.length-1]:h;f.parentNode.insertBefore(i,f.nextSibling)}this._updateDropdown(),this._updateBadges(),w(this.dom,"ln-translations:added",{target:this.dom,lang:t,langName:o})},r.prototype.removeLanguage=function(t){if(!this.activeLanguages.has(t)||q(this.dom,"ln-translations:before-remove",{target:this.dom,lang:t}).defaultPrevented)return;const o=this.dom.querySelectorAll('[data-ln-translatable-lang="'+t+'"]');for(const n of o)n.parentNode.removeChild(n);this.activeLanguages.delete(t),this._updateDropdown(),this._updateBadges(),w(this.dom,"ln-translations:removed",{target:this.dom,lang:t})},r.prototype.getActiveLanguages=function(){return new Set(this.activeLanguages)},r.prototype.hasLanguage=function(t){return this.activeLanguages.has(t)},r.prototype.destroy=function(){if(!this.dom[a])return;const t=this.defaultLang,s=this.dom.querySelectorAll("[data-ln-translatable-lang]");for(const o of s)o.getAttribute("data-ln-translatable-lang")!==t&&o.parentNode.removeChild(o);this.dom.removeEventListener("ln-translations:request-add",this._onRequestAdd),this.dom.removeEventListener("ln-translations:request-remove",this._onRequestRemove),delete this.dom[a]},_(e,a,r,"ln-translations")})()})();
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 b={};function m(t,e){b[t]||(b[t]=document.querySelector('[data-ln-template="'+t+'"]'));const i=b[t];return i?i.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function A(t,e,i){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:i||{}}))}function S(t,e,i){const c=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:i||{}});return t.dispatchEvent(c),c}function E(t,e){if(!t||!e)return t;const i=t.querySelectorAll("[data-ln-field]");for(let n=0;n<i.length;n++){const l=i[n],s=l.getAttribute("data-ln-field");e[s]!=null&&(l.textContent=e[s])}const c=t.querySelectorAll("[data-ln-attr]");for(let n=0;n<c.length;n++){const l=c[n],s=l.getAttribute("data-ln-attr").split(",");for(let r=0;r<s.length;r++){const d=s[r].trim().split(":");if(d.length!==2)continue;const u=d[0].trim(),f=d[1].trim();e[f]!=null&&l.setAttribute(u,e[f])}}const a=t.querySelectorAll("[data-ln-show]");for(let n=0;n<a.length;n++){const l=a[n],s=l.getAttribute("data-ln-show");s in e&&l.classList.toggle("hidden",!e[s])}const o=t.querySelectorAll("[data-ln-class]");for(let n=0;n<o.length;n++){const l=o[n],s=l.getAttribute("data-ln-class").split(",");for(let r=0;r<s.length;r++){const d=s[r].trim().split(":");if(d.length!==2)continue;const u=d[0].trim(),f=d[1].trim();f in e&&l.classList.toggle(u,!!e[f])}}return t}function R(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const i=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let c=0;c<i.length;c++)i[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)E(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let i=0;i<e.length;i++)e[i].textContent=""}})));function x(t,e){if(!t||!e)return t;const i=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;i.nextNode();){const o=i.currentNode;o.textContent.indexOf("{{")!==-1&&(o.textContent=o.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(n,l){return e[l]!==void 0?e[l]:""}))}const c=function(o,n){return e[n]!==void 0?e[n]:""},a=Array.from(t.querySelectorAll("*"));t.nodeType===1&&a.push(t);for(let o=0;o<a.length;o++){const n=a[o],l=n.attributes;for(let s=0;s<l.length;s++){const r=l[s];r.value.indexOf("{{")!==-1&&n.setAttribute(r.name,r.value.replace(/\{\{\s*(\w+)\s*\}\}/g,c))}}return t}function B(t,e,i,c,a,o){const n={};for(let s=0;s<t.children.length;s++){const r=t.children[s],d=r.getAttribute("data-ln-key");d&&(n[d]=r)}const l=document.createDocumentFragment();for(let s=0;s<e.length;s++){const r=e[s],d=String(c(r));let u=n[d];if(u)a(u,r,s);else{const f=m(i,o);if(!f||(x(f,r),u=f.firstElementChild,!u))continue;u.setAttribute("data-ln-key",d),a(u,r,s)}l.appendChild(u)}t.textContent="",t.appendChild(l)}function q(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){q(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function y(t,e,i,c){if(t.nodeType!==1)return;const o=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",n=Array.from(t.querySelectorAll(o));t.matches&&t.matches(o)&&n.push(t);for(const l of n)l[i]||(l[i]=new c(l))}function M(t,e,i,c,a={}){const o=a.extraAttributes||[],n=a.onAttributeChange||null,l=a.onInit||null;function s(r){const d=r||document.body;y(d,t,e,i),l&&l(d)}return q(function(){const r=new MutationObserver(function(u){for(let f=0;f<u.length;f++){const h=u[f];if(h.type==="childList"){for(let p=0;p<h.addedNodes.length;p++){const g=h.addedNodes[p];g.nodeType===1&&(y(g,t,e,i),l&&l(g))}for(let p=0;p<h.removedNodes.length;p++){const g=h.removedNodes[p];if(g.nodeType===1){const N=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",L=Array.from(g.querySelectorAll(N));g.matches&&g.matches(N)&&L.push(g);for(let v=0;v<L.length;v++){const T=L[v];if(!document.contains(T)){const C=T[e];C&&typeof C.destroy=="function"&&C.destroy()}}}}}else h.type==="attributes"&&(n&&h.target[e]?n(h.target,h.attributeName):(y(h.target,t,e,i),l&&l(h.target)))}});let d=[];if(t.indexOf("[")!==-1){const u=/\[([\w-]+)/g;let f;for(;(f=u.exec(t))!==null;)d.push(f[1])}else d.push(t);r.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:d.concat(o)})},c),window[e]=s,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){s(document.body)}):s(document.body),s}const _={};function j(t,e){_[t]=e}function K(t){return _[t]||{ingress:e=>e,egress:e=>e}}const D={};function O(t,e){if(!t||typeof e!="object")return;const i=t.toLowerCase().split("-")[0];D[i]=e}function F(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return D[e]||null}O("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=j,window.lnCore.getDataMapper=K,window.lnCore.registerLocaleFallback=O,window.lnCore.getLocaleFallback=F,window.lnCore.fillTemplate=x,window.lnCore.fill=E,window.lnCore.lnFill=R,window.lnCore.renderList=B);function k(t){return(t||"").replace(/^#/,"")}function w(t){const e=t===void 0?location.hash:t,i={},c=k(e);if(!c)return i;const a=c.split("&");for(let o=0;o<a.length;o++){const n=a[o];if(!n)continue;const l=n.indexOf(":"),s=l>-1?n.slice(0,l):n,r=l>-1?n.slice(l+1):"";if(s)try{i[s]=decodeURIComponent(r)}catch{i[s]=r}}return i}function W(t){if(!t)return null;const e=w();return t in e?e[t]:null}function I(t,e){if(!t)return;const i=w();e==null?delete i[t]:i[t]=String(e);const a=Object.keys(i).map(function(o){const n=i[o];return n===""?o:o+":"+encodeURIComponent(n)}).join("&");k(location.hash)!==a&&(location.hash=a)}function P(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=w,window.lnCore.hashGet=W,window.lnCore.hashSet=I,window.lnCore.hashLinkClick=P),(function(){const t="data-ln-translations",e="lnTranslations";if(window[e]!==void 0)return;const i={en:"English",sq:"Shqip",sr:"Srpski"};function c(a){this.dom=a,this.activeLanguages=new Set,this.defaultLang=a.getAttribute(t+"-default")||"",this.placeholderLabel=a.getAttribute(t+"-placeholder")||"{lang} translation",this.removeLabel=a.getAttribute(t+"-remove-label")||"Remove {lang}",this.badgesEl=a.querySelector("["+t+"-active]"),this.menuEl=a.querySelector("[data-ln-dropdown] > [data-ln-toggle]");const o=a.getAttribute(t+"-locales");if(this.locales=i,o)try{this.locales=JSON.parse(o)}catch{console.warn("[ln-translations] Invalid JSON in data-ln-translations-locales")}this._applyDefaultLang(),this._updateDropdown();const n=this;return this._onRequestAdd=function(l){l.detail&&l.detail.lang&&n.addLanguage(l.detail.lang)},this._onRequestRemove=function(l){l.detail&&l.detail.lang&&n.removeLanguage(l.detail.lang)},a.addEventListener("ln-translations:request-add",this._onRequestAdd),a.addEventListener("ln-translations:request-remove",this._onRequestRemove),this._detectExisting(),this}c.prototype._applyDefaultLang=function(){if(!this.defaultLang)return;const a=this.dom.querySelectorAll("[data-ln-translatable]");for(const o of a){const n=o.querySelectorAll("input:not([data-ln-translatable-lang]), textarea:not([data-ln-translatable-lang]), select:not([data-ln-translatable-lang])");for(const l of n)l.setAttribute("data-ln-translatable-lang",this.defaultLang)}},c.prototype._detectExisting=function(){const a=this.dom.querySelectorAll("[data-ln-translatable-lang]");for(const o of a){const n=o.getAttribute("data-ln-translatable-lang");n&&n!==this.defaultLang&&this.activeLanguages.add(n)}this.activeLanguages.size>0&&(this._updateBadges(),this._updateDropdown())},c.prototype._updateDropdown=function(){if(!this.menuEl)return;this.menuEl.textContent="";const a=this;let o=0;for(const l in this.locales){if(!this.locales.hasOwnProperty(l)||this.activeLanguages.has(l))continue;o++;const s=m("ln-translations-menu-item","ln-translations");if(!s)return;const r=s.querySelector("[data-ln-translations-lang]");r.setAttribute("data-ln-translations-lang",l),r.textContent=this.locales[l],r.addEventListener("click",function(d){d.ctrlKey||d.metaKey||d.button===1||(d.preventDefault(),d.stopPropagation(),a.menuEl.getAttribute("data-ln-toggle")==="open"&&a.menuEl.setAttribute("data-ln-toggle","close"),a.addLanguage(l))}),this.menuEl.appendChild(s)}const n=this.dom.querySelector("["+t+"-add]");n&&(n.hidden=o===0)},c.prototype._updateBadges=function(){if(!this.badgesEl)return;this.badgesEl.textContent="";const a=this;this.activeLanguages.forEach(function(o){const n=m("ln-translations-badge","ln-translations");if(!n)return;const l=n.querySelector("[data-ln-translations-lang]");l.setAttribute("data-ln-translations-lang",o);const s=l.querySelector("span");s.textContent=a.locales[o]||o.toUpperCase();const r=l.querySelector("button"),d=a.locales[o]||o.toUpperCase();r.setAttribute("aria-label",a.removeLabel.replace("{lang}",d)),r.addEventListener("click",function(u){u.ctrlKey||u.metaKey||u.button===1||(u.preventDefault(),u.stopPropagation(),a.removeLanguage(o))}),a.badgesEl.appendChild(n)})},c.prototype.addLanguage=function(a,o){if(this.activeLanguages.has(a))return;const n=this.locales[a]||a;if(S(this.dom,"ln-translations:before-add",{target:this.dom,lang:a,langName:n}).defaultPrevented)return;this.activeLanguages.add(a),o=o||{};const s=this.dom.querySelectorAll("[data-ln-translatable]");for(const r of s){const d=r.getAttribute("data-ln-translatable"),u=r.getAttribute("data-ln-translations-prefix")||"",f=r.querySelector(this.defaultLang?'[data-ln-translatable-lang="'+this.defaultLang+'"]':"input:not([data-ln-translatable-lang]), textarea:not([data-ln-translatable-lang]), select:not([data-ln-translatable-lang])");if(!f)continue;const h=f.cloneNode(f.tagName==="SELECT");u?h.name=u+"[trans]["+a+"]["+d+"]":h.name="trans["+a+"]["+d+"]",h.value=o[d]!==void 0?o[d]:"",h.removeAttribute("id"),"placeholder"in h&&(h.placeholder=this.placeholderLabel.replace("{lang}",n)),h.setAttribute("data-ln-translatable-lang",a);const p=r.querySelectorAll('[data-ln-translatable-lang]:not([data-ln-translatable-lang="'+this.defaultLang+'"])'),g=p.length>0?p[p.length-1]:f;g.parentNode.insertBefore(h,g.nextSibling)}this._updateDropdown(),this._updateBadges(),A(this.dom,"ln-translations:added",{target:this.dom,lang:a,langName:n})},c.prototype.removeLanguage=function(a){if(!this.activeLanguages.has(a)||S(this.dom,"ln-translations:before-remove",{target:this.dom,lang:a}).defaultPrevented)return;const n=this.dom.querySelectorAll('[data-ln-translatable-lang="'+a+'"]');for(const l of n)l.parentNode.removeChild(l);this.activeLanguages.delete(a),this._updateDropdown(),this._updateBadges(),A(this.dom,"ln-translations:removed",{target:this.dom,lang:a})},c.prototype.getActiveLanguages=function(){return new Set(this.activeLanguages)},c.prototype.hasLanguage=function(a){return this.activeLanguages.has(a)},c.prototype.destroy=function(){if(!this.dom[e])return;const a=this.defaultLang,o=this.dom.querySelectorAll("[data-ln-translatable-lang]");for(const n of o)n.getAttribute("data-ln-translatable-lang")!==a&&n.parentNode.removeChild(n);this.dom.removeEventListener("ln-translations:request-add",this._onRequestAdd),this.dom.removeEventListener("ln-translations:request-remove",this._onRequestRemove),delete this.dom[e]},M(t,e,c,"ln-translations")})()})();
@@ -20,6 +20,8 @@ import { cloneTemplate, dispatch, dispatchCancelable, registerComponent } from '
20
20
  this.dom = dom;
21
21
  this.activeLanguages = new Set();
22
22
  this.defaultLang = dom.getAttribute(DOM_SELECTOR + '-default') || '';
23
+ this.placeholderLabel = dom.getAttribute(DOM_SELECTOR + '-placeholder') || '{lang} translation';
24
+ this.removeLabel = dom.getAttribute(DOM_SELECTOR + '-remove-label') || 'Remove {lang}';
23
25
  this.badgesEl = dom.querySelector('[' + DOM_SELECTOR + '-active]');
24
26
  this.menuEl = dom.querySelector('[data-ln-dropdown] > [data-ln-toggle]');
25
27
 
@@ -121,7 +123,7 @@ import { cloneTemplate, dispatch, dispatchCancelable, registerComponent } from '
121
123
  // Hide trigger if no languages available
122
124
  const triggerBtn = this.dom.querySelector('[' + DOM_SELECTOR + '-add]');
123
125
  if (triggerBtn) {
124
- triggerBtn.style.display = availableCount === 0 ? 'none' : '';
126
+ triggerBtn.hidden = availableCount === 0;
125
127
  }
126
128
  };
127
129
 
@@ -143,7 +145,8 @@ import { cloneTemplate, dispatch, dispatchCancelable, registerComponent } from '
143
145
  label.textContent = self.locales[lang] || lang.toUpperCase();
144
146
 
145
147
  const closeBtn = p.querySelector('button');
146
- closeBtn.setAttribute('aria-label', 'Remove ' + (self.locales[lang] || lang.toUpperCase()));
148
+ const removeLangName = self.locales[lang] || lang.toUpperCase();
149
+ closeBtn.setAttribute('aria-label', self.removeLabel.replace('{lang}', removeLangName));
147
150
 
148
151
  closeBtn.addEventListener('click', function (e) {
149
152
  if (e.ctrlKey || e.metaKey || e.button === 1) return;
@@ -184,7 +187,7 @@ import { cloneTemplate, dispatch, dispatchCancelable, registerComponent } from '
184
187
  );
185
188
  if (!original) continue;
186
189
 
187
- const clone = original.cloneNode(false);
190
+ const clone = original.cloneNode(original.tagName === 'SELECT');
188
191
 
189
192
  // Set name
190
193
  if (prefix) {
@@ -199,8 +202,10 @@ import { cloneTemplate, dispatch, dispatchCancelable, registerComponent } from '
199
202
  // Remove id to avoid duplicates
200
203
  clone.removeAttribute('id');
201
204
 
202
- // Set placeholder
203
- clone.placeholder = langName + ' translation';
205
+ // Set placeholder (selects have no placeholder property)
206
+ if ('placeholder' in clone) {
207
+ clone.placeholder = this.placeholderLabel.replace('{lang}', langName);
208
+ }
204
209
 
205
210
  // Mark with language attribute
206
211
  clone.setAttribute('data-ln-translatable-lang', lang);
@@ -2,13 +2,28 @@
2
2
 
3
3
  File upload component — drag-and-drop zone with XHR progress, client-side validation, and auto-rendered hidden inputs for form submit.
4
4
 
5
+ ## Rationale & Mindset
6
+
7
+ Historically, the component used custom attributes like `data-ln-upload-context` to pass extra parameters (such as the context category `lecture`, `email_logo`, etc.) to the upload endpoint. This created a **tight coupling** between the generic frontend library component and specific project-level backend parameter names.
8
+
9
+ To make the component truly generic and declarative, it follows a **native-HTML form design mindset**:
10
+ * **Inputs as Parameters**: Any parameter or metadata that needs to be sent alongside the uploaded file is defined using standard HTML form inputs (e.g. `<input type="hidden">`, `<select>`, `<textarea>`) nested directly inside the `.ln-upload` container.
11
+ * **Dynamic Gathering**: When a file is dropped or selected, the component dynamically serializes all nested inputs and appends them to the `FormData` request.
12
+ * **Separation of Concerns**: The frontend library does not need to know about specific keys (like `context` or `entity_id`). It simply forwards whatever form inputs you declare.
13
+
14
+ ### Benefits
15
+ 1. **Zero Configuration for Backend Keys**: No need to rewrite the JS component if the backend changes a parameter name from `context` to `type` or requires extra fields.
16
+ 2. **Dynamic Values**: If an input's value is modified by other scripts on the page prior to upload (e.g., dynamically setting an `entity_id` based on selection), the upload request will automatically receive the latest value at the moment of file dispatch.
17
+ 3. **Decoupled Delete Routes**: The component derives the `DELETE` endpoint dynamically by replacing `/upload` in the upload URL with the file ID, or you can specify a custom delete pattern using `data-ln-upload-delete="/my-api/{id}"`.
18
+
5
19
  ## Attributes
6
20
 
7
21
  | Attribute | On | Description |
8
22
  |-----------|-----|-------------|
9
23
  | `data-ln-upload="/files/upload"` | container | Upload URL (default: `/files/upload`) |
10
24
  | `data-ln-upload-accept=".pdf,.doc,.docx"` | container | Allowed extensions (comma-separated) |
11
- | `data-ln-upload-context="documents"` | container | Context string sent with upload (FormData `context` field) |
25
+ | `data-ln-upload-delete="/files/{id}"` | container | Optional custom delete URL pattern containing `{id}` (default: derived dynamically by replacing `/upload` with the file ID) |
26
+ | `data-ln-upload-context="documents"` | container | **[DEPRECATED]** Legacy context string sent as `context` fallback if no `<input name="context">` is nested. |
12
27
  | `data-ln-upload-dict="key"` | hidden element | I18n dictionary for messages (see below) |
13
28
 
14
29
  ## Dictionary (i18n)
@@ -57,7 +72,7 @@ Your template MUST include these elements for the component to function:
57
72
  | `<li>` root | `data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting"` | State classes toggled via `fill()` |
58
73
  | File name target | `data-ln-field="name"` | File name text |
59
74
  | Size/status target | `data-ln-field="sizeText"` | `"0%"` → `"45%"` → `"12.3 KB"` → `"Error"` |
60
- | File icon `<use>` | `data-ln-attr="href:iconHref"` | Auto-swapped to `#ln-file` / `#lnc-file-pdf` / `#lnc-file-doc` / `#lnc-file-epub` based on extension |
75
+ | File icon `<use>` | `data-ln-attr="href:iconHref"` | Auto-swapped to `#ln-icon-file` / `#ln-icon-custom-file-pdf` / `#ln-icon-custom-file-doc` / `#ln-icon-custom-file-epub` based on extension |
61
76
  | Remove button | `data-ln-upload-action="remove"` and `data-ln-attr="aria-label:removeLabel, title:removeLabel"` | Click target (attribute-based, not class-based) |
62
77
  | Progress bar | `class="ln-upload__progress-bar"` | Width is animated imperatively via inline style |
63
78
 
@@ -67,13 +82,13 @@ Your template MUST include these elements for the component to function:
67
82
  <div data-ln-upload="/files/upload">
68
83
  <template data-ln-template="ln-upload-item">
69
84
  <li class="ln-upload__item" data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">
70
- <svg class="ln-icon ln-icon--lg" aria-hidden="true"><use data-ln-attr="href:iconHref" href="#ln-file"></use></svg>
85
+ <svg class="ln-icon ln-icon--lg" aria-hidden="true"><use data-ln-attr="href:iconHref" href="#ln-icon-file"></use></svg>
71
86
  <article>
72
87
  <span class="ln-upload__name" data-ln-field="name"></span>
73
88
  <span class="ln-upload__size" data-ln-field="sizeText"></span>
74
89
  </article>
75
90
  <button type="button" class="ln-upload__remove" data-ln-upload-action="remove" data-ln-attr="aria-label:removeLabel, title:removeLabel">
76
- <svg class="ln-icon" aria-hidden="true"><use href="#ln-x"></use></svg>
91
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
77
92
  </button>
78
93
  <div class="ln-upload__progress"><div class="ln-upload__progress-bar"></div></div>
79
94
  </li>
@@ -129,8 +144,16 @@ Expected status: `200`
129
144
 
130
145
  ## HTML Structure
131
146
 
147
+ ### Recommended (New Declarative Approach)
148
+
149
+ Place nested hidden inputs (or any form fields) representing your upload metadata inside the container:
150
+
132
151
  ```html
133
- <div data-ln-upload="/files/upload" data-ln-upload-accept=".pdf,.doc,.docx" data-ln-upload-context="documents">
152
+ <div class="ln-upload" data-ln-upload="/files/upload" data-ln-upload-accept=".pdf,.doc,.docx">
153
+ <!-- Extra parameters sent dynamically to the POST endpoint -->
154
+ <input type="hidden" name="context" value="documents">
155
+ <input type="hidden" name="entity_id" value="123">
156
+
134
157
  <div class="ln-upload__zone">
135
158
  <p>Drag files here or click to browse</p>
136
159
  </div>
@@ -140,15 +163,26 @@ Expected status: `200`
140
163
  </div>
141
164
  ```
142
165
 
166
+ ### Legacy (Attribute-Based Approach)
167
+
168
+ ```html
169
+ <div class="ln-upload" data-ln-upload="/files/upload" data-ln-upload-accept=".pdf,.doc,.docx" data-ln-upload-context="documents">
170
+ <div class="ln-upload__zone">
171
+ <p>Drag files here or click to browse</p>
172
+ </div>
173
+ <ul class="ln-upload__list"></ul>
174
+ </div>
175
+ ```
176
+
143
177
  ## File Icons
144
178
 
145
179
  The component automatically adds an SVG icon per file type using the icon loader:
146
- - `#lnc-file-pdf` — PDF (custom CDN)
147
- - `#lnc-file-doc` — DOC/DOCX (custom CDN)
148
- - `#lnc-file-epub` — EPUB (custom CDN)
149
- - `#ln-file` — all other types (Tabler CDN)
180
+ - `#ln-icon-custom-file-pdf` — PDF (custom CDN)
181
+ - `#ln-icon-custom-file-doc` — DOC/DOCX (custom CDN)
182
+ - `#ln-icon-custom-file-epub` — EPUB (custom CDN)
183
+ - `#ln-icon-file` — all other types (Tabler CDN)
150
184
 
151
- Custom icons require `window.LN_ICONS_CUSTOM_CDN` to be set. See `js/ln-icons/README.md`.
185
+ Custom icons require `window.LN_ICON_CUSTOM_CDN` to be set. See `js/ln-icon/README.md`.
152
186
 
153
187
  ## Hidden Inputs
154
188
 
@@ -178,3 +212,44 @@ For reference, active development, or customization, the component's codebase is
178
212
 
179
213
  * **Active Development Source (ESM)**: The primary development file where all component features, drag-and-drop mechanics, validation, and XHR progress logic are implemented is [js/ln-upload/src/ln-upload.js](file:///c:/laragon/www/ln-ashlar/js/ln-upload/src/ln-upload.js).
180
214
  * **Compiled Standalone (IIFE)**: The built zero-dependency distribution version compiled for browser execution is [js/ln-upload/ln-upload.js](file:///c:/laragon/www/ln-ashlar/js/ln-upload/ln-upload.js).
215
+
216
+ ---
217
+
218
+ ## 🔧 Internals
219
+
220
+ Source: `js/ln-upload/ln-upload.js`. Each container has a closure-scoped `uploadedFiles` Map (`localId → { serverId, name, size }`) — not reactive; DOM is mutated directly as XHR events fire. `fileIdCounter` generates unique `localId` values (`'file-1'`, `'file-2'`, ...).
221
+
222
+ ### Init
223
+
224
+ `_initUpload(container)`, guarded by `container.lnUploadAPI` against double-init. `_ensureDefaultItemTemplate()` runs once per init and injects the default `<template>` into `<body>` if no `[data-ln-template="ln-upload-item"]` exists anywhere in the document (see template lookup order above). A hidden `<input type="file" multiple>` is created if none exists inside the container.
225
+
226
+ ### `addFile(file)` flow
227
+
228
+ 1. Validate extension against `data-ln-upload-accept`. Invalid → dispatch `ln-upload:invalid`, enqueue error toast, return.
229
+ 2. `cloneTemplateScoped` resolves the template (scoped → global → auto-injected default).
230
+ 3. `fill(li, {...})` populates name, `sizeText: '0%'`, icon `href`, `removeLabel`, and the `uploading`/`error`/`deleting` state classes in one pass — the `ln-icon` observer independently picks up the `<use href>` swap and fetches the sprite.
231
+ 4. `data-file-id` set (structural, not a `fill` slot); remove button disabled until upload completes.
232
+ 5. `<li>` appended to `.ln-upload__list`, XHR POST opened with `FormData` (nested container inputs serialized in).
233
+ 6. Progress: `progressBar.style.width` set imperatively + `fill(li, { sizeText: percent + '%' })`.
234
+ 7. 2xx: `fill` to final state, remove button re-enabled, entry stored in `uploadedFiles`, hidden inputs rebuilt, `ln-upload:uploaded` dispatched.
235
+ 8. Non-2xx / XHR error: progress forced to 100%, `fill(li, { sizeText: dict.error, error: true })`, `ln-upload:error` dispatched, error toast enqueued.
236
+
237
+ ### Remove / delete flow
238
+
239
+ Remove-button clicks are delegated on `.ln-upload__list`, resolved via `closest('[data-ln-upload-action="remove"]')` (works through nested `svg`/`use`). `removeFile(localId)`: if the file has no `serverId` yet (still uploading), it's removed from DOM + Map directly. If a `serverId` exists, `fill(item, { deleting: true })` then `DELETE /files/{serverId}` — success removes from DOM + Map and dispatches `ln-upload:removed`; failure (non-200 or network catch) reverts `deleting: false` and enqueues an error toast.
240
+
241
+ ### Hidden inputs
242
+
243
+ `updateHiddenInput()` fully rebuilds `<input type="hidden" name="file_ids[]">` from the current `uploadedFiles` Map after every upload and delete — form submit always reflects the current (not deleted) file set.
244
+
245
+ ### Auto-toast
246
+
247
+ Errors dispatch `ln-toast:enqueue` on `window`. This is a soft integration — `ln-upload` does not import `ln-toast`; the toast only renders if that component is present.
248
+
249
+ ### `destroy()`
250
+
251
+ Part of `container.lnUploadAPI`. Removes all listeners (zone, input, list), clears `uploadedFiles`, empties the `<ul>`, removes the init guard, deletes `lnUploadAPI`.
252
+
253
+ ### MutationObserver
254
+
255
+ Global observer on `document.body` detects `[data-ln-upload]` elements added to the DOM and calls `_initUpload()` on them.
@@ -1 +1 @@
1
- (function(){"use strict";const z={};function V(n,o){z[n]||(z[n]=document.querySelector('[data-ln-template="'+n+'"]'));const c=z[n];return c?c.content.cloneNode(!0):(console.warn("["+o+'] Template "'+n+'" not found'),null)}function E(n,o,c){n.dispatchEvent(new CustomEvent(o,{bubbles:!0,detail:c||{}}))}function A(n,o){if(!n||!o)return n;const c=n.querySelectorAll("[data-ln-field]");for(let s=0;s<c.length;s++){const f=c[s],p=f.getAttribute("data-ln-field");o[p]!=null&&(f.textContent=o[p])}const m=n.querySelectorAll("[data-ln-attr]");for(let s=0;s<m.length;s++){const f=m[s],p=f.getAttribute("data-ln-attr").split(",");for(let b=0;b<p.length;b++){const L=p[b].trim().split(":");if(L.length!==2)continue;const w=L[0].trim(),y=L[1].trim();o[y]!=null&&f.setAttribute(w,o[y])}}const _=n.querySelectorAll("[data-ln-show]");for(let s=0;s<_.length;s++){const f=_[s],p=f.getAttribute("data-ln-show");p in o&&f.classList.toggle("hidden",!o[p])}const M=n.querySelectorAll("[data-ln-class]");for(let s=0;s<M.length;s++){const f=M[s],p=f.getAttribute("data-ln-class").split(",");for(let b=0;b<p.length;b++){const L=p[b].trim().split(":");if(L.length!==2)continue;const w=L[0].trim(),y=L[1].trim();y in o&&f.classList.toggle(w,!!o[y])}}return n}function R(n,o){if(!document.body){document.addEventListener("DOMContentLoaded",function(){R(n,o)}),console.warn("["+o+'] Script loaded before <body> — add "defer" to your <script> tag');return}n()}function W(n,o,c){if(n){const m=n.querySelector('[data-ln-template="'+o+'"]');if(m)return m.content.cloneNode(!0)}return V(o,c)}function Z(n,o){const c={},m=n.querySelectorAll("["+o+"]");for(let _=0;_<m.length;_++)c[m[_].getAttribute(o)]=m[_].textContent,m[_].remove();return c}const N={};function Q(n,o){N[n]=o}function Y(n){return N[n]||{ingress:o=>o,egress:o=>o}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=Q,window.lnCore.getDataMapper=Y),(function(){const n="data-ln-upload",o="lnUpload",c="data-ln-upload-dict",m="data-ln-upload-accept",_="data-ln-upload-context",M='<template data-ln-template="ln-upload-item"><li class="ln-upload__item" data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting"><svg class="ln-icon" aria-hidden="true"><use data-ln-attr="href:iconHref" href="#ln-file"></use></svg><span class="ln-upload__name" data-ln-field="name"></span><span class="ln-upload__size" data-ln-field="sizeText"></span><button type="button" class="ln-upload__remove" data-ln-upload-action="remove" data-ln-attr="aria-label:removeLabel, title:removeLabel"><svg class="ln-icon" aria-hidden="true"><use href="#ln-x"></use></svg></button><div class="ln-upload__progress"><div class="ln-upload__progress-bar"></div></div></li></template>';function s(){if(document.querySelector('[data-ln-template="ln-upload-item"]')||!document.body)return;const t=document.createElement("div");t.innerHTML=M;const r=t.firstElementChild;r&&document.body.appendChild(r)}if(window[o]!==void 0)return;function f(t){if(t===0)return"0 B";const r=1024,l=["B","KB","MB","GB"],d=Math.floor(Math.log(t)/Math.log(r));return parseFloat((t/Math.pow(r,d)).toFixed(1))+" "+l[d]}function p(t){return t.split(".").pop().toLowerCase()}function b(t){return t==="docx"&&(t="doc"),["pdf","doc","epub"].includes(t)?"lnc-file-"+t:"ln-file"}function L(t,r){if(!r)return!0;const l="."+p(t.name);return r.split(",").map(function(C){return C.trim().toLowerCase()}).includes(l.toLowerCase())}function w(t){if(t.hasAttribute("data-ln-upload-initialized"))return;t.setAttribute("data-ln-upload-initialized","true"),s();const r=Z(t,c),l=t.querySelector(".ln-upload__zone"),d=t.querySelector(".ln-upload__list"),C=t.getAttribute(m)||"";if(!l||!d){console.warn("[ln-upload] Missing .ln-upload__zone or .ln-upload__list in container:",t);return}let v=t.querySelector('input[type="file"]');v||(v=document.createElement("input"),v.type="file",v.multiple=!0,v.classList.add("hidden"),C&&(v.accept=C.split(",").map(function(e){return e=e.trim(),e.startsWith(".")?e:"."+e}).join(",")),t.appendChild(v));const ee=t.getAttribute(n)||"/files/upload",te=t.getAttribute(_)||"",h=new Map;let ne=0;function F(){const e=document.querySelector('meta[name="csrf-token"]');return e?e.getAttribute("content"):""}function oe(e){if(!L(e,C)){const i=r["invalid-type"];E(t,"ln-upload:invalid",{file:e,message:i}),E(window,"ln-toast:enqueue",{type:"error",title:r["invalid-title"]||"Invalid File",message:i||r["invalid-type"]||"This file type is not allowed"});return}const a="file-"+ ++ne,u=p(e.name),q=b(u),G=W(t,"ln-upload-item","ln-upload");if(!G)return;const T=G.firstElementChild;if(!T)return;T.setAttribute("data-file-id",a),A(T,{name:e.name,sizeText:"0%",iconHref:"#"+q,removeLabel:r.remove||"Remove",uploading:!0,error:!1,deleting:!1});const O=T.querySelector(".ln-upload__progress-bar"),D=T.querySelector('[data-ln-upload-action="remove"]');D&&(D.disabled=!0),d.appendChild(T);const k=new FormData;k.append("file",e),k.append("context",te);const g=new XMLHttpRequest;g.upload.addEventListener("progress",function(i){if(i.lengthComputable){const I=Math.round(i.loaded/i.total*100);O.style.width=I+"%",A(T,{sizeText:I+"%"})}}),g.addEventListener("load",function(){if(g.status>=200&&g.status<300){let i;try{i=JSON.parse(g.responseText)}catch{x("Invalid response");return}A(T,{sizeText:f(i.size||e.size),uploading:!1}),D&&(D.disabled=!1),h.set(a,{serverId:i.id,name:i.name,size:i.size}),S(),E(t,"ln-upload:uploaded",{localId:a,serverId:i.id,name:i.name})}else{let i=r["upload-failed"]||"Upload failed";try{i=JSON.parse(g.responseText).message||i}catch{}x(i)}}),g.addEventListener("error",function(){x(r["network-error"]||"Network error")});function x(i){O&&(O.style.width="100%"),A(T,{sizeText:r.error||"Error",uploading:!1,error:!0}),D&&(D.disabled=!1),E(t,"ln-upload:error",{file:e,message:i}),E(window,"ln-toast:enqueue",{type:"error",title:r["error-title"]||"Upload Error",message:i||r["upload-failed"]||"Failed to upload file"})}g.open("POST",ee),g.setRequestHeader("X-CSRF-TOKEN",F()),g.setRequestHeader("Accept","application/json"),g.send(k)}function S(){for(const e of t.querySelectorAll('input[name="file_ids[]"]'))e.remove();for(const[,e]of h){const a=document.createElement("input");a.type="hidden",a.name="file_ids[]",a.value=e.serverId,t.appendChild(a)}}function re(e){const a=h.get(e),u=d.querySelector('[data-file-id="'+e+'"]');if(!a||!a.serverId){u&&u.remove(),h.delete(e),S();return}u&&A(u,{deleting:!0}),fetch("/files/"+a.serverId,{method:"DELETE",headers:{"X-CSRF-TOKEN":F(),Accept:"application/json"}}).then(function(q){q.status===200?(u&&u.remove(),h.delete(e),S(),E(t,"ln-upload:removed",{localId:e,serverId:a.serverId})):(u&&A(u,{deleting:!1}),E(window,"ln-toast:enqueue",{type:"error",title:r["delete-title"]||"Error",message:r["delete-error"]||"Failed to delete file"}))}).catch(function(q){console.warn("[ln-upload] Delete error:",q),u&&A(u,{deleting:!1}),E(window,"ln-toast:enqueue",{type:"error",title:r["network-error"]||"Network error",message:r["connection-error"]||"Could not connect to server"})})}function H(e){for(const a of e)oe(a);v.value=""}const B=function(){v.click()},P=function(){H(this.files)},U=function(e){e.preventDefault(),e.stopPropagation(),l.classList.add("ln-upload__zone--dragover")},j=function(e){e.preventDefault(),e.stopPropagation(),l.classList.add("ln-upload__zone--dragover")},X=function(e){e.preventDefault(),e.stopPropagation(),l.classList.remove("ln-upload__zone--dragover")},K=function(e){e.preventDefault(),e.stopPropagation(),l.classList.remove("ln-upload__zone--dragover"),H(e.dataTransfer.files)},J=function(e){const a=e.target.closest('[data-ln-upload-action="remove"]');if(!a||!d.contains(a)||a.disabled)return;const u=a.closest(".ln-upload__item");u&&re(u.getAttribute("data-file-id"))};l.addEventListener("click",B),v.addEventListener("change",P),l.addEventListener("dragenter",U),l.addEventListener("dragover",j),l.addEventListener("dragleave",X),l.addEventListener("drop",K),d.addEventListener("click",J),t.lnUploadAPI={getFileIds:function(){return Array.from(h.values()).map(function(e){return e.serverId})},getFiles:function(){return Array.from(h.values())},clear:function(){for(const[,e]of h)e.serverId&&fetch("/files/"+e.serverId,{method:"DELETE",headers:{"X-CSRF-TOKEN":F(),Accept:"application/json"}});h.clear(),d.innerHTML="",S(),E(t,"ln-upload:cleared",{})},destroy:function(){l.removeEventListener("click",B),v.removeEventListener("change",P),l.removeEventListener("dragenter",U),l.removeEventListener("dragover",j),l.removeEventListener("dragleave",X),l.removeEventListener("drop",K),d.removeEventListener("click",J),h.clear(),d.innerHTML="",S(),t.removeAttribute("data-ln-upload-initialized"),delete t.lnUploadAPI}}}function y(){for(const t of document.querySelectorAll("["+n+"]"))w(t)}function $(){R(function(){new MutationObserver(function(r){for(const l of r)if(l.type==="childList"){for(const d of l.addedNodes)if(d.nodeType===1){d.hasAttribute(n)&&w(d);for(const C of d.querySelectorAll("["+n+"]"))w(C)}}else l.type==="attributes"&&l.target.hasAttribute(n)&&w(l.target)}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[n]})},"ln-upload")}window[o]={init:w,initAll:y},$(),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",y):y()})()})();
1
+ (function(){"use strict";if(typeof window<"u"){const e=console.warn;console.warn=function(...t){typeof t[0]=="string"&&(t[0].startsWith("[ln-")||t[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||e.apply(console,t)}}const M={};function B(e,t){M[e]||(M[e]=document.querySelector('[data-ln-template="'+e+'"]'));const l=M[e];return l?l.content.cloneNode(!0):(console.warn("["+(t||"ln-core")+'] Template "'+e+'" not found'),null)}function E(e,t,l){e.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:l||{}}))}function A(e,t){if(!e||!t)return e;const l=e.querySelectorAll("[data-ln-field]");for(let r=0;r<l.length;r++){const s=l[r],i=s.getAttribute("data-ln-field");t[i]!=null&&(s.textContent=t[i])}const d=e.querySelectorAll("[data-ln-attr]");for(let r=0;r<d.length;r++){const s=d[r],i=s.getAttribute("data-ln-attr").split(",");for(let f=0;f<i.length;f++){const w=i[f].trim().split(":");if(w.length!==2)continue;const h=w[0].trim(),y=w[1].trim();t[y]!=null&&s.setAttribute(h,t[y])}}const p=e.querySelectorAll("[data-ln-show]");for(let r=0;r<p.length;r++){const s=p[r],i=s.getAttribute("data-ln-show");i in t&&s.classList.toggle("hidden",!t[i])}const m=e.querySelectorAll("[data-ln-class]");for(let r=0;r<m.length;r++){const s=m[r],i=s.getAttribute("data-ln-class").split(",");for(let f=0;f<i.length;f++){const w=i[f].trim().split(":");if(w.length!==2)continue;const h=w[0].trim(),y=w[1].trim();y in t&&s.classList.toggle(h,!!t[y])}}return e}function le(e,t){e.matches&&e.matches("[data-ln-form], [data-ln-fillable]")&&e.dispatchEvent(new CustomEvent("ln-fill",{detail:t??null,bubbles:!0}));const l=e.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let d=0;d<l.length;d++)l[d].dispatchEvent(new CustomEvent("ln-fill",{detail:t??null,bubbles:!0}));return e}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore._fillBound||(window.lnCore._fillBound=!0,document.addEventListener("ln-fill",function(e){if(!(!e.target.matches||!e.target.matches("[data-ln-fillable]")))if(e.detail)A(e.target,e.detail);else{const t=e.target.querySelectorAll("[data-ln-field]");for(let l=0;l<t.length;l++)t[l].textContent=""}})));function P(e,t){if(!e||!t)return e;const l=document.createTreeWalker(e,NodeFilter.SHOW_TEXT);for(;l.nextNode();){const m=l.currentNode;m.textContent.indexOf("{{")!==-1&&(m.textContent=m.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(r,s){return t[s]!==void 0?t[s]:""}))}const d=function(m,r){return t[r]!==void 0?t[r]:""},p=Array.from(e.querySelectorAll("*"));e.nodeType===1&&p.push(e);for(let m=0;m<p.length;m++){const r=p[m],s=r.attributes;for(let i=0;i<s.length;i++){const f=s[i];f.value.indexOf("{{")!==-1&&r.setAttribute(f.name,f.value.replace(/\{\{\s*(\w+)\s*\}\}/g,d))}}return e}function re(e,t,l,d,p,m){const r={};for(let i=0;i<e.children.length;i++){const f=e.children[i],w=f.getAttribute("data-ln-key");w&&(r[w]=f)}const s=document.createDocumentFragment();for(let i=0;i<t.length;i++){const f=t[i],w=String(d(f));let h=r[w];if(h)p(h,f,i);else{const y=B(l,m);if(!y||(P(y,f),h=y.firstElementChild,!h))continue;h.setAttribute("data-ln-key",w),p(h,f,i)}s.appendChild(h)}e.textContent="",e.appendChild(s)}function H(e,t){if(!document.body){document.addEventListener("DOMContentLoaded",function(){H(e,t)}),console.warn("["+t+'] Script loaded before <body> — add "defer" to your <script> tag');return}e()}function ie(e,t,l){if(e){const d=e.querySelector('[data-ln-template="'+t+'"]');if(d)return d.content.cloneNode(!0)}return B(t,l)}function ae(e,t){const l={},d=e.querySelectorAll("["+t+"]");for(let p=0;p<d.length;p++)l[d[p].getAttribute(t)]=d[p].textContent,d[p].remove();return l}const j={};function se(e,t){j[e]=t}function de(e){return j[e]||{ingress:t=>t,egress:t=>t}}const K={};function W(e,t){if(!e||typeof t!="object")return;const l=e.toLowerCase().split("-")[0];K[l]=t}function ce(e){if(!e)return null;const t=e.toLowerCase().split("-")[0];return K[t]||null}W("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=se,window.lnCore.getDataMapper=de,window.lnCore.registerLocaleFallback=W,window.lnCore.getLocaleFallback=ce,window.lnCore.fillTemplate=P,window.lnCore.fill=A,window.lnCore.lnFill=le,window.lnCore.renderList=re);function X(e){return(e||"").replace(/^#/,"")}function O(e){const t=e===void 0?location.hash:e,l={},d=X(t);if(!d)return l;const p=d.split("&");for(let m=0;m<p.length;m++){const r=p[m];if(!r)continue;const s=r.indexOf(":"),i=s>-1?r.slice(0,s):r,f=s>-1?r.slice(s+1):"";if(i)try{l[i]=decodeURIComponent(f)}catch{l[i]=f}}return l}function ue(e){if(!e)return null;const t=O();return e in t?t[e]:null}function fe(e,t){if(!e)return;const l=O();t==null?delete l[e]:l[e]=String(t);const p=Object.keys(l).map(function(m){const r=l[m];return r===""?m:m+":"+encodeURIComponent(r)}).join("&");X(location.hash)!==p&&(location.hash=p)}function pe(e){return e.button===1||e.ctrlKey||e.metaKey||e.shiftKey?!1:(e.preventDefault(),!0)}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.hashParse=O,window.lnCore.hashGet=ue,window.lnCore.hashSet=fe,window.lnCore.hashLinkClick=pe),(function(){const e="data-ln-upload",t="lnUpload",l="data-ln-upload-dict",d="data-ln-upload-accept",p="data-ln-upload-context",m='<template data-ln-template="ln-upload-item"><li class="ln-upload__item" data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting"><svg class="ln-icon" aria-hidden="true"><use data-ln-attr="href:iconHref" href="#ln-icon-file"></use></svg><span class="ln-upload__name" data-ln-field="name"></span><span class="ln-upload__size" data-ln-field="sizeText"></span><button type="button" class="ln-upload__remove" data-ln-upload-action="remove" data-ln-attr="aria-label:removeLabel, title:removeLabel"><svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg></button><div class="ln-upload__progress"><div class="ln-upload__progress-bar"></div></div></li></template>';function r(){if(document.querySelector('[data-ln-template="ln-upload-item"]')||!document.body)return;const o=document.createElement("div");o.innerHTML=m;const c=o.firstElementChild;c&&document.body.appendChild(c)}if(window[t]!==void 0)return;function s(o){if(o===0)return"0 B";const c=1024,u=["B","KB","MB","GB"],v=Math.floor(Math.log(o)/Math.log(c));return parseFloat((o/Math.pow(c,v)).toFixed(1))+" "+u[v]}function i(o){return o.split(".").pop().toLowerCase()}function f(o){return o==="docx"&&(o="doc"),["pdf","doc","epub"].includes(o)?"ln-icon-custom-file-"+o:"ln-icon-file"}function w(o,c){if(!c)return!0;const u="."+i(o.name);return c.split(",").map(function(S){return S.trim().toLowerCase()}).includes(u.toLowerCase())}function h(o){if(o.lnUploadAPI)return;r();const c=ae(o,l),u=o.querySelector(".ln-upload__zone"),v=o.querySelector(".ln-upload__list"),S=o.getAttribute(d)||"";if(!u||!v){console.warn("[ln-upload] Missing .ln-upload__zone or .ln-upload__list in container:",o);return}let _=o.querySelector('input[type="file"]');_||(_=document.createElement("input"),_.type="file",_.multiple=!0,_.classList.add("hidden"),S&&(_.accept=S.split(",").map(function(n){return n=n.trim(),n.startsWith(".")?n:"."+n}).join(",")),o.appendChild(_));const x=o.getAttribute(e)||"/files/upload",G=o.getAttribute(p)||"",J=o.getAttribute("data-ln-upload-delete")||(x.includes("/upload")?x.replace(/\/upload\/?$/,"/{id}"):x+"/{id}"),L=new Map;let ge=0;function z(){const n=document.querySelector('meta[name="csrf-token"]');return n?n.getAttribute("content"):""}function he(n){if(!w(n,S)){const a=c["invalid-type"];E(o,"ln-upload:invalid",{file:n,message:a}),E(window,"ln-toast:enqueue",{type:"error",title:c["invalid-title"]||"Invalid File",message:a||c["invalid-type"]||"This file type is not allowed"});return}const g="file-"+ ++ge,b=i(n.name),N=f(b),D=ie(o,"ln-upload-item","ln-upload");if(!D)return;const T=D.firstElementChild;if(!T)return;T.setAttribute("data-file-id",g),A(T,{name:n.name,sizeText:"0%",iconHref:"#"+N,removeLabel:c.remove||"Remove",uploading:!0,error:!1,deleting:!1});const U=T.querySelector(".ln-upload__progress-bar"),k=T.querySelector('[data-ln-upload-action="remove"]');k&&(k.disabled=!0),v.appendChild(T);const F=new FormData;F.append("file",n);const oe=new Set;o.querySelectorAll("input, select, textarea").forEach(function(a){if(a.name&&a.name!=="file_ids[]"&&a.type!=="file"){if((a.type==="checkbox"||a.type==="radio")&&!a.checked)return;F.append(a.name,a.value),oe.add(a.name)}}),!oe.has("context")&&G&&F.append("context",G);const C=new XMLHttpRequest;C.upload.addEventListener("progress",function(a){if(a.lengthComputable){const I=Math.round(a.loaded/a.total*100);U.style.width=I+"%",A(T,{sizeText:I+"%"})}}),C.addEventListener("load",function(){if(C.status>=200&&C.status<300){let a;try{a=JSON.parse(C.responseText)}catch{R("Invalid response");return}A(T,{sizeText:s(a.size||n.size),uploading:!1}),k&&(k.disabled=!1),L.set(g,{serverId:a.id,name:a.name,size:a.size}),q(),E(o,"ln-upload:uploaded",{localId:g,serverId:a.id,name:a.name})}else{let a=c["upload-failed"]||"Upload failed";try{a=JSON.parse(C.responseText).message||a}catch{}R(a)}}),C.addEventListener("error",function(){R(c["network-error"]||"Network error")});function R(a){U&&(U.style.width="100%"),A(T,{sizeText:c.error||"Error",uploading:!1,error:!0}),k&&(k.disabled=!1),E(o,"ln-upload:error",{file:n,message:a}),E(window,"ln-toast:enqueue",{type:"error",title:c["error-title"]||"Upload Error",message:a||c["upload-failed"]||"Failed to upload file"})}C.open("POST",x),C.setRequestHeader("X-CSRF-TOKEN",z()),C.setRequestHeader("Accept","application/json"),C.send(F)}function q(){for(const n of o.querySelectorAll('input[name="file_ids[]"]'))n.remove();for(const[,n]of L){const g=document.createElement("input");g.type="hidden",g.name="file_ids[]",g.value=n.serverId,o.appendChild(g)}}function ve(n){const g=L.get(n),b=v.querySelector('[data-file-id="'+n+'"]');if(!g||!g.serverId){b&&b.remove(),L.delete(n),q();return}b&&A(b,{deleting:!0});const N=J.replace("{id}",g.serverId);fetch(N,{method:"DELETE",headers:{"X-CSRF-TOKEN":z(),Accept:"application/json"}}).then(function(D){D.status===200?(b&&b.remove(),L.delete(n),q(),E(o,"ln-upload:removed",{localId:n,serverId:g.serverId})):(b&&A(b,{deleting:!1}),E(window,"ln-toast:enqueue",{type:"error",title:c["delete-title"]||"Error",message:c["delete-error"]||"Failed to delete file"}))}).catch(function(D){console.warn("[ln-upload] Delete error:",D),b&&A(b,{deleting:!1}),E(window,"ln-toast:enqueue",{type:"error",title:c["network-error"]||"Network error",message:c["connection-error"]||"Could not connect to server"})})}function V(n){for(const g of n)he(g);_.value=""}const Z=function(){_.click()},$=function(){V(this.files)},Q=function(n){n.preventDefault(),n.stopPropagation(),u.classList.add("ln-upload__zone--dragover")},Y=function(n){n.preventDefault(),n.stopPropagation(),u.classList.add("ln-upload__zone--dragover")},ee=function(n){n.preventDefault(),n.stopPropagation(),u.classList.remove("ln-upload__zone--dragover")},te=function(n){n.preventDefault(),n.stopPropagation(),u.classList.remove("ln-upload__zone--dragover"),V(n.dataTransfer.files)},ne=function(n){const g=n.target.closest('[data-ln-upload-action="remove"]');if(!g||!v.contains(g)||g.disabled)return;const b=g.closest(".ln-upload__item");b&&ve(b.getAttribute("data-file-id"))};u.addEventListener("click",Z),_.addEventListener("change",$),u.addEventListener("dragenter",Q),u.addEventListener("dragover",Y),u.addEventListener("dragleave",ee),u.addEventListener("drop",te),v.addEventListener("click",ne),o.lnUploadAPI={getFileIds:function(){return Array.from(L.values()).map(function(n){return n.serverId})},getFiles:function(){return Array.from(L.values())},clear:function(){for(const[,n]of L)if(n.serverId){const g=J.replace("{id}",n.serverId);fetch(g,{method:"DELETE",headers:{"X-CSRF-TOKEN":z(),Accept:"application/json"}})}L.clear(),v.innerHTML="",q(),E(o,"ln-upload:cleared",{})},destroy:function(){u.removeEventListener("click",Z),_.removeEventListener("change",$),u.removeEventListener("dragenter",Q),u.removeEventListener("dragover",Y),u.removeEventListener("dragleave",ee),u.removeEventListener("drop",te),v.removeEventListener("click",ne),L.clear(),v.innerHTML="",q(),delete o.lnUploadAPI}}}function y(){for(const o of document.querySelectorAll("["+e+"]"))h(o)}function me(){H(function(){new MutationObserver(function(c){for(const u of c)if(u.type==="childList"){for(const v of u.addedNodes)if(v.nodeType===1){v.hasAttribute(e)&&h(v);for(const S of v.querySelectorAll("["+e+"]"))h(S)}}else u.type==="attributes"&&u.target.hasAttribute(e)&&h(u.target)}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[e]})},"ln-upload")}window[t]={init:h,initAll:y},me(),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",y):y()})()})();