@livenetworks/ashlar 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +177 -0
  2. package/js/COMPONENTS.md +1102 -0
  3. package/js/index.js +41 -0
  4. package/js/ln-accordion/README.md +137 -0
  5. package/js/ln-accordion/ln-accordion.js +1 -0
  6. package/js/ln-accordion/src/ln-accordion.js +41 -0
  7. package/js/ln-ajax/README.md +91 -0
  8. package/js/ln-ajax/ln-ajax.js +1 -0
  9. package/js/ln-ajax/src/ln-ajax.js +277 -0
  10. package/js/ln-api-connector/README.md +150 -0
  11. package/js/ln-api-connector/ln-api-connector.js +1 -0
  12. package/js/ln-api-connector/src/ln-api-connector.js +265 -0
  13. package/js/ln-autoresize/README.md +80 -0
  14. package/js/ln-autoresize/ln-autoresize.js +1 -0
  15. package/js/ln-autoresize/src/ln-autoresize.js +47 -0
  16. package/js/ln-autosave/README.md +92 -0
  17. package/js/ln-autosave/ln-autosave.js +1 -0
  18. package/js/ln-autosave/src/ln-autosave.js +147 -0
  19. package/js/ln-circular-progress/README.md +161 -0
  20. package/js/ln-circular-progress/ln-circular-progress.js +1 -0
  21. package/js/ln-circular-progress/src/ln-circular-progress.js +133 -0
  22. package/js/ln-confirm/README.md +86 -0
  23. package/js/ln-confirm/_ln-confirm.scss +13 -0
  24. package/js/ln-confirm/ln-confirm.js +1 -0
  25. package/js/ln-confirm/src/ln-confirm.js +131 -0
  26. package/js/ln-core/crypto.js +83 -0
  27. package/js/ln-core/helpers.js +411 -0
  28. package/js/ln-core/index.js +5 -0
  29. package/js/ln-core/persist.js +71 -0
  30. package/js/ln-core/positioning.js +207 -0
  31. package/js/ln-core/reactive.js +74 -0
  32. package/js/ln-couchdb-connector/README.md +156 -0
  33. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -0
  34. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +348 -0
  35. package/js/ln-data-coordinator/README.md +165 -0
  36. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -0
  37. package/js/ln-data-coordinator/src/ln-data-coordinator.js +249 -0
  38. package/js/ln-data-store/README.md +94 -0
  39. package/js/ln-data-store/ln-data-store.js +1 -0
  40. package/js/ln-data-store/src/ln-data-store.js +699 -0
  41. package/js/ln-data-table/README.md +110 -0
  42. package/js/ln-data-table/ln-data-table.js +1 -0
  43. package/js/ln-data-table/ln-data-table.scss +10 -0
  44. package/js/ln-data-table/src/ln-data-table.js +1103 -0
  45. package/js/ln-date/README.md +151 -0
  46. package/js/ln-date/ln-date.js +1 -0
  47. package/js/ln-date/src/ln-date.js +442 -0
  48. package/js/ln-dropdown/README.md +117 -0
  49. package/js/ln-dropdown/ln-dropdown.js +1 -0
  50. package/js/ln-dropdown/ln-dropdown.scss +15 -0
  51. package/js/ln-dropdown/src/ln-dropdown.js +174 -0
  52. package/js/ln-external-links/README.md +341 -0
  53. package/js/ln-external-links/ln-external-links.js +1 -0
  54. package/js/ln-external-links/src/ln-external-links.js +116 -0
  55. package/js/ln-filter/README.md +99 -0
  56. package/js/ln-filter/ln-filter.js +1 -0
  57. package/js/ln-filter/ln-filter.scss +7 -0
  58. package/js/ln-filter/src/ln-filter.js +404 -0
  59. package/js/ln-form/README.md +101 -0
  60. package/js/ln-form/ln-form.js +1 -0
  61. package/js/ln-form/src/ln-form.js +199 -0
  62. package/js/ln-http/README.md +89 -0
  63. package/js/ln-http/ln-http.js +1 -0
  64. package/js/ln-http/src/ln-http.js +219 -0
  65. package/js/ln-icons/README.md +88 -0
  66. package/js/ln-icons/ln-icons.js +1 -0
  67. package/js/ln-icons/src/ln-icons.js +169 -0
  68. package/js/ln-link/README.md +303 -0
  69. package/js/ln-link/ln-link.js +1 -0
  70. package/js/ln-link/src/ln-link.js +196 -0
  71. package/js/ln-modal/README.md +154 -0
  72. package/js/ln-modal/ln-modal.js +1 -0
  73. package/js/ln-modal/ln-modal.scss +11 -0
  74. package/js/ln-modal/src/ln-modal.js +201 -0
  75. package/js/ln-nav/README.md +70 -0
  76. package/js/ln-nav/ln-nav.js +1 -0
  77. package/js/ln-nav/src/ln-nav.js +177 -0
  78. package/js/ln-number/README.md +122 -0
  79. package/js/ln-number/ln-number.js +1 -0
  80. package/js/ln-number/src/ln-number.js +302 -0
  81. package/js/ln-popover/README.md +127 -0
  82. package/js/ln-popover/ln-popover.js +1 -0
  83. package/js/ln-popover/src/ln-popover.js +288 -0
  84. package/js/ln-progress/README.md +442 -0
  85. package/js/ln-progress/ln-progress.js +1 -0
  86. package/js/ln-progress/src/ln-progress.js +150 -0
  87. package/js/ln-search/README.md +83 -0
  88. package/js/ln-search/ln-search.js +1 -0
  89. package/js/ln-search/ln-search.scss +7 -0
  90. package/js/ln-search/src/ln-search.js +114 -0
  91. package/js/ln-sortable/README.md +95 -0
  92. package/js/ln-sortable/ln-sortable.js +1 -0
  93. package/js/ln-sortable/src/ln-sortable.js +203 -0
  94. package/js/ln-table/README.md +101 -0
  95. package/js/ln-table/ln-table-sort.js +1 -0
  96. package/js/ln-table/ln-table.js +1 -0
  97. package/js/ln-table/ln-table.scss +11 -0
  98. package/js/ln-table/src/ln-table-sort.js +168 -0
  99. package/js/ln-table/src/ln-table.js +473 -0
  100. package/js/ln-tabs/README.md +137 -0
  101. package/js/ln-tabs/ln-tabs.js +1 -0
  102. package/js/ln-tabs/src/ln-tabs.js +171 -0
  103. package/js/ln-time/README.md +81 -0
  104. package/js/ln-time/ln-time.js +1 -0
  105. package/js/ln-time/src/ln-time.js +192 -0
  106. package/js/ln-toast/README.md +122 -0
  107. package/js/ln-toast/ln-toast.js +15 -0
  108. package/js/ln-toast/src/ln-toast.js +210 -0
  109. package/js/ln-toast/template.html +14 -0
  110. package/js/ln-toggle/README.md +137 -0
  111. package/js/ln-toggle/ln-toggle.js +1 -0
  112. package/js/ln-toggle/src/ln-toggle.js +139 -0
  113. package/js/ln-tooltip/README.md +58 -0
  114. package/js/ln-tooltip/ln-tooltip.js +1 -0
  115. package/js/ln-tooltip/ln-tooltip.scss +9 -0
  116. package/js/ln-tooltip/src/ln-tooltip.js +169 -0
  117. package/js/ln-translations/README.md +96 -0
  118. package/js/ln-translations/ln-translations.js +1 -0
  119. package/js/ln-translations/src/ln-translations.js +275 -0
  120. package/js/ln-upload/README.md +180 -0
  121. package/js/ln-upload/ln-upload.js +1 -0
  122. package/js/ln-upload/ln-upload.scss +20 -0
  123. package/js/ln-upload/src/ln-upload.js +407 -0
  124. package/js/ln-validate/README.md +108 -0
  125. package/js/ln-validate/ln-validate.js +1 -0
  126. package/js/ln-validate/src/ln-validate.js +160 -0
  127. package/package.json +55 -0
  128. package/scss/base/_global.scss +83 -0
  129. package/scss/base/_reset.scss +17 -0
  130. package/scss/base/_typography.scss +125 -0
  131. package/scss/components/_accordion.scss +34 -0
  132. package/scss/components/_ajax.scss +15 -0
  133. package/scss/components/_alert.scss +5 -0
  134. package/scss/components/_app-shell.scss +15 -0
  135. package/scss/components/_avatar.scss +6 -0
  136. package/scss/components/_breadcrumbs.scss +33 -0
  137. package/scss/components/_button.scss +20 -0
  138. package/scss/components/_card.scss +10 -0
  139. package/scss/components/_chip.scss +5 -0
  140. package/scss/components/_circular-progress.scss +29 -0
  141. package/scss/components/_confirm.scss +5 -0
  142. package/scss/components/_data-table.scss +83 -0
  143. package/scss/components/_dropdown.scss +25 -0
  144. package/scss/components/_empty-state.scss +22 -0
  145. package/scss/components/_form.scss +100 -0
  146. package/scss/components/_layout.scss +8 -0
  147. package/scss/components/_link.scss +11 -0
  148. package/scss/components/_ln-table.scss +60 -0
  149. package/scss/components/_loader.scss +6 -0
  150. package/scss/components/_modal.scss +20 -0
  151. package/scss/components/_nav.scss +9 -0
  152. package/scss/components/_page-header.scss +10 -0
  153. package/scss/components/_popover.scss +10 -0
  154. package/scss/components/_progress.scss +17 -0
  155. package/scss/components/_prose.scss +5 -0
  156. package/scss/components/_scrollbar.scss +32 -0
  157. package/scss/components/_sections.scss +12 -0
  158. package/scss/components/_sidebar.scss +5 -0
  159. package/scss/components/_stat-card.scss +5 -0
  160. package/scss/components/_status-badge.scss +4 -0
  161. package/scss/components/_stepper.scss +5 -0
  162. package/scss/components/_table.scss +19 -0
  163. package/scss/components/_tabs.scss +21 -0
  164. package/scss/components/_timeline.scss +14 -0
  165. package/scss/components/_toast.scss +41 -0
  166. package/scss/components/_toggle.scss +81 -0
  167. package/scss/components/_tooltip.scss +18 -0
  168. package/scss/components/_translations.scss +111 -0
  169. package/scss/components/_upload.scss +51 -0
  170. package/scss/config/_breakpoints.scss +72 -0
  171. package/scss/config/_density.scss +117 -0
  172. package/scss/config/_icons.scss +37 -0
  173. package/scss/config/_mixins.scss +13 -0
  174. package/scss/config/_theme.scss +216 -0
  175. package/scss/config/_tokens.scss +419 -0
  176. package/scss/config/mixins/_accordion.scss +52 -0
  177. package/scss/config/mixins/_ajax.scss +39 -0
  178. package/scss/config/mixins/_alert.scss +82 -0
  179. package/scss/config/mixins/_app-shell.scss +312 -0
  180. package/scss/config/mixins/_avatar.scss +109 -0
  181. package/scss/config/mixins/_borders.scss +36 -0
  182. package/scss/config/mixins/_breadcrumbs.scss +72 -0
  183. package/scss/config/mixins/_breakpoints.scss +62 -0
  184. package/scss/config/mixins/_btn.scss +179 -0
  185. package/scss/config/mixins/_card.scss +338 -0
  186. package/scss/config/mixins/_chip.scss +66 -0
  187. package/scss/config/mixins/_circular-progress.scss +71 -0
  188. package/scss/config/mixins/_collapsible.scss +24 -0
  189. package/scss/config/mixins/_colors.scss +46 -0
  190. package/scss/config/mixins/_confirm.scss +31 -0
  191. package/scss/config/mixins/_data-table.scss +346 -0
  192. package/scss/config/mixins/_display.scss +32 -0
  193. package/scss/config/mixins/_dropdown.scss +143 -0
  194. package/scss/config/mixins/_empty-state.scss +30 -0
  195. package/scss/config/mixins/_focus.scss +55 -0
  196. package/scss/config/mixins/_footer.scss +42 -0
  197. package/scss/config/mixins/_form.scss +601 -0
  198. package/scss/config/mixins/_index.scss +58 -0
  199. package/scss/config/mixins/_interaction.scss +15 -0
  200. package/scss/config/mixins/_kbd.scss +22 -0
  201. package/scss/config/mixins/_layout.scss +117 -0
  202. package/scss/config/mixins/_link.scss +55 -0
  203. package/scss/config/mixins/_ln-table.scss +420 -0
  204. package/scss/config/mixins/_loader.scss +26 -0
  205. package/scss/config/mixins/_modal.scss +66 -0
  206. package/scss/config/mixins/_motion.scss +19 -0
  207. package/scss/config/mixins/_nav.scss +273 -0
  208. package/scss/config/mixins/_page-header.scss +69 -0
  209. package/scss/config/mixins/_popover.scss +25 -0
  210. package/scss/config/mixins/_position.scss +32 -0
  211. package/scss/config/mixins/_progress.scss +56 -0
  212. package/scss/config/mixins/_prose.scss +127 -0
  213. package/scss/config/mixins/_shadows.scss +8 -0
  214. package/scss/config/mixins/_sidebar.scss +95 -0
  215. package/scss/config/mixins/_sizing.scss +6 -0
  216. package/scss/config/mixins/_spacing.scss +19 -0
  217. package/scss/config/mixins/_stat-card.scss +68 -0
  218. package/scss/config/mixins/_status-badge.scss +83 -0
  219. package/scss/config/mixins/_stepper.scss +78 -0
  220. package/scss/config/mixins/_table.scss +215 -0
  221. package/scss/config/mixins/_tabs.scss +64 -0
  222. package/scss/config/mixins/_timeline.scss +69 -0
  223. package/scss/config/mixins/_toast.scss +148 -0
  224. package/scss/config/mixins/_tooltip.scss +111 -0
  225. package/scss/config/mixins/_transitions.scss +10 -0
  226. package/scss/config/mixins/_translations.scss +124 -0
  227. package/scss/config/mixins/_typography.scss +57 -0
  228. package/scss/config/mixins/_upload.scss +168 -0
  229. package/scss/ln-ashlar.scss +62 -0
  230. package/scss/tabler-icons.txt +5039 -0
  231. package/scss/utilities/_animations.scss +83 -0
  232. package/scss/utilities/_utilities.scss +49 -0
@@ -0,0 +1,58 @@
1
+ # ln-tooltip
2
+
3
+ A zero-dependency, progressively enhanced **Dual-Layer Tooltip Primitive** that displays lightweight contextual descriptions on hover and focus.
4
+
5
+ It supports two levels of execution: a **pure CSS baseline** (zero JS footprint, utilizing pseudo-elements) and a **JS progressive enhancement layer** (portaled to the body to escape parent clipping, viewport-aware auto-flipping, and automated `aria-describedby` wiring).
6
+
7
+ ---
8
+
9
+ ## 🧭 Philosophy & Architecture
10
+
11
+ 1. **CSS-First Baseline (Zero-JS):** Every element carrying `data-ln-tooltip="text"` immediately receives a beautiful hover/focus visual tooltip via pure CSS `::after` pseudo-elements.
12
+ 2. **JS Portaled Enhancement (`data-ln-tooltip-enhance`):** Opt-in to activate JS features. The component detaches the tooltip from the trigger and mounts it in a global `<body>` portal (`#ln-tooltip-portal`), avoiding parent `overflow: hidden` clipping, wrapping long texts safely, and auto-flipping the bubble if it hits viewport boundaries.
13
+ 3. **Automated `<abbr>` Semantic Integration:** Elements containing both `data-ln-tooltip` and a native `title` attribute (such as standard `<abbr>` elements) auto-enhance without requiring the `-enhance` flag. The JS layer intercepts the native browser hover tooltip and replaces it dynamically to avoid double tooltips.
14
+
15
+ ---
16
+
17
+ ## 📦 Minimal Blueprint
18
+
19
+ ### CSS Baseline (Zero JS)
20
+ ```html
21
+ <button type="button" data-ln-tooltip="Save document" aria-label="Save document">
22
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-device-floppy"></use></svg>
23
+ </button>
24
+ ```
25
+
26
+ ### JS Progressive Enhancement (Viewport-Aware & Portaled)
27
+ Add `data-ln-tooltip-enhance` to activate advanced positioning and accessibility wiring.
28
+ ```html
29
+ <button type="button"
30
+ data-ln-tooltip="Delete this document permanently"
31
+ data-ln-tooltip-enhance
32
+ data-ln-tooltip-position="right"
33
+ aria-label="Delete document">
34
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-trash"></use></svg>
35
+ </button>
36
+ ```
37
+
38
+ ---
39
+
40
+ ## 🛠️ Declarative API Contract
41
+
42
+ ### HTML Attributes
43
+
44
+ | Attribute | Elements | Description |
45
+ | :--- | :--- | :--- |
46
+ | `data-ln-tooltip="text"` | Trigger element | Tooltip text. Required. If empty, falls back to the native `title` attribute. |
47
+ | `data-ln-tooltip-position` | Trigger element | Preferred placement side: `top` (default), `bottom`, `left`, `right`. |
48
+ | `data-ln-tooltip-enhance` | Trigger element | Opt-in. Activates JS portaling, edge auto-flipping, and accessibility descriptions. |
49
+ | `title` | Trigger element | When present alongside `data-ln-tooltip`, forces auto-enhance to suppress native tooltips. |
50
+ | `aria-describedby` | Trigger element | *State*. Automatically wired by the JS layer at runtime to point to the portal bubble ID. |
51
+
52
+ ---
53
+
54
+ ## ⚠️ Common Pitfalls
55
+
56
+ - **Omitting `aria-label` on Icon Buttons:** Sighted users see the tooltip, but screen readers require standard labeling. Sighted tooltips are visual mirrors; always include a matching `aria-label` on icon-only controls.
57
+ - **Triggering on Non-Focusable Elements:** Tooltips rely on hover and keyboard focus. Putting `data-ln-tooltip` on plain `<span>` or `<div>` elements without `tabindex="0"` makes them completely inaccessible to keyboard users.
58
+ - **Applying to Native Disabled Buttons:** Standard disabled buttons (e.g. `<button disabled>`) block pointer events in many browsers, preventing tooltips from firing. Use `aria-disabled="true"` instead to preserve tooltips while indicating disabled status.
@@ -0,0 +1 @@
1
+ (function(){"use strict";function x(e,n,a){e.dispatchEvent(new CustomEvent(n,{bubbles:!0,detail:a||{}}))}function T(e,n){if(!document.body){document.addEventListener("DOMContentLoaded",function(){T(e,n)}),console.warn("["+n+'] Script loaded before <body> — add "defer" to your <script> tag');return}e()}function L(e,n,a,u){if(e.nodeType!==1)return;const p=n.indexOf("[")!==-1||n.indexOf(".")!==-1||n.indexOf("#")!==-1?n:"["+n+"]",s=Array.from(e.querySelectorAll(p));e.matches&&e.matches(p)&&s.push(e);for(const i of s)i[a]||(i[a]=new u(i))}function O(e,n,a,u,c={}){const p=c.extraAttributes||[],s=c.onAttributeChange||null,i=c.onInit||null;function r(f){const l=f||document.body;L(l,e,n,a),i&&i(l)}return T(function(){const f=new MutationObserver(function(b){for(let v=0;v<b.length;v++){const d=b[v];if(d.type==="childList")for(let w=0;w<d.addedNodes.length;w++){const h=d.addedNodes[w];h.nodeType===1&&(L(h,e,n,a),i&&i(h))}else d.type==="attributes"&&(s&&d.target[n]?s(d.target,d.attributeName):(L(d.target,e,n,a),i&&i(d.target)))}});let l=[];if(e.indexOf("[")!==-1){const b=/\[([\w-]+)/g;let v;for(;(v=b.exec(e))!==null;)l.push(v[1])}else l.push(e);f.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:l.concat(p)})},u),window[n]=r,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){r(document.body)}):r(document.body),r}const C={};function M(e,n){C[e]=n}function N(e){return C[e]||{ingress:n=>n,egress:n=>n}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=M,window.lnCore.getDataMapper=N);function D(e,n,a,u){const c=u,p=window.innerWidth,s=window.innerHeight,i=n.width,r=n.height,f=a.split("-"),l=f[0],b=f[1]==="start"||f[1]==="end"?f[1]:"center",v={top:["top","bottom","right","left"],bottom:["bottom","top","right","left"],left:["left","right","top","bottom"],right:["right","left","top","bottom"]},d=v[l]||v.bottom;function w(o){return o==="top"||o==="bottom"?b==="start"?e.left:b==="end"?e.right-i:e.left+(e.width-i)/2:b==="start"?e.top:b==="end"?e.bottom-r:e.top+(e.height-r)/2}function h(o){let m,A,_=!0;return o==="top"?(m=e.top-c-r,A=w(o),m<0&&(_=!1)):o==="bottom"?(m=e.bottom+c,A=w(o),m+r>s&&(_=!1)):o==="left"?(m=w(o),A=e.left-c-i,A<0&&(_=!1)):(m=w(o),A=e.right+c,A+i>p&&(_=!1)),{top:m,left:A,side:o,fits:_}}let E=null;for(let o=0;o<d.length;o++){const m=h(d[o]);if(m.fits){E=m;break}}E||(E=h(d[0]));let t=E.top,y=E.left;return i>=p?y=0:(y<0&&(y=0),y+i>p&&(y=p-i)),r>=s?t=0:(t<0&&(t=0),t+r>s&&(t=s-r)),{top:t,left:y,placement:E.side}}(function(){const e="data-ln-tooltip-enhance",n="data-ln-tooltip",a="data-ln-tooltip-position",u="lnTooltipEnhance",c="ln-tooltip-portal";if(window[u]!==void 0)return;let p=0,s=null,i=null,r=null,f=null,l=null;function b(){return s&&s.parentNode||(s=document.getElementById(c),s||(s=document.createElement("div"),s.id=c,document.body.appendChild(s))),s}function v(){l||(l=function(t){t.key==="Escape"&&h()},document.addEventListener("keydown",l))}function d(){l&&(document.removeEventListener("keydown",l),l=null)}function w(t){if(r===t)return;h();const y=t.getAttribute(n)||t.getAttribute("title");if(!y)return;b(),t.hasAttribute("title")&&(f=t.getAttribute("title"),t.removeAttribute("title"));const o=document.createElement("div");o.className="ln-tooltip",o.textContent=y,t[u+"Uid"]||(p+=1,t[u+"Uid"]="ln-tooltip-"+p),o.id=t[u+"Uid"],s.appendChild(o);const m=o.offsetWidth,A=o.offsetHeight,_=t.getBoundingClientRect(),I=t.getAttribute(a)||"top",g=D(_,{width:m,height:A},I,6);o.style.top=g.top+"px",o.style.left=g.left+"px",o.setAttribute("data-ln-tooltip-placement",g.placement),t.setAttribute("aria-describedby",o.id),i=o,r=t,v()}function h(){if(!i){d();return}r&&(r.removeAttribute("aria-describedby"),f!==null&&r.setAttribute("title",f)),f=null,i.parentNode&&i.parentNode.removeChild(i),i=null,r=null,d()}function E(t){return this.dom=t,t.hasAttribute("data-ln-tooltip-enhanced")||(t.setAttribute("data-ln-tooltip-enhanced",""),this._addedEnhancedAttr=!0),this._onEnter=function(){w(t)},this._onLeave=function(){r===t&&h()},this._onFocus=function(){w(t)},this._onBlur=function(){r===t&&h()},t.addEventListener("mouseenter",this._onEnter),t.addEventListener("mouseleave",this._onLeave),t.addEventListener("focus",this._onFocus,!0),t.addEventListener("blur",this._onBlur,!0),this}E.prototype.destroy=function(){const t=this.dom;t.removeEventListener("mouseenter",this._onEnter),t.removeEventListener("mouseleave",this._onLeave),t.removeEventListener("focus",this._onFocus,!0),t.removeEventListener("blur",this._onBlur,!0),r===t&&h(),this._addedEnhancedAttr&&t.removeAttribute("data-ln-tooltip-enhanced"),delete t[u],delete t[u+"Uid"],x(t,"ln-tooltip:destroyed",{trigger:t})},O("["+e+"], ["+n+"][title]",u,E,"ln-tooltip")})()})();
@@ -0,0 +1,9 @@
1
+ /* Suppress CSS baseline pseudo-element when JS enhance takes over —
2
+ either explicitly via data-ln-tooltip-enhance, or automatically when a
3
+ `title` attribute is present (JS auto-attaches to strip the native
4
+ browser tooltip, which CSS alone cannot do). */
5
+ [data-ln-tooltip][data-ln-tooltip-enhance]::after,
6
+ [data-ln-tooltip][data-ln-tooltip-enhanced]::after,
7
+ [data-ln-tooltip][title]::after {
8
+ content: none;
9
+ }
@@ -0,0 +1,169 @@
1
+ import { computePlacement, dispatch, registerComponent } from '../../ln-core';
2
+
3
+ (function () {
4
+ const TRIGGER_SELECTOR = 'data-ln-tooltip-enhance';
5
+ const TEXT_ATTR = 'data-ln-tooltip';
6
+ const POSITION_ATTR = 'data-ln-tooltip-position';
7
+ const DOM_ATTRIBUTE = 'lnTooltipEnhance';
8
+ const PORTAL_ID = 'ln-tooltip-portal';
9
+
10
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
11
+
12
+ let uidCounter = 0;
13
+ let portal = null;
14
+ let activeTooltipNode = null;
15
+ let activeTrigger = null;
16
+ let activeStashedTitle = null;
17
+ let escListener = null;
18
+
19
+ function _ensurePortal() {
20
+ if (portal && portal.parentNode) return portal;
21
+ portal = document.getElementById(PORTAL_ID);
22
+ if (!portal) {
23
+ portal = document.createElement('div');
24
+ portal.id = PORTAL_ID;
25
+ document.body.appendChild(portal);
26
+ }
27
+ return portal;
28
+ }
29
+
30
+ function _ensureEscListener() {
31
+ if (escListener) return;
32
+ escListener = function (e) {
33
+ if (e.key === 'Escape') _hide();
34
+ };
35
+ document.addEventListener('keydown', escListener);
36
+ }
37
+
38
+ function _removeEscListener() {
39
+ if (!escListener) return;
40
+ document.removeEventListener('keydown', escListener);
41
+ escListener = null;
42
+ }
43
+
44
+ function _show(trigger) {
45
+ if (activeTrigger === trigger) return;
46
+ _hide();
47
+
48
+ // Fallback: if data-ln-tooltip has no value, pull text from `title`.
49
+ // Supports the semantic `<abbr data-ln-tooltip title="...">` pattern.
50
+ const text = trigger.getAttribute(TEXT_ATTR) || trigger.getAttribute('title');
51
+ if (!text) return;
52
+
53
+ _ensurePortal();
54
+
55
+ // Stash + strip `title` while our tooltip is visible so the browser's
56
+ // native title tooltip does not appear alongside it. Restored on hide.
57
+ if (trigger.hasAttribute('title')) {
58
+ activeStashedTitle = trigger.getAttribute('title');
59
+ trigger.removeAttribute('title');
60
+ }
61
+
62
+ const node = document.createElement('div');
63
+ node.className = 'ln-tooltip';
64
+ node.textContent = text;
65
+
66
+ // Assign a stable id so aria-describedby can point at it.
67
+ if (!trigger[DOM_ATTRIBUTE + 'Uid']) {
68
+ uidCounter += 1;
69
+ trigger[DOM_ATTRIBUTE + 'Uid'] = 'ln-tooltip-' + uidCounter;
70
+ }
71
+ node.id = trigger[DOM_ATTRIBUTE + 'Uid'];
72
+
73
+ portal.appendChild(node);
74
+
75
+ // Measure after attach (it's in the DOM with default styles).
76
+ const w = node.offsetWidth;
77
+ const h = node.offsetHeight;
78
+
79
+ const rect = trigger.getBoundingClientRect();
80
+ const preferred = trigger.getAttribute(POSITION_ATTR) || 'top';
81
+ const placement = computePlacement(rect, { width: w, height: h }, preferred, 6);
82
+
83
+ // Coordinate-only inline styles — same exception as ln-popover.
84
+ node.style.top = placement.top + 'px';
85
+ node.style.left = placement.left + 'px';
86
+ node.setAttribute('data-ln-tooltip-placement', placement.placement);
87
+
88
+ trigger.setAttribute('aria-describedby', node.id);
89
+
90
+ activeTooltipNode = node;
91
+ activeTrigger = trigger;
92
+ _ensureEscListener();
93
+ }
94
+
95
+ function _hide() {
96
+ if (!activeTooltipNode) {
97
+ _removeEscListener();
98
+ return;
99
+ }
100
+ if (activeTrigger) {
101
+ activeTrigger.removeAttribute('aria-describedby');
102
+ if (activeStashedTitle !== null) {
103
+ activeTrigger.setAttribute('title', activeStashedTitle);
104
+ }
105
+ }
106
+ activeStashedTitle = null;
107
+ if (activeTooltipNode.parentNode) {
108
+ activeTooltipNode.parentNode.removeChild(activeTooltipNode);
109
+ }
110
+ activeTooltipNode = null;
111
+ activeTrigger = null;
112
+ _removeEscListener();
113
+ }
114
+
115
+ // ─── Component ─────────────────────────────────────────────
116
+
117
+ function _component(el) {
118
+ this.dom = el;
119
+
120
+ // Set data-ln-tooltip-enhanced attribute so CSS knows JS has taken over,
121
+ // preventing the CSS ::after fallback from rendering when `title` is stashed/removed.
122
+ if (!el.hasAttribute('data-ln-tooltip-enhanced')) {
123
+ el.setAttribute('data-ln-tooltip-enhanced', '');
124
+ this._addedEnhancedAttr = true;
125
+ }
126
+
127
+ const self = this;
128
+ this._onEnter = function () { _show(el); };
129
+ this._onLeave = function () {
130
+ if (activeTrigger === el) _hide();
131
+ };
132
+ this._onFocus = function () { _show(el); };
133
+ this._onBlur = function () {
134
+ if (activeTrigger === el) _hide();
135
+ };
136
+
137
+ el.addEventListener('mouseenter', this._onEnter);
138
+ el.addEventListener('mouseleave', this._onLeave);
139
+ el.addEventListener('focus', this._onFocus, true);
140
+ el.addEventListener('blur', this._onBlur, true);
141
+
142
+ return this;
143
+ }
144
+
145
+ _component.prototype.destroy = function () {
146
+ const el = this.dom;
147
+ el.removeEventListener('mouseenter', this._onEnter);
148
+ el.removeEventListener('mouseleave', this._onLeave);
149
+ el.removeEventListener('focus', this._onFocus, true);
150
+ el.removeEventListener('blur', this._onBlur, true);
151
+ if (activeTrigger === el) _hide();
152
+ if (this._addedEnhancedAttr) {
153
+ el.removeAttribute('data-ln-tooltip-enhanced');
154
+ }
155
+ delete el[DOM_ATTRIBUTE];
156
+ delete el[DOM_ATTRIBUTE + 'Uid'];
157
+ dispatch(el, 'ln-tooltip:destroyed', { trigger: el });
158
+ };
159
+
160
+ // ─── Registration ──────────────────────────────────────────
161
+
162
+ registerComponent(
163
+ '[' + TRIGGER_SELECTOR + '], [' + TEXT_ATTR + '][title]',
164
+ DOM_ATTRIBUTE,
165
+ _component,
166
+ 'ln-tooltip'
167
+ );
168
+ })();
169
+
@@ -0,0 +1,96 @@
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.
@@ -0,0 +1 @@
1
+ (function(){"use strict";const b={};function y(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 v(e,a,d){e.dispatchEvent(new CustomEvent(a,{bubbles:!0,detail:d||{}}))}function L(e,a,d){const i=new CustomEvent(a,{bubbles:!0,cancelable:!0,detail:d||{}});return e.dispatchEvent(i),i}function A(e,a){if(!document.body){document.addEventListener("DOMContentLoaded",function(){A(e,a)}),console.warn("["+a+'] Script loaded before <body> — add "defer" to your <script> tag');return}e()}function m(e,a,d,i){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 i(n))}function E(e,a,d,i,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 A(function(){const u=new MutationObserver(function(g){for(let f=0;f<g.length;f++){const r=g[f];if(r.type==="childList")for(let h=0;h<r.addedNodes.length;h++){const p=r.addedNodes[h];p.nodeType===1&&(m(p,e,a,d),n&&n(p))}else r.type==="attributes"&&(o&&r.target[a]?o(r.target,r.attributeName):(m(r.target,e,a,d),n&&n(r.target)))}});let l=[];if(e.indexOf("[")!==-1){const g=/\[([\w-]+)/g;let f;for(;(f=g.exec(e))!==null;)l.push(f[1])}else l.push(e);u.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:l.concat(s)})},i),window[a]=c,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){c(document.body)}):c(document.body),c}const w={};function q(e,a){w[e]=a}function S(e){return w[e]||{ingress:a=>a,egress:a=>a}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=q,window.lnCore.getDataMapper=S),(function(){const e="data-ln-translations",a="lnTranslations";if(window[a]!==void 0)return;const d={en:"English",sq:"Shqip",sr:"Srpski"};function i(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}i.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)}},i.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())},i.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=y("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":"")},i.prototype._updateBadges=function(){if(!this.badgesEl)return;this.badgesEl.textContent="";const t=this;this.activeLanguages.forEach(function(s){const o=y("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)})},i.prototype.addLanguage=function(t,s){if(this.activeLanguages.has(t))return;const o=this.locales[t]||t;if(L(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"),g=u.getAttribute("data-ln-translations-prefix")||"",f=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(!f)continue;const r=f.cloneNode(!1);g?r.name=g+"[trans]["+t+"]["+l+"]":r.name="trans["+t+"]["+l+"]",r.value=s[l]!==void 0?s[l]:"",r.removeAttribute("id"),r.placeholder=o+" translation",r.setAttribute("data-ln-translatable-lang",t);const h=u.querySelectorAll('[data-ln-translatable-lang]:not([data-ln-translatable-lang="'+this.defaultLang+'"])'),p=h.length>0?h[h.length-1]:f;p.parentNode.insertBefore(r,p.nextSibling)}this._updateDropdown(),this._updateBadges(),v(this.dom,"ln-translations:added",{target:this.dom,lang:t,langName:o})},i.prototype.removeLanguage=function(t){if(!this.activeLanguages.has(t)||L(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(),v(this.dom,"ln-translations:removed",{target:this.dom,lang:t})},i.prototype.getActiveLanguages=function(){return new Set(this.activeLanguages)},i.prototype.hasLanguage=function(t){return this.activeLanguages.has(t)},i.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(e,a,i,"ln-translations")})()})();
@@ -0,0 +1,275 @@
1
+ import { cloneTemplate, dispatch, dispatchCancelable, registerComponent } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-translations';
5
+ const DOM_ATTRIBUTE = 'lnTranslations';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ // ─── Default locales (override via data-ln-translations-locales JSON) ──
10
+
11
+ const DEFAULT_LOCALES = {
12
+ en: 'English',
13
+ sq: 'Shqip',
14
+ sr: 'Srpski'
15
+ };
16
+
17
+ // ─── Component ─────────────────────────────────────────────
18
+
19
+ function _component(dom) {
20
+ this.dom = dom;
21
+ this.activeLanguages = new Set();
22
+ this.defaultLang = dom.getAttribute(DOM_SELECTOR + '-default') || '';
23
+ this.badgesEl = dom.querySelector('[' + DOM_SELECTOR + '-active]');
24
+ this.menuEl = dom.querySelector('[data-ln-dropdown] > [data-ln-toggle]');
25
+
26
+ // Parse locales from attribute or use defaults
27
+ const localesAttr = dom.getAttribute(DOM_SELECTOR + '-locales');
28
+ this.locales = DEFAULT_LOCALES;
29
+ if (localesAttr) {
30
+ try { this.locales = JSON.parse(localesAttr); }
31
+ catch (e) { console.warn('[ln-translations] Invalid JSON in data-ln-translations-locales'); }
32
+ }
33
+
34
+ // Set default language flag on original inputs
35
+ this._applyDefaultLang();
36
+
37
+ // Populate dropdown menu
38
+ this._updateDropdown();
39
+
40
+ // Bind request events
41
+ const self = this;
42
+ this._onRequestAdd = function (e) {
43
+ if (e.detail && e.detail.lang) self.addLanguage(e.detail.lang);
44
+ };
45
+ this._onRequestRemove = function (e) {
46
+ if (e.detail && e.detail.lang) self.removeLanguage(e.detail.lang);
47
+ };
48
+ dom.addEventListener('ln-translations:request-add', this._onRequestAdd);
49
+ dom.addEventListener('ln-translations:request-remove', this._onRequestRemove);
50
+
51
+ // Detect existing translations in DOM (server-rendered)
52
+ this._detectExisting();
53
+
54
+ return this;
55
+ }
56
+
57
+ // ─── Default language flag ─────────────────────────────────
58
+
59
+ _component.prototype._applyDefaultLang = function () {
60
+ if (!this.defaultLang) return;
61
+
62
+ const translatables = this.dom.querySelectorAll('[data-ln-translatable]');
63
+ for (const wrapper of translatables) {
64
+ const originals = wrapper.querySelectorAll('input:not([data-ln-translatable-lang]), textarea:not([data-ln-translatable-lang]), select:not([data-ln-translatable-lang])');
65
+ for (const el of originals) {
66
+ el.setAttribute('data-ln-translatable-lang', this.defaultLang);
67
+ }
68
+ }
69
+ };
70
+
71
+ // ─── Detect existing translations ──────────────────────────
72
+
73
+ _component.prototype._detectExisting = function () {
74
+ const existing = this.dom.querySelectorAll('[data-ln-translatable-lang]');
75
+ for (const el of existing) {
76
+ const lang = el.getAttribute('data-ln-translatable-lang');
77
+ if (lang && lang !== this.defaultLang) {
78
+ this.activeLanguages.add(lang);
79
+ }
80
+ }
81
+
82
+ if (this.activeLanguages.size > 0) {
83
+ this._updateBadges();
84
+ this._updateDropdown();
85
+ }
86
+ };
87
+
88
+ // ─── Dropdown update ───────────────────────────────────────
89
+
90
+ _component.prototype._updateDropdown = function () {
91
+ if (!this.menuEl) return;
92
+
93
+ this.menuEl.textContent = '';
94
+ const self = this;
95
+ let availableCount = 0;
96
+
97
+ for (const lang in this.locales) {
98
+ if (!this.locales.hasOwnProperty(lang)) continue;
99
+ if (this.activeLanguages.has(lang)) continue;
100
+ availableCount++;
101
+
102
+ const frag = cloneTemplate('ln-translations-menu-item', 'ln-translations');
103
+ if (!frag) return;
104
+ const btn = frag.querySelector('[data-ln-translations-lang]');
105
+ btn.setAttribute('data-ln-translations-lang', lang);
106
+ btn.textContent = this.locales[lang];
107
+
108
+ btn.addEventListener('click', function (e) {
109
+ if (e.ctrlKey || e.metaKey || e.button === 1) return;
110
+ e.preventDefault();
111
+ e.stopPropagation();
112
+ if (self.menuEl.getAttribute('data-ln-toggle') === 'open') {
113
+ self.menuEl.setAttribute('data-ln-toggle', 'close');
114
+ }
115
+ self.addLanguage(lang);
116
+ });
117
+
118
+ this.menuEl.appendChild(frag);
119
+ }
120
+
121
+ // Hide trigger if no languages available
122
+ const triggerBtn = this.dom.querySelector('[' + DOM_SELECTOR + '-add]');
123
+ if (triggerBtn) {
124
+ triggerBtn.style.display = availableCount === 0 ? 'none' : '';
125
+ }
126
+ };
127
+
128
+ // ─── Badges update ─────────────────────────────────────────
129
+
130
+ _component.prototype._updateBadges = function () {
131
+ if (!this.badgesEl) return;
132
+
133
+ this.badgesEl.textContent = '';
134
+ const self = this;
135
+
136
+ this.activeLanguages.forEach(function (lang) {
137
+ const frag = cloneTemplate('ln-translations-badge', 'ln-translations');
138
+ if (!frag) return;
139
+ const p = frag.querySelector('[data-ln-translations-lang]');
140
+ p.setAttribute('data-ln-translations-lang', lang);
141
+
142
+ const label = p.querySelector('span');
143
+ label.textContent = self.locales[lang] || lang.toUpperCase();
144
+
145
+ const closeBtn = p.querySelector('button');
146
+ closeBtn.setAttribute('aria-label', 'Remove ' + (self.locales[lang] || lang.toUpperCase()));
147
+
148
+ closeBtn.addEventListener('click', function (e) {
149
+ if (e.ctrlKey || e.metaKey || e.button === 1) return;
150
+ e.preventDefault();
151
+ e.stopPropagation();
152
+ self.removeLanguage(lang);
153
+ });
154
+
155
+ self.badgesEl.appendChild(frag);
156
+ });
157
+ };
158
+
159
+ // ─── Public API ────────────────────────────────────────────
160
+
161
+ _component.prototype.addLanguage = function (lang, values) {
162
+ if (this.activeLanguages.has(lang)) return;
163
+
164
+ const langName = this.locales[lang] || lang;
165
+ const before = dispatchCancelable(this.dom, 'ln-translations:before-add', {
166
+ target: this.dom, lang: lang, langName: langName
167
+ });
168
+ if (before.defaultPrevented) return;
169
+
170
+ this.activeLanguages.add(lang);
171
+ values = values || {};
172
+
173
+ // Clone inputs for each translatable field
174
+ const translatables = this.dom.querySelectorAll('[data-ln-translatable]');
175
+ for (const wrapper of translatables) {
176
+ const field = wrapper.getAttribute('data-ln-translatable');
177
+ const prefix = wrapper.getAttribute('data-ln-translations-prefix') || '';
178
+
179
+ // Find the original input/textarea (first one without data-ln-translatable-lang, or with default lang)
180
+ const original = wrapper.querySelector(
181
+ this.defaultLang
182
+ ? '[data-ln-translatable-lang="' + this.defaultLang + '"]'
183
+ : 'input:not([data-ln-translatable-lang]), textarea:not([data-ln-translatable-lang]), select:not([data-ln-translatable-lang])'
184
+ );
185
+ if (!original) continue;
186
+
187
+ const clone = original.cloneNode(false);
188
+
189
+ // Set name
190
+ if (prefix) {
191
+ clone.name = prefix + '[trans][' + lang + '][' + field + ']';
192
+ } else {
193
+ clone.name = 'trans[' + lang + '][' + field + ']';
194
+ }
195
+
196
+ // Set value
197
+ clone.value = (values[field] !== undefined) ? values[field] : '';
198
+
199
+ // Remove id to avoid duplicates
200
+ clone.removeAttribute('id');
201
+
202
+ // Set placeholder
203
+ clone.placeholder = langName + ' translation';
204
+
205
+ // Mark with language attribute
206
+ clone.setAttribute('data-ln-translatable-lang', lang);
207
+
208
+ // Insert after original or after last clone for this field
209
+ const existing = wrapper.querySelectorAll('[data-ln-translatable-lang]:not([data-ln-translatable-lang="' + this.defaultLang + '"])');
210
+ const insertAfter = existing.length > 0 ? existing[existing.length - 1] : original;
211
+ insertAfter.parentNode.insertBefore(clone, insertAfter.nextSibling);
212
+ }
213
+
214
+ this._updateDropdown();
215
+ this._updateBadges();
216
+
217
+ dispatch(this.dom, 'ln-translations:added', {
218
+ target: this.dom, lang: lang, langName: langName
219
+ });
220
+ };
221
+
222
+ _component.prototype.removeLanguage = function (lang) {
223
+ if (!this.activeLanguages.has(lang)) return;
224
+
225
+ const before = dispatchCancelable(this.dom, 'ln-translations:before-remove', {
226
+ target: this.dom, lang: lang
227
+ });
228
+ if (before.defaultPrevented) return;
229
+
230
+ // Remove all clones for this language
231
+ const clones = this.dom.querySelectorAll('[data-ln-translatable-lang="' + lang + '"]');
232
+ for (const clone of clones) {
233
+ clone.parentNode.removeChild(clone);
234
+ }
235
+
236
+ this.activeLanguages.delete(lang);
237
+ this._updateDropdown();
238
+ this._updateBadges();
239
+
240
+ dispatch(this.dom, 'ln-translations:removed', {
241
+ target: this.dom, lang: lang
242
+ });
243
+ };
244
+
245
+ _component.prototype.getActiveLanguages = function () {
246
+ return new Set(this.activeLanguages);
247
+ };
248
+
249
+ _component.prototype.hasLanguage = function (lang) {
250
+ return this.activeLanguages.has(lang);
251
+ };
252
+
253
+ _component.prototype.destroy = function () {
254
+ if (!this.dom[DOM_ATTRIBUTE]) return;
255
+
256
+ // Remove all translation clones (not default lang)
257
+ const defaultLang = this.defaultLang;
258
+ const clones = this.dom.querySelectorAll('[data-ln-translatable-lang]');
259
+ for (const clone of clones) {
260
+ if (clone.getAttribute('data-ln-translatable-lang') !== defaultLang) {
261
+ clone.parentNode.removeChild(clone);
262
+ }
263
+ }
264
+
265
+ // Remove event listeners
266
+ this.dom.removeEventListener('ln-translations:request-add', this._onRequestAdd);
267
+ this.dom.removeEventListener('ln-translations:request-remove', this._onRequestRemove);
268
+
269
+ delete this.dom[DOM_ATTRIBUTE];
270
+ };
271
+
272
+ // ─── Init ──────────────────────────────────────────────────
273
+
274
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-translations');
275
+ })();