@livenetworks/ashlar 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +189 -177
  2. package/js/COMPONENTS.md +55 -49
  3. package/js/index.js +22 -5
  4. package/js/ln-accordion/README.md +186 -137
  5. package/js/ln-accordion/ln-accordion.js +1 -1
  6. package/js/ln-ajax/README.md +73 -4
  7. package/js/ln-ajax/ln-ajax.js +1 -1
  8. package/js/ln-ajax/src/ln-ajax.js +13 -13
  9. package/js/ln-api-connector/README.md +261 -150
  10. package/js/ln-api-connector/ln-api-connector.js +1 -1
  11. package/js/ln-api-connector/src/ln-api-connector.js +368 -265
  12. package/js/ln-api-queue/README.md +219 -0
  13. package/js/ln-api-queue/ln-api-queue.js +1 -0
  14. package/js/ln-api-queue/src/ln-api-queue.js +320 -0
  15. package/js/ln-api-queue/src/queue-storage.js +455 -0
  16. package/js/ln-autoresize/README.md +47 -0
  17. package/js/ln-autoresize/ln-autoresize.js +1 -1
  18. package/js/ln-autosave/README.md +33 -1
  19. package/js/ln-autosave/ln-autosave.js +1 -1
  20. package/js/ln-autosave/src/ln-autosave.js +4 -4
  21. package/js/ln-circular-progress/README.md +27 -13
  22. package/js/ln-circular-progress/ln-circular-progress.js +1 -1
  23. package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
  24. package/js/ln-confirm/README.md +165 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +82 -42
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +92 -2
  66. package/js/ln-filter/ln-filter-dev.scss +8 -0
  67. package/js/ln-filter/ln-filter.js +1 -1
  68. package/js/ln-filter/src/ln-filter.js +22 -7
  69. package/js/ln-form/README.md +212 -49
  70. package/js/ln-form/ln-form.js +1 -1
  71. package/js/ln-form/src/ln-form.js +99 -199
  72. package/js/ln-http/README.md +125 -89
  73. package/js/ln-http/ln-http.js +1 -1
  74. package/js/ln-http/src/ln-http.js +32 -13
  75. package/js/ln-icon/README.md +130 -0
  76. package/js/ln-icon/ln-icon.js +1 -0
  77. package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
  78. package/js/ln-link/README.md +47 -9
  79. package/js/ln-link/ln-link.js +1 -1
  80. package/js/ln-link/src/ln-link.js +22 -6
  81. package/js/ln-list/README.md +129 -0
  82. package/js/ln-list/ln-list.js +1 -0
  83. package/js/ln-list/ln-list.scss +28 -0
  84. package/js/ln-list/src/ln-list.js +1216 -0
  85. package/js/ln-modal/README.md +185 -154
  86. package/js/ln-modal/ln-modal-dev.scss +13 -0
  87. package/js/ln-modal/ln-modal.js +1 -1
  88. package/js/ln-modal/ln-modal.scss +28 -4
  89. package/js/ln-modal/src/ln-modal.js +137 -201
  90. package/js/ln-modal-coordinator/README.md +99 -0
  91. package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
  92. package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
  93. package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
  94. package/js/ln-nav/README.md +46 -3
  95. package/js/ln-nav/ln-nav.js +1 -1
  96. package/js/ln-nav/src/ln-nav.js +92 -127
  97. package/js/ln-number/README.md +28 -1
  98. package/js/ln-number/ln-number-dev.scss +13 -0
  99. package/js/ln-number/ln-number.js +1 -1
  100. package/js/ln-number/src/ln-number.js +443 -350
  101. package/js/ln-options/README.md +66 -0
  102. package/js/ln-options/ln-options.js +1 -0
  103. package/js/ln-options/src/ln-options.js +74 -0
  104. package/js/ln-popover/README.md +40 -5
  105. package/js/ln-popover/ln-popover.js +1 -1
  106. package/js/ln-popover/src/ln-popover.js +38 -11
  107. package/js/ln-progress/README.md +413 -442
  108. package/js/ln-progress/ln-progress.js +1 -1
  109. package/js/ln-progress/src/ln-progress.js +98 -150
  110. package/js/ln-router/README.md +432 -0
  111. package/js/ln-router/ln-router.js +1 -0
  112. package/js/ln-router/src/ln-router.js +539 -0
  113. package/js/ln-search/README.md +158 -83
  114. package/js/ln-search/ln-search-dev.scss +8 -0
  115. package/js/ln-search/ln-search.js +1 -1
  116. package/js/ln-search/src/ln-search.js +12 -9
  117. package/js/ln-slug/README.md +110 -0
  118. package/js/ln-slug/ln-slug.js +1 -0
  119. package/js/ln-slug/src/ln-slug.js +74 -0
  120. package/js/ln-sort/README.md +170 -0
  121. package/js/ln-sort/ln-sort-dev.scss +8 -0
  122. package/js/ln-sort/ln-sort.js +1 -0
  123. package/js/ln-sort/src/ln-sort.js +178 -0
  124. package/js/ln-sortable/README.md +133 -95
  125. package/js/ln-sortable/ln-sortable.js +1 -1
  126. package/js/ln-stat/README.md +55 -0
  127. package/js/ln-stat/ln-stat.js +1 -0
  128. package/js/ln-stat/src/ln-stat.js +55 -0
  129. package/js/ln-table/README.md +285 -101
  130. package/js/ln-table/ln-table-dev.scss +17 -0
  131. package/js/ln-table/ln-table.js +1 -1
  132. package/js/ln-table/ln-table.scss +1 -1
  133. package/js/ln-table/src/ln-table.js +1493 -473
  134. package/js/ln-table-coordinator/README.md +73 -0
  135. package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
  136. package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
  137. package/js/ln-tabs/README.md +221 -137
  138. package/js/ln-tabs/ln-tabs-dev.scss +18 -0
  139. package/js/ln-tabs/ln-tabs.js +1 -1
  140. package/js/ln-tabs/src/ln-tabs.js +41 -24
  141. package/js/ln-time/README.md +14 -0
  142. package/js/ln-time/ln-time.js +1 -1
  143. package/js/ln-time/src/ln-time.js +223 -192
  144. package/js/ln-toast/README.md +178 -122
  145. package/js/ln-toast/ln-toast.js +1 -15
  146. package/js/ln-toast/src/ln-toast.js +232 -210
  147. package/js/ln-toast/template.html +16 -12
  148. package/js/ln-toggle/README.md +61 -2
  149. package/js/ln-toggle/ln-toggle-dev.scss +13 -0
  150. package/js/ln-toggle/ln-toggle.js +1 -1
  151. package/js/ln-toggle/src/ln-toggle.js +56 -43
  152. package/js/ln-tooltip/README.md +95 -58
  153. package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
  154. package/js/ln-tooltip/ln-tooltip.js +1 -1
  155. package/js/ln-tooltip/src/ln-tooltip.js +45 -6
  156. package/js/ln-translations/README.md +118 -96
  157. package/js/ln-translations/ln-translations.js +1 -1
  158. package/js/ln-translations/src/ln-translations.js +10 -5
  159. package/js/ln-upload/README.md +85 -10
  160. package/js/ln-upload/ln-upload.js +1 -1
  161. package/js/ln-upload/src/ln-upload.js +26 -10
  162. package/js/ln-validate/README.md +72 -0
  163. package/js/ln-validate/ln-validate-dev.scss +15 -0
  164. package/js/ln-validate/ln-validate.js +1 -1
  165. package/js/ln-validate/src/ln-validate.js +64 -0
  166. package/package.json +61 -56
  167. package/scss/base/_global.scss +2 -2
  168. package/scss/base/_typography.scss +1 -2
  169. package/scss/components/_date.scss +1 -0
  170. package/scss/components/_dropdown.scss +0 -5
  171. package/scss/components/_editor.scss +5 -0
  172. package/scss/components/_filter.scss +26 -25
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +13 -3
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +36 -23
  177. package/scss/components/_toast.scss +68 -41
  178. package/scss/components/_toggle.scss +16 -0
  179. package/scss/config/_density.scss +205 -117
  180. package/scss/config/_icons.scss +10 -9
  181. package/scss/config/_tokens.scss +69 -47
  182. package/scss/config/mixins/_accordion.scss +2 -7
  183. package/scss/config/mixins/_alert.scss +4 -4
  184. package/scss/config/mixins/_breadcrumbs.scss +2 -2
  185. package/scss/config/mixins/_btn.scss +1 -1
  186. package/scss/config/mixins/_card.scss +89 -14
  187. package/scss/config/mixins/_collapsible.scss +3 -0
  188. package/scss/config/mixins/_colors.scss +1 -1
  189. package/scss/config/mixins/_diagnostics.scss +11 -0
  190. package/scss/config/mixins/_display.scss +1 -0
  191. package/scss/config/mixins/_dropdown.scss +3 -5
  192. package/scss/config/mixins/_editor.scss +124 -0
  193. package/scss/config/mixins/_empty-state.scss +7 -0
  194. package/scss/config/mixins/_filter.scss +3 -5
  195. package/scss/config/mixins/_footer.scss +1 -2
  196. package/scss/config/mixins/_form.scss +207 -21
  197. package/scss/config/mixins/_index.scss +2 -0
  198. package/scss/config/mixins/_ln-table.scss +41 -2
  199. package/scss/config/mixins/_modal.scss +149 -66
  200. package/scss/config/mixins/_nav.scss +3 -4
  201. package/scss/config/mixins/_popover.scss +39 -0
  202. package/scss/config/mixins/_sidebar.scss +1 -1
  203. package/scss/config/mixins/_sort.scss +42 -0
  204. package/scss/config/mixins/_table.scss +85 -60
  205. package/scss/config/mixins/_toast.scss +179 -152
  206. package/scss/config/mixins/_tooltip.scss +17 -7
  207. package/scss/config/mixins/_typography.scss +4 -2
  208. package/scss/ln-ashlar-dev.scss +27 -9
  209. package/scss/ln-ashlar.scss +7 -1
  210. package/js/ln-data-table/README.md +0 -110
  211. package/js/ln-data-table/ln-data-table.js +0 -1
  212. package/js/ln-data-table/ln-data-table.scss +0 -10
  213. package/js/ln-data-table/src/ln-data-table.js +0 -1087
  214. package/js/ln-icons/README.md +0 -88
  215. package/js/ln-icons/ln-icons.js +0 -1
  216. package/js/ln-table/ln-table-sort.js +0 -1
  217. package/js/ln-table/src/ln-table-sort.js +0 -156
@@ -0,0 +1 @@
1
+ (function(){"use strict";if(typeof window<"u"){const t=console.warn;console.warn=function(...e){typeof e[0]=="string"&&(e[0].startsWith("[ln-")||e[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||t.apply(console,e)}}const m={};function _(t,e){m[t]||(m[t]=document.querySelector('[data-ln-template="'+t+'"]'));const n=m[t];return n?n.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function x(t,e){if(!t||!e)return t;const n=t.querySelectorAll("[data-ln-field]");for(let o=0;o<n.length;o++){const i=n[o],l=i.getAttribute("data-ln-field");e[l]!=null&&(i.textContent=e[l])}const c=t.querySelectorAll("[data-ln-attr]");for(let o=0;o<c.length;o++){const i=c[o],l=i.getAttribute("data-ln-attr").split(",");for(let s=0;s<l.length;s++){const d=l[s].trim().split(":");if(d.length!==2)continue;const f=d[0].trim(),a=d[1].trim();e[a]!=null&&i.setAttribute(f,e[a])}}const u=t.querySelectorAll("[data-ln-show]");for(let o=0;o<u.length;o++){const i=u[o],l=i.getAttribute("data-ln-show");l in e&&i.classList.toggle("hidden",!e[l])}const r=t.querySelectorAll("[data-ln-class]");for(let o=0;o<r.length;o++){const i=r[o],l=i.getAttribute("data-ln-class").split(",");for(let s=0;s<l.length;s++){const d=l[s].trim().split(":");if(d.length!==2)continue;const f=d[0].trim(),a=d[1].trim();a in e&&i.classList.toggle(f,!!e[a])}}return t}function q(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const n=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let c=0;c<n.length;c++)n[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)x(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let n=0;n<e.length;n++)e[n].textContent=""}})));function L(t,e){if(!t||!e)return t;const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;n.nextNode();){const r=n.currentNode;r.textContent.indexOf("{{")!==-1&&(r.textContent=r.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(o,i){return e[i]!==void 0?e[i]:""}))}const c=function(r,o){return e[o]!==void 0?e[o]:""},u=Array.from(t.querySelectorAll("*"));t.nodeType===1&&u.push(t);for(let r=0;r<u.length;r++){const o=u[r],i=o.attributes;for(let l=0;l<i.length;l++){const s=i[l];s.value.indexOf("{{")!==-1&&o.setAttribute(s.name,s.value.replace(/\{\{\s*(\w+)\s*\}\}/g,c))}}return t}function M(t,e,n,c,u,r){const o={};for(let l=0;l<t.children.length;l++){const s=t.children[l],d=s.getAttribute("data-ln-key");d&&(o[d]=s)}const i=document.createDocumentFragment();for(let l=0;l<e.length;l++){const s=e[l],d=String(c(s));let f=o[d];if(f)u(f,s,l);else{const a=_(n,r);if(!a||(L(a,s),f=a.firstElementChild,!f))continue;f.setAttribute("data-ln-key",d),u(f,s,l)}i.appendChild(f)}t.textContent="",t.appendChild(i)}function S(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){S(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function g(t,e,n,c){if(t.nodeType!==1)return;const r=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",o=Array.from(t.querySelectorAll(r));t.matches&&t.matches(r)&&o.push(t);for(const i of o)i[n]||(i[n]=new c(i))}function N(t,e,n,c,u={}){const r=u.extraAttributes||[],o=u.onAttributeChange||null,i=u.onInit||null;function l(s){const d=s||document.body;g(d,t,e,n),i&&i(d)}return S(function(){const s=new MutationObserver(function(f){for(let a=0;a<f.length;a++){const h=f[a];if(h.type==="childList"){for(let w=0;w<h.addedNodes.length;w++){const p=h.addedNodes[w];p.nodeType===1&&(g(p,t,e,n),i&&i(p))}for(let w=0;w<h.removedNodes.length;w++){const p=h.removedNodes[w];if(p.nodeType===1){const E=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",b=Array.from(p.querySelectorAll(E));p.matches&&p.matches(E)&&b.push(p);for(let C=0;C<b.length;C++){const D=b[C];if(!document.contains(D)){const A=D[e];A&&typeof A.destroy=="function"&&A.destroy()}}}}}else h.type==="attributes"&&(o&&h.target[e]?o(h.target,h.attributeName):(g(h.target,t,e,n),i&&i(h.target)))}});let d=[];if(t.indexOf("[")!==-1){const f=/\[([\w-]+)/g;let a;for(;(a=f.exec(t))!==null;)d.push(a[1])}else d.push(t);s.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:d.concat(r)})},c),window[e]=l,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){l(document.body)}):l(document.body),l}const O={};function j(t,e){O[t]=e}function W(t){return O[t]||{ingress:e=>e,egress:e=>e}}const k={};function v(t,e){if(!t||typeof e!="object")return;const n=t.toLowerCase().split("-")[0];k[n]=e}function F(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return k[e]||null}v("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=j,window.lnCore.getDataMapper=W,window.lnCore.registerLocaleFallback=v,window.lnCore.getLocaleFallback=F,window.lnCore.fillTemplate=L,window.lnCore.fill=x,window.lnCore.lnFill=q,window.lnCore.renderList=M);function T(t){return(t||"").replace(/^#/,"")}function y(t){const e=t===void 0?location.hash:t,n={},c=T(e);if(!c)return n;const u=c.split("&");for(let r=0;r<u.length;r++){const o=u[r];if(!o)continue;const i=o.indexOf(":"),l=i>-1?o.slice(0,i):o,s=i>-1?o.slice(i+1):"";if(l)try{n[l]=decodeURIComponent(s)}catch{n[l]=s}}return n}function B(t){if(!t)return null;const e=y();return t in e?e[t]:null}function I(t,e){if(!t)return;const n=y();e==null?delete n[t]:n[t]=String(e);const u=Object.keys(n).map(function(r){const o=n[r];return o===""?r:r+":"+encodeURIComponent(o)}).join("&");T(location.hash)!==u&&(location.hash=u)}function K(t){return t.button===1||t.ctrlKey||t.metaKey||t.shiftKey?!1:(t.preventDefault(),!0)}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.hashParse=y,window.lnCore.hashGet=B,window.lnCore.hashSet=I,window.lnCore.hashLinkClick=K),(function(){const t="data-ln-debug",e="lnDebug";if(window[e]!==void 0)return;function n(c){return this.dom=c,this}n.prototype.destroy=function(){delete this.dom[e]},N(t,e,n,"ln-debug")})()})();
@@ -0,0 +1,19 @@
1
+ import { registerComponent } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-debug';
5
+ const DOM_ATTRIBUTE = 'lnDebug';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ function _component(dom) {
10
+ this.dom = dom;
11
+ return this;
12
+ }
13
+
14
+ _component.prototype.destroy = function () {
15
+ delete this.dom[DOM_ATTRIBUTE];
16
+ };
17
+
18
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-debug');
19
+ })();
@@ -1,15 +1,15 @@
1
1
  # ln-dropdown
2
2
 
3
- > A menu-grade coordinator that adds click-outside, body teleportation, and automatic positioning on top of `ln-toggle`.
3
+ > A menu-grade coordinator that adds click-outside, top-layer promotion, and automatic positioning on top of `ln-toggle`.
4
4
 
5
5
  ---
6
6
 
7
7
  ## 1. Philosophy & The Dropdown Mindset
8
8
 
9
- In `ln-ashlar`, the core design principle is **orthogonality**. Rather than creating a heavy component that bundles state, LIFO click stacks, teleportation contexts, and styles, `ln-dropdown` splits them cleanly:
9
+ In `ln-ashlar`, the core design principle is **orthogonality**. Rather than creating a heavy component that bundles state, LIFO click stacks, top-layer contexts, and styles, `ln-dropdown` splits them cleanly:
10
10
 
11
11
  1. **State Primitive (`ln-toggle`)**: Open/close state lives entirely on the inner `data-ln-toggle` attribute on the menu. `ln-dropdown` does not re-implement state; it is a thin behavior layer on top.
12
- 2. **Behavior & Positioning (JavaScript)**: The `ln-dropdown` coordinator handles click-outside detection, viewport resize closures (which makes absolute positioning unreliable), layout teleportation to `<body>` to escape parent `overflow: hidden` clips, and scroll position tracking.
12
+ 2. **Behavior & Positioning (JavaScript)**: The `ln-dropdown` coordinator handles click-outside detection, viewport resize closures (which makes absolute positioning unreliable), top-layer promotion via the native Popover API (`popover="manual"`) to escape parent `overflow: hidden` clips and stacking contexts, and scroll position tracking.
13
13
  3. **Visual Presentation (CSS)**: Visual layouts, background shadows, and borders are handled in Vanilla CSS. The library ships mixins like `@include dropdown` and `@include dropdown-menu` to style the wrapper and popup elements.
14
14
 
15
15
  ---
@@ -51,7 +51,7 @@ Outside clicks, window resizes, triggers, and custom scripts all change state by
51
51
  const wrapper = document.querySelector('[data-ln-dropdown]');
52
52
  const menu = wrapper.querySelector('[data-ln-toggle]');
53
53
 
54
- // Open the menu (dropdown teleports and positions it automatically)
54
+ // Open the menu (dropdown promotes it to the top layer and positions it automatically)
55
55
  menu.setAttribute('data-ln-toggle', 'open');
56
56
 
57
57
  // Close the menu
@@ -74,8 +74,8 @@ All events bubble. The dispatch target is the inner menu element (except `:destr
74
74
 
75
75
  | Event | Bubbles | Detail | Dispatched When |
76
76
  |---|---|---|---|
77
- | **`ln-dropdown:open`** | Yes | `{ target: menuElement }` | After teleportation and positioning are complete. |
78
- | **`ln-dropdown:close`** | Yes | `{ target: menuElement }` | After menu is closed, teleported back, and outside listeners removed. |
77
+ | **`ln-dropdown:open`** | Yes | `{ target: menuElement }` | After top-layer promotion and positioning are complete. |
78
+ | **`ln-dropdown:close`** | Yes | `{ target: menuElement }` | After menu is closed, exits the top layer, and outside listeners removed. |
79
79
  | **`ln-dropdown:destroyed`** | Yes | `{ target: wrapperElement }` | Inside `destroy()`, after removing listeners. |
80
80
 
81
81
  *Note*: Open/close state is managed by `ln-toggle`. Use `ln-toggle:before-open` / `ln-toggle:before-close` to cancel transitions.
@@ -91,7 +91,7 @@ document.addEventListener('ln-dropdown:open', (e) => {
91
91
 
92
92
  ## 5. Behavior & Integration
93
93
 
94
- - **Teleportation**: On open, the menu is moved to `<body>` so it escapes ancestor `overflow: hidden` clipping and parent stacking contexts. On close, it returns to its original position in the DOM.
94
+ - **Top-Layer Promotion**: On open, the menu is shown via the native Popover API (`showPopover()`), which renders it in the browser's top layer — escaping ancestor `overflow: hidden` clipping and stacking contexts without moving it in the DOM. `popover="manual"` keeps dismissal entirely under `ln-dropdown`'s own control (no native light-dismiss).
95
95
  - **Positioning**: The menu opens below the trigger, right-aligned to it. It automatically flips above if there is no vertical space below, and left-aligned if there is no horizontal space on the right.
96
96
  - **Scroll & Resize Tracking**: Repositions automatically on every scroll to track the trigger. A window viewport resize closes the menu to prevent layout misalignments.
97
97
 
@@ -114,4 +114,27 @@ document.addEventListener('ln-dropdown:open', (e) => {
114
114
  ## Related
115
115
  - **[`ln-toggle`](../ln-toggle/README.md)** — Binary disclosure state primitive.
116
116
  - **[`ln-popover`](../ln-popover/README.md)** — Viewport-aware click-triggered rich-content overlays.
117
- - **Architecture deep-dive** — [`docs/js/dropdown.md`](../../docs/js/dropdown.md).
117
+
118
+ ---
119
+
120
+ ## 🔧 Internals
121
+
122
+ Source: `js/ln-dropdown/ln-dropdown.js`. Each `[data-ln-dropdown]` gets a `_component` instance at `element.lnDropdown` (`dom` the wrapper, `toggleEl` the menu, `triggerBtn`, plus the bound outside-click / scroll-reposition / resize-close handles, cleared on close and destroy).
123
+
124
+ ### Dependency on ln-toggle
125
+
126
+ Dropdown does not manage open/close state itself — it listens for `ln-toggle:open`/`ln-toggle:close` on the inner `[data-ln-toggle]` menu and runs top-layer/positioning/listener-lifecycle steps in response. This is the library's **Presentation Coordinator** pattern layered on a state primitive; see [Coordinator Doctrine Reference](../../docs/architecture/coordinator.md).
127
+
128
+ ### Top-layer + placement
129
+
130
+ The menu carries `popover="manual"` (set at construction). On `ln-toggle:open`: `showPopover()` promotes it to the browser's top layer — escaping ancestor `overflow: hidden` clips and stacking contexts without moving it in the DOM — then `_reposition()` measures and places it. On `ln-toggle:close`: the inline `top`/`left` are cleared and `hidePopover()` retracts it (guarded by `:popover-open`, since a menu opened at boot via persisted/static state was never shown through `showPopover()`).
131
+
132
+ `_reposition()` reads the trigger's bounding rect, measures the menu via `measureHidden` (ln-core's hidden-safe dimension read), and calls `computePlacement(rect, size, 'bottom-end', gap)` — `gap` from the `--size-xs` token. `'bottom-end'` means below the trigger, right-aligned; `computePlacement` flips to `'top-end'` if there's no room below (alignment preserved through the flip) and clamps to the viewport.
133
+
134
+ ### Listener lifecycle
135
+
136
+ `ln-toggle:open`/`ln-toggle:close` are bound at construction and removed at `destroy()`. On open only: an outside-click listener on `document` (deferred one tick via `setTimeout(0)` so the opening click itself doesn't immediately close the menu), a `scroll` listener on `window` (reposition), and a `resize` listener on `window` (close — resize makes fixed-position coordinates stale). All three are removed on close.
137
+
138
+ ### MutationObserver
139
+
140
+ One global observer on `document.body`: `childList` (subtree) auto-initializes new `[data-ln-dropdown]` wrappers; `attributes` on `data-ln-dropdown` initializes on attribute addition.
@@ -1 +1 @@
1
- (function(){"use strict";function v(t,e,n){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:n||{}}))}function k(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){k(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function L(t,e,n,i){if(t.nodeType!==1)return;const s=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",d=Array.from(t.querySelectorAll(s));t.matches&&t.matches(s)&&d.push(t);for(const r of d)r[n]||(r[n]=new i(r))}function O(t,e,n,i,o={}){const s=o.extraAttributes||[],d=o.onAttributeChange||null,r=o.onInit||null;function f(_){const E=_||document.body;L(E,t,e,n),r&&r(E)}return k(function(){const _=new MutationObserver(function(m){for(let b=0;b<m.length;b++){const u=m[b];if(u.type==="childList"){for(let c=0;c<u.addedNodes.length;c++){const p=u.addedNodes[c];p.nodeType===1&&(L(p,t,e,n),r&&r(p))}for(let c=0;c<u.removedNodes.length;c++){const p=u.removedNodes[c];if(p.nodeType===1){const y=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",h=Array.from(p.querySelectorAll(y));p.matches&&p.matches(y)&&h.push(p);for(let l=0;l<h.length;l++){const g=h[l];if(!document.contains(g)){const a=g[e];a&&typeof a.destroy=="function"&&a.destroy()}}}}}else u.type==="attributes"&&(d&&u.target[e]?d(u.target,u.attributeName):(L(u.target,t,e,n),r&&r(u.target)))}});let E=[];if(t.indexOf("[")!==-1){const m=/\[([\w-]+)/g;let b;for(;(b=m.exec(t))!==null;)E.push(b[1])}else E.push(t);_.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:E.concat(s)})},i),window[e]=f,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){f(document.body)}):f(document.body),f}const x={};function R(t,e){x[t]=e}function A(t){return x[t]||{ingress:e=>e,egress:e=>e}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=R,window.lnCore.getDataMapper=A);function T(t,e,n,i){const o=typeof i=="number"?i:4,s=window.innerWidth,d=window.innerHeight,r=e.width,f=e.height,_=n.split("-"),E=_[0],m=_[1]==="start"||_[1]==="end"?_[1]:"center",b={top:["top","bottom","right","left"],bottom:["bottom","top","right","left"],left:["left","right","top","bottom"],right:["right","left","top","bottom"]},u=b[E]||b.bottom;function c(l){return l==="top"||l==="bottom"?m==="start"?t.left:m==="end"?t.right-r:t.left+(t.width-r)/2:m==="start"?t.top:m==="end"?t.bottom-f:t.top+(t.height-f)/2}function p(l){let g,a,w=!0;return l==="top"?(g=t.top-o-f,a=c(l),g<0&&(w=!1)):l==="bottom"?(g=t.bottom+o,a=c(l),g+f>d&&(w=!1)):l==="left"?(g=c(l),a=t.left-o-r,a<0&&(w=!1)):(g=c(l),a=t.right+o,a+r>s&&(w=!1)),{top:g,left:a,side:l,fits:w}}let C=null;for(let l=0;l<u.length;l++){const g=p(u[l]);if(g.fits){C=g;break}}C||(C=p(u[0]));let y=C.top,h=C.left;return r>=s?h=0:(h<0&&(h=0),h+r>s&&(h=s-r)),f>=d?y=0:(y<0&&(y=0),y+f>d&&(y=d-f)),{top:y,left:h,placement:C.side}}function S(t){if(!t||t.parentNode===document.body)return function(){};const e=t.parentNode,n=document.createComment("ln-teleport");return e.insertBefore(n,t),document.body.appendChild(t),function(){n.parentNode&&(n.parentNode.insertBefore(t,n),n.parentNode.removeChild(n))}}function D(t){if(!t)return{width:0,height:0};const e=t.style,n=e.visibility,i=e.display,o=e.position;e.visibility="hidden",e.display="block",e.position="fixed";const s=t.offsetWidth,d=t.offsetHeight;return e.visibility=n,e.display=i,e.position=o,{width:s,height:d}}(function(){const t="data-ln-dropdown",e="lnDropdown";if(window[e]!==void 0)return;function n(i){if(this.dom=i,this.toggleEl=i.querySelector("[data-ln-toggle]"),this._teleportRestore=null,this._boundDocClick=null,this._docClickTimeout=null,this._boundScrollReposition=null,this._boundResizeClose=null,this.toggleEl&&(this.toggleEl.setAttribute("data-ln-dropdown-menu",""),this.toggleEl.setAttribute("role","menu")),this.triggerBtn=i.querySelector("[data-ln-toggle-for]"),this.triggerBtn&&(this.triggerBtn.setAttribute("aria-haspopup","menu"),this.triggerBtn.setAttribute("aria-expanded","false")),this.toggleEl)for(const s of this.toggleEl.children)s.setAttribute("role","menuitem");const o=this;return this._onToggleOpen=function(s){s.detail.target===o.toggleEl&&(o.triggerBtn&&o.triggerBtn.setAttribute("aria-expanded","true"),o._teleportRestore=S(o.toggleEl),o.toggleEl.style.position="fixed",o.toggleEl.style.right="auto",o._reposition(),o._addOutsideClickListener(),o._addScrollRepositionListener(),o._addResizeCloseListener(),v(i,"ln-dropdown:open",{target:s.detail.target}))},this._onToggleClose=function(s){s.detail.target===o.toggleEl&&(o.triggerBtn&&o.triggerBtn.setAttribute("aria-expanded","false"),o._removeOutsideClickListener(),o._removeScrollRepositionListener(),o._removeResizeCloseListener(),o.toggleEl.style.position="",o.toggleEl.style.top="",o.toggleEl.style.left="",o.toggleEl.style.right="",o.toggleEl.style.transform="",o.toggleEl.style.margin="",o._teleportRestore&&(o._teleportRestore(),o._teleportRestore=null),v(i,"ln-dropdown:close",{target:s.detail.target}))},this.toggleEl&&(this.toggleEl.addEventListener("ln-toggle:open",this._onToggleOpen),this.toggleEl.addEventListener("ln-toggle:close",this._onToggleClose)),this}n.prototype._reposition=function(){if(!this.triggerBtn||!this.toggleEl)return;const i=this.triggerBtn.getBoundingClientRect(),o=D(this.toggleEl),s=parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--size-xs"))*16||4,d=T(i,o,"bottom-end",s);this.toggleEl.style.top=d.top+"px",this.toggleEl.style.left=d.left+"px"},n.prototype._addOutsideClickListener=function(){if(this._boundDocClick)return;const i=this;this._boundDocClick=function(o){i.dom.contains(o.target)||i.toggleEl&&i.toggleEl.contains(o.target)||i.toggleEl&&i.toggleEl.getAttribute("data-ln-toggle")==="open"&&i.toggleEl.setAttribute("data-ln-toggle","close")},i._docClickTimeout=setTimeout(function(){i._docClickTimeout=null,document.addEventListener("click",i._boundDocClick)},0)},n.prototype._removeOutsideClickListener=function(){this._docClickTimeout&&(clearTimeout(this._docClickTimeout),this._docClickTimeout=null),this._boundDocClick&&(document.removeEventListener("click",this._boundDocClick),this._boundDocClick=null)},n.prototype._addScrollRepositionListener=function(){const i=this;this._boundScrollReposition=function(){i._reposition()},window.addEventListener("scroll",this._boundScrollReposition,{passive:!0,capture:!0})},n.prototype._removeScrollRepositionListener=function(){this._boundScrollReposition&&(window.removeEventListener("scroll",this._boundScrollReposition,{capture:!0}),this._boundScrollReposition=null)},n.prototype._addResizeCloseListener=function(){const i=this;this._boundResizeClose=function(){i.toggleEl&&i.toggleEl.getAttribute("data-ln-toggle")==="open"&&i.toggleEl.setAttribute("data-ln-toggle","close")},window.addEventListener("resize",this._boundResizeClose)},n.prototype._removeResizeCloseListener=function(){this._boundResizeClose&&(window.removeEventListener("resize",this._boundResizeClose),this._boundResizeClose=null)},n.prototype.destroy=function(){this.dom[e]&&(this._removeOutsideClickListener(),this._removeScrollRepositionListener(),this._removeResizeCloseListener(),this._teleportRestore&&(this._teleportRestore(),this._teleportRestore=null),this.toggleEl&&(this.toggleEl.removeEventListener("ln-toggle:open",this._onToggleOpen),this.toggleEl.removeEventListener("ln-toggle:close",this._onToggleClose)),v(this.dom,"ln-dropdown:destroyed",{target:this.dom}),delete this.dom[e])},O(t,e,n,"ln-dropdown")})()})();
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 v={};function B(t,e){v[t]||(v[t]=document.querySelector('[data-ln-template="'+t+'"]'));const o=v[t];return o?o.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function L(t,e,o){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:o||{}}))}function x(t,e){if(!t||!e)return t;const o=t.querySelectorAll("[data-ln-field]");for(let i=0;i<o.length;i++){const r=o[i],d=r.getAttribute("data-ln-field");e[d]!=null&&(r.textContent=e[d])}const l=t.querySelectorAll("[data-ln-attr]");for(let i=0;i<l.length;i++){const r=l[i],d=r.getAttribute("data-ln-attr").split(",");for(let u=0;u<d.length;u++){const c=d[u].trim().split(":");if(c.length!==2)continue;const f=c[0].trim(),g=c[1].trim();e[g]!=null&&r.setAttribute(f,e[g])}}const n=t.querySelectorAll("[data-ln-show]");for(let i=0;i<n.length;i++){const r=n[i],d=r.getAttribute("data-ln-show");d in e&&r.classList.toggle("hidden",!e[d])}const s=t.querySelectorAll("[data-ln-class]");for(let i=0;i<s.length;i++){const r=s[i],d=r.getAttribute("data-ln-class").split(",");for(let u=0;u<d.length;u++){const c=d[u].trim().split(":");if(c.length!==2)continue;const f=c[0].trim(),g=c[1].trim();g in e&&r.classList.toggle(f,!!e[g])}}return t}function z(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const o=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let l=0;l<o.length;l++)o[l].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)x(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let o=0;o<e.length;o++)e[o].textContent=""}})));function S(t,e){if(!t||!e)return t;const o=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;o.nextNode();){const s=o.currentNode;s.textContent.indexOf("{{")!==-1&&(s.textContent=s.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(i,r){return e[r]!==void 0?e[r]:""}))}const l=function(s,i){return e[i]!==void 0?e[i]:""},n=Array.from(t.querySelectorAll("*"));t.nodeType===1&&n.push(t);for(let s=0;s<n.length;s++){const i=n[s],r=i.attributes;for(let d=0;d<r.length;d++){const u=r[d];u.value.indexOf("{{")!==-1&&i.setAttribute(u.name,u.value.replace(/\{\{\s*(\w+)\s*\}\}/g,l))}}return t}function P(t,e,o,l,n,s){const i={};for(let d=0;d<t.children.length;d++){const u=t.children[d],c=u.getAttribute("data-ln-key");c&&(i[c]=u)}const r=document.createDocumentFragment();for(let d=0;d<e.length;d++){const u=e[d],c=String(l(u));let f=i[c];if(f)n(f,u,d);else{const g=B(o,s);if(!g||(S(g,u),f=g.firstElementChild,!f))continue;f.setAttribute("data-ln-key",c),n(f,u,d)}r.appendChild(f)}t.textContent="",t.appendChild(r)}function T(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){T(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function A(t,e,o,l){if(t.nodeType!==1)return;const s=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",i=Array.from(t.querySelectorAll(s));t.matches&&t.matches(s)&&i.push(t);for(const r of i)r[o]||(r[o]=new l(r))}function M(t,e,o,l,n={}){const s=n.extraAttributes||[],i=n.onAttributeChange||null,r=n.onInit||null;function d(u){const c=u||document.body;A(c,t,e,o),r&&r(c)}return T(function(){const u=new MutationObserver(function(f){for(let g=0;g<f.length;g++){const p=f[g];if(p.type==="childList"){for(let m=0;m<p.addedNodes.length;m++){const b=p.addedNodes[m];b.nodeType===1&&(A(b,t,e,o),r&&r(b))}for(let m=0;m<p.removedNodes.length;m++){const b=p.removedNodes[m];if(b.nodeType===1){const C=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",y=Array.from(b.querySelectorAll(C));b.matches&&b.matches(C)&&y.push(b);for(let a=0;a<y.length;a++){const h=y[a];if(!document.contains(h)){const w=h[e];w&&typeof w.destroy=="function"&&w.destroy()}}}}}else p.type==="attributes"&&(i&&p.target[e]?i(p.target,p.attributeName):(A(p.target,t,e,o),r&&r(p.target)))}});let c=[];if(t.indexOf("[")!==-1){const f=/\[([\w-]+)/g;let g;for(;(g=f.exec(t))!==null;)c.push(g[1])}else c.push(t);u.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:c.concat(s)})},l),window[e]=d,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){d(document.body)}):d(document.body),d}const O={};function N(t,e){O[t]=e}function W(t){return O[t]||{ingress:e=>e,egress:e=>e}}const q={};function R(t,e){if(!t||typeof e!="object")return;const o=t.toLowerCase().split("-")[0];q[o]=e}function j(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return q[e]||null}R("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=N,window.lnCore.getDataMapper=W,window.lnCore.registerLocaleFallback=R,window.lnCore.getLocaleFallback=j,window.lnCore.fillTemplate=S,window.lnCore.fill=x,window.lnCore.lnFill=z,window.lnCore.renderList=P);function D(t){return(t||"").replace(/^#/,"")}function k(t){const e=t===void 0?location.hash:t,o={},l=D(e);if(!l)return o;const n=l.split("&");for(let s=0;s<n.length;s++){const i=n[s];if(!i)continue;const r=i.indexOf(":"),d=r>-1?i.slice(0,r):i,u=r>-1?i.slice(r+1):"";if(d)try{o[d]=decodeURIComponent(u)}catch{o[d]=u}}return o}function F(t){if(!t)return null;const e=k();return t in e?e[t]:null}function I(t,e){if(!t)return;const o=k();e==null?delete o[t]:o[t]=String(e);const n=Object.keys(o).map(function(s){const i=o[s];return i===""?s:s+":"+encodeURIComponent(i)}).join("&");D(location.hash)!==n&&(location.hash=n)}function H(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=k,window.lnCore.hashGet=F,window.lnCore.hashSet=I,window.lnCore.hashLinkClick=H);function K(t,e,o,l){const n=typeof l=="number"?l:4,s=window.innerWidth,i=window.innerHeight,r=e.width,d=e.height,u=o.split("-"),c=u[0],f=u[1]==="start"||u[1]==="end"?u[1]:"center",g={top:["top","bottom","right","left"],bottom:["bottom","top","right","left"],left:["left","right","top","bottom"],right:["right","left","top","bottom"]},p=g[c]||g.bottom;function m(a){return a==="top"||a==="bottom"?f==="start"?t.left:f==="end"?t.right-r:t.left+(t.width-r)/2:f==="start"?t.top:f==="end"?t.bottom-d:t.top+(t.height-d)/2}function b(a){let h,w,_=!0;return a==="top"?(h=t.top-n-d,w=m(a),h<0&&(_=!1)):a==="bottom"?(h=t.bottom+n,w=m(a),h+d>i&&(_=!1)):a==="left"?(h=m(a),w=t.left-n-r,w<0&&(_=!1)):(h=m(a),w=t.right+n,w+r>s&&(_=!1)),{top:h,left:w,side:a,fits:_}}let E=null;for(let a=0;a<p.length;a++){const h=b(p[a]);if(h.fits){E=h;break}}E||(E=b(p[0]));let C=E.top,y=E.left;return r>=s?y=0:(y<0&&(y=0),y+r>s&&(y=s-r)),d>=i?C=0:(C<0&&(C=0),C+d>i&&(C=i-d)),{top:C,left:y,placement:E.side}}function U(t){if(!t)return{width:0,height:0};const e=t.style,o=e.visibility,l=e.display,n=e.position;e.visibility="hidden",e.display="block",e.position="fixed";const s=t.offsetWidth,i=t.offsetHeight;return e.visibility=o,e.display=l,e.position=n,{width:s,height:i}}(function(){const t="data-ln-dropdown",e="lnDropdown";if(window[e]!==void 0)return;function o(l){if(this.dom=l,this.toggleEl=l.querySelector("[data-ln-toggle]"),this._boundDocClick=null,this._docClickTimeout=null,this._boundScrollReposition=null,this._boundResizeClose=null,this.toggleEl&&(this.toggleEl.setAttribute("data-ln-dropdown-menu",""),this.toggleEl.setAttribute("role","menu"),this.toggleEl.setAttribute("popover","manual")),this.triggerBtn=l.querySelector("[data-ln-toggle-for]"),this.triggerBtn&&(this.triggerBtn.setAttribute("aria-haspopup","menu"),this.triggerBtn.setAttribute("aria-expanded","false")),this.toggleEl)for(const s of this.toggleEl.children)s.setAttribute("role","menuitem");const n=this;return this._onRequestOpen=function(){n.toggleEl&&n.toggleEl.setAttribute("data-ln-toggle","open")},this._onRequestClose=function(){n.toggleEl&&n.toggleEl.setAttribute("data-ln-toggle","close")},this._onRequestToggle=function(){if(n.toggleEl){const s=n.toggleEl.getAttribute("data-ln-toggle");n.toggleEl.setAttribute("data-ln-toggle",s==="open"?"close":"open")}},this.dom.addEventListener("ln-dropdown:request-open",this._onRequestOpen),this.dom.addEventListener("ln-dropdown:request-close",this._onRequestClose),this.dom.addEventListener("ln-dropdown:request-toggle",this._onRequestToggle),this._onToggleOpen=function(s){!s.detail||s.detail.target!==n.toggleEl||(n.triggerBtn&&n.triggerBtn.setAttribute("aria-expanded","true"),typeof n.toggleEl.showPopover=="function"&&n.toggleEl.showPopover(),n._reposition(),n._addOutsideClickListener(),n._addScrollRepositionListener(),n._addResizeCloseListener(),L(l,"ln-dropdown:open",{target:s.detail.target}))},this._onToggleClose=function(s){!s.detail||s.detail.target!==n.toggleEl||(n.triggerBtn&&n.triggerBtn.setAttribute("aria-expanded","false"),n._removeOutsideClickListener(),n._removeScrollRepositionListener(),n._removeResizeCloseListener(),n.toggleEl.style.top="",n.toggleEl.style.left="",typeof n.toggleEl.hidePopover=="function"&&n.toggleEl.matches(":popover-open")&&n.toggleEl.hidePopover(),L(l,"ln-dropdown:close",{target:s.detail.target}))},this.toggleEl&&(this.toggleEl.addEventListener("ln-toggle:open",this._onToggleOpen),this.toggleEl.addEventListener("ln-toggle:close",this._onToggleClose)),this}o.prototype._reposition=function(){if(!this.triggerBtn||!this.toggleEl)return;const l=this.triggerBtn.getBoundingClientRect(),n=U(this.toggleEl),s=parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--size-xs"))*16||4,i=K(l,n,"bottom-end",s);this.toggleEl.style.top=i.top+"px",this.toggleEl.style.left=i.left+"px"},o.prototype._addOutsideClickListener=function(){if(this._boundDocClick)return;const l=this;this._boundDocClick=function(n){l.dom.contains(n.target)||l.toggleEl&&l.toggleEl.contains(n.target)||l.toggleEl&&l.toggleEl.getAttribute("data-ln-toggle")==="open"&&l.toggleEl.setAttribute("data-ln-toggle","close")},l._docClickTimeout=setTimeout(function(){l._docClickTimeout=null,document.addEventListener("click",l._boundDocClick)},0)},o.prototype._removeOutsideClickListener=function(){this._docClickTimeout&&(clearTimeout(this._docClickTimeout),this._docClickTimeout=null),this._boundDocClick&&(document.removeEventListener("click",this._boundDocClick),this._boundDocClick=null)},o.prototype._addScrollRepositionListener=function(){const l=this;this._boundScrollReposition=function(){l._reposition()},window.addEventListener("scroll",this._boundScrollReposition,{passive:!0,capture:!0})},o.prototype._removeScrollRepositionListener=function(){this._boundScrollReposition&&(window.removeEventListener("scroll",this._boundScrollReposition,{capture:!0}),this._boundScrollReposition=null)},o.prototype._addResizeCloseListener=function(){const l=this;this._boundResizeClose=function(){l.toggleEl&&l.toggleEl.getAttribute("data-ln-toggle")==="open"&&l.toggleEl.setAttribute("data-ln-toggle","close")},window.addEventListener("resize",this._boundResizeClose)},o.prototype._removeResizeCloseListener=function(){this._boundResizeClose&&(window.removeEventListener("resize",this._boundResizeClose),this._boundResizeClose=null)},o.prototype.destroy=function(){this.dom[e]&&(this.dom.removeEventListener("ln-dropdown:request-open",this._onRequestOpen),this.dom.removeEventListener("ln-dropdown:request-close",this._onRequestClose),this.dom.removeEventListener("ln-dropdown:request-toggle",this._onRequestToggle),this._removeOutsideClickListener(),this._removeScrollRepositionListener(),this._removeResizeCloseListener(),this.toggleEl&&typeof this.toggleEl.hidePopover=="function"&&this.toggleEl.matches(":popover-open")&&this.toggleEl.hidePopover(),this.toggleEl&&(this.toggleEl.removeEventListener("ln-toggle:open",this._onToggleOpen),this.toggleEl.removeEventListener("ln-toggle:close",this._onToggleClose)),L(this.dom,"ln-dropdown:destroyed",{target:this.dom}),delete this.dom[e])},M(t,e,o,"ln-dropdown")})()})();
@@ -1,4 +1,4 @@
1
- import { dispatch, computePlacement, teleportToBody, measureHidden, registerComponent } from '../../ln-core';
1
+ import { dispatch, computePlacement, measureHidden, registerComponent } from '../../ln-core';
2
2
 
3
3
  (function () {
4
4
  const DOM_SELECTOR = 'data-ln-dropdown';
@@ -11,7 +11,6 @@ import { dispatch, computePlacement, teleportToBody, measureHidden, registerComp
11
11
  function _component(dom) {
12
12
  this.dom = dom;
13
13
  this.toggleEl = dom.querySelector('[data-ln-toggle]');
14
- this._teleportRestore = null;
15
14
  this._boundDocClick = null;
16
15
  this._docClickTimeout = null;
17
16
  this._boundScrollReposition = null;
@@ -20,6 +19,7 @@ import { dispatch, computePlacement, teleportToBody, measureHidden, registerComp
20
19
  if (this.toggleEl) {
21
20
  this.toggleEl.setAttribute('data-ln-dropdown-menu', '');
22
21
  this.toggleEl.setAttribute('role', 'menu');
22
+ this.toggleEl.setAttribute('popover', 'manual');
23
23
  }
24
24
 
25
25
  // ARIA on trigger button
@@ -38,12 +38,27 @@ import { dispatch, computePlacement, teleportToBody, measureHidden, registerComp
38
38
 
39
39
  const self = this;
40
40
 
41
+ this._onRequestOpen = function () {
42
+ if (self.toggleEl) self.toggleEl.setAttribute('data-ln-toggle', 'open');
43
+ };
44
+ this._onRequestClose = function () {
45
+ if (self.toggleEl) self.toggleEl.setAttribute('data-ln-toggle', 'close');
46
+ };
47
+ this._onRequestToggle = function () {
48
+ if (self.toggleEl) {
49
+ const current = self.toggleEl.getAttribute('data-ln-toggle');
50
+ self.toggleEl.setAttribute('data-ln-toggle', current === 'open' ? 'close' : 'open');
51
+ }
52
+ };
53
+
54
+ this.dom.addEventListener('ln-dropdown:request-open', this._onRequestOpen);
55
+ this.dom.addEventListener('ln-dropdown:request-close', this._onRequestClose);
56
+ this.dom.addEventListener('ln-dropdown:request-toggle', this._onRequestToggle);
57
+
41
58
  this._onToggleOpen = function (e) {
42
- if (e.detail.target !== self.toggleEl) return;
59
+ if (!e.detail || e.detail.target !== self.toggleEl) return;
43
60
  if (self.triggerBtn) self.triggerBtn.setAttribute('aria-expanded', 'true');
44
- self._teleportRestore = teleportToBody(self.toggleEl);
45
- self.toggleEl.style.position = 'fixed';
46
- self.toggleEl.style.right = 'auto';
61
+ if (typeof self.toggleEl.showPopover === 'function') self.toggleEl.showPopover();
47
62
  self._reposition();
48
63
  self._addOutsideClickListener();
49
64
  self._addScrollRepositionListener();
@@ -52,18 +67,15 @@ import { dispatch, computePlacement, teleportToBody, measureHidden, registerComp
52
67
  };
53
68
 
54
69
  this._onToggleClose = function (e) {
55
- if (e.detail.target !== self.toggleEl) return;
70
+ if (!e.detail || e.detail.target !== self.toggleEl) return;
56
71
  if (self.triggerBtn) self.triggerBtn.setAttribute('aria-expanded', 'false');
57
72
  self._removeOutsideClickListener();
58
73
  self._removeScrollRepositionListener();
59
74
  self._removeResizeCloseListener();
60
- self.toggleEl.style.position = '';
61
75
  self.toggleEl.style.top = '';
62
76
  self.toggleEl.style.left = '';
63
- self.toggleEl.style.right = '';
64
- self.toggleEl.style.transform = '';
65
- self.toggleEl.style.margin = '';
66
- if (self._teleportRestore) { self._teleportRestore(); self._teleportRestore = null; }
77
+ // :popover-open guard — a boot-opened menu (persist/static "open") was never shown via showPopover()
78
+ if (typeof self.toggleEl.hidePopover === 'function' && self.toggleEl.matches(':popover-open')) self.toggleEl.hidePopover();
67
79
  dispatch(dom, 'ln-dropdown:close', { target: e.detail.target });
68
80
  };
69
81
 
@@ -156,10 +168,15 @@ import { dispatch, computePlacement, teleportToBody, measureHidden, registerComp
156
168
 
157
169
  _component.prototype.destroy = function () {
158
170
  if (!this.dom[DOM_ATTRIBUTE]) return;
171
+ this.dom.removeEventListener('ln-dropdown:request-open', this._onRequestOpen);
172
+ this.dom.removeEventListener('ln-dropdown:request-close', this._onRequestClose);
173
+ this.dom.removeEventListener('ln-dropdown:request-toggle', this._onRequestToggle);
159
174
  this._removeOutsideClickListener();
160
175
  this._removeScrollRepositionListener();
161
176
  this._removeResizeCloseListener();
162
- if (this._teleportRestore) { this._teleportRestore(); this._teleportRestore = null; }
177
+ if (this.toggleEl && typeof this.toggleEl.hidePopover === 'function' && this.toggleEl.matches(':popover-open')) {
178
+ this.toggleEl.hidePopover();
179
+ }
163
180
  if (this.toggleEl) {
164
181
  this.toggleEl.removeEventListener('ln-toggle:open', this._onToggleOpen);
165
182
  this.toggleEl.removeEventListener('ln-toggle:close', this._onToggleClose);
@@ -0,0 +1,274 @@
1
+ # ln-editor
2
+
3
+ Lightweight WYSIWYG rich text editor. Enhances a `<textarea>` into a `contentEditable` editing surface with a toolbar. Progressive enhancement: without JS, the textarea works as plain text.
4
+
5
+ ## Quick Start
6
+
7
+ ```html
8
+ <div data-ln-editor>
9
+ <div role="toolbar" aria-label="Text formatting">
10
+ <ul>
11
+ <li><button type="button" data-ln-editor-action="bold" aria-label="Bold">
12
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-bold"></use></svg>
13
+ </button></li>
14
+ <li><button type="button" data-ln-editor-action="italic" aria-label="Italic">
15
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-italic"></use></svg>
16
+ </button></li>
17
+ </ul>
18
+ </div>
19
+ <textarea name="content" placeholder="Write something..."></textarea>
20
+ </div>
21
+ ```
22
+
23
+ ## Attributes
24
+
25
+ | Attribute | On element | Owned by | Description |
26
+ |-----------|-----------|----------|-------------|
27
+ | `data-ln-editor` | Container `<div>` | `ln-editor` | Marks the container as an editor instance |
28
+ | `data-ln-editor-action` | Toolbar `<button>` | `ln-editor` | Identifies which formatting action the button triggers |
29
+ | `data-ln-editor-source` | `<textarea>` | `ln-editor` (auto-set) | JS marks the textarea for CSS hiding. Removed on `destroy()` |
30
+
31
+ ## Toolbar Actions
32
+
33
+ | `data-ln-editor-action` | Effect | Keyboard Shortcut |
34
+ |--------------------------|--------|-------------------|
35
+ | `bold` | Toggle bold | Ctrl+B |
36
+ | `italic` | Toggle italic | Ctrl+I |
37
+ | `underline` | Toggle underline | Ctrl+U |
38
+ | `strikethrough` | Toggle strikethrough | — |
39
+ | `heading-2` | Toggle H2 | — |
40
+ | `heading-3` | Toggle H3 | — |
41
+ | `heading-4` | Toggle H4 | — |
42
+ | `blockquote` | Toggle blockquote | — |
43
+ | `code` | Toggle code block | — |
44
+ | `ordered-list` | Toggle numbered list | — |
45
+ | `unordered-list` | Toggle bullet list | — |
46
+ | `link` | Insert/edit link (inline popover) | Ctrl+K |
47
+ | `unlink` | Remove link | — |
48
+ | `clear` | Remove all formatting | — |
49
+ | `paragraph` | Reset block to paragraph — applied internally by `clear`; no default toolbar exposes it as its own button | — |
50
+
51
+ ## Toolbar Structure
52
+
53
+ The toolbar is **authored HTML** — the consumer controls which buttons appear, their order, icons, and labels.
54
+
55
+ ```html
56
+ <div role="toolbar" aria-label="Text formatting">
57
+ <!-- Group 1: Inline formatting -->
58
+ <ul>
59
+ <li><button type="button" data-ln-editor-action="bold" aria-label="Bold">
60
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-bold"></use></svg>
61
+ </button></li>
62
+ <li><button type="button" data-ln-editor-action="italic" aria-label="Italic">
63
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-italic"></use></svg>
64
+ </button></li>
65
+ <li><button type="button" data-ln-editor-action="underline" aria-label="Underline">
66
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-underline"></use></svg>
67
+ </button></li>
68
+ </ul>
69
+
70
+ <!-- Group 2: Block formatting -->
71
+ <ul>
72
+ <li><button type="button" data-ln-editor-action="heading-2" aria-label="Heading 2">H2</button></li>
73
+ <li><button type="button" data-ln-editor-action="heading-3" aria-label="Heading 3">H3</button></li>
74
+ </ul>
75
+
76
+ <!-- Group 3: Lists -->
77
+ <ul>
78
+ <li><button type="button" data-ln-editor-action="unordered-list" aria-label="Bullet list">
79
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-list"></use></svg>
80
+ </button></li>
81
+ <li><button type="button" data-ln-editor-action="ordered-list" aria-label="Numbered list">
82
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-list-numbers"></use></svg>
83
+ </button></li>
84
+ </ul>
85
+
86
+ <!-- Group 4: Insert -->
87
+ <ul>
88
+ <li><button type="button" data-ln-editor-action="link" aria-label="Insert link">
89
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-link"></use></svg>
90
+ </button></li>
91
+ </ul>
92
+ </div>
93
+ ```
94
+
95
+ Multiple `<ul>` elements create visual groups with separators between them.
96
+
97
+ ### Accessibility
98
+
99
+ Mark the toolbar wrapper with `role="toolbar"` and an `aria-label`. The
100
+ component links it to the editing surface via `aria-controls`, and manages
101
+ `aria-pressed` on every toggle-format button (bold, italic, headings,
102
+ lists, link) so assistive technology announces the active state as the
103
+ cursor moves. One-shot actions (`unlink`, `clear`) receive no `aria-pressed`.
104
+ You author the buttons; the component owns the ARIA state. See the sync
105
+ mechanism in [🔧 Internals](#-internals).
106
+
107
+ ## Link Popover Template
108
+
109
+ If you include the `link` action in the toolbar, you must define a `<template data-ln-template="ln-editor-link-popover">` on the page. The editor clones this template to render the inline link input popover.
110
+
111
+ This authored-markup design keeps the popover structure fully customizable and allows localization directly in the HTML:
112
+
113
+ ```html
114
+ <template data-ln-template="ln-editor-link-popover">
115
+ <div class="ln-editor__link-popover">
116
+ <input type="url" placeholder="https://…" />
117
+ <button type="button" data-ln-editor-action="confirm-link" aria-label="Confirm" title="Confirm">
118
+ <svg class="ln-icon ln-icon--sm" aria-hidden="true"><use href="#ln-icon-check"></use></svg>
119
+ </button>
120
+ <button type="button" data-ln-editor-action="cancel-link" aria-label="Cancel" title="Cancel">
121
+ <svg class="ln-icon ln-icon--sm" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
122
+ </button>
123
+ </div>
124
+ </template>
125
+ ```
126
+
127
+ ## Events
128
+
129
+ | Event | Type | Detail | When |
130
+ |-------|------|--------|------|
131
+ | `ln-editor:changed` | Notification | `{ html, target }` | Fires exactly once per content mutation — typing, paste, formatting, link apply, programmatic `set-content`/`setHTML`, or form reset |
132
+ | `ln-editor:before-change` | Lifecycle (cancelable) | `{ action, target }` | Before a formatting command |
133
+ | `ln-editor:focus` | Notification | `{ target }` | Editing surface focused |
134
+ | `ln-editor:blur` | Notification | `{ target }` | Editing surface blurred |
135
+ | `ln-editor:set-content` | Request | `{ html }` | Set content programmatically — also emits `ln-editor:changed` |
136
+ | `ln-editor:destroyed` | Notification | `{ target }` | Instance destroyed |
137
+
138
+ ## API
139
+
140
+ ```javascript
141
+ const el = document.querySelector('[data-ln-editor]');
142
+
143
+ // Read current HTML
144
+ el.lnEditor.getHTML();
145
+
146
+ // Set content programmatically
147
+ el.lnEditor.setHTML('<p>New content</p>');
148
+
149
+ // Or via request event (coordinator pattern)
150
+ el.dispatchEvent(new CustomEvent('ln-editor:set-content', {
151
+ detail: { html: '<p>New content</p>' }
152
+ }));
153
+
154
+ // Destroy instance — restores textarea visibility
155
+ el.lnEditor.destroy();
156
+ ```
157
+
158
+ ## Form Integration
159
+
160
+ The editor syncs content to the hidden `<textarea>` on every input, paste, and formatting action. On form submit, the textarea contains the current HTML.
161
+
162
+ ```html
163
+ <form data-ln-form>
164
+ <div class="form-element">
165
+ <label for="article-body">Content</label>
166
+ <div data-ln-editor>
167
+ <div role="toolbar" aria-label="Text formatting"><!-- toolbar --></div>
168
+ <textarea id="article-body" name="body" required></textarea>
169
+ </div>
170
+ </div>
171
+ <div class="form-actions">
172
+ <button type="submit">Save</button>
173
+ </div>
174
+ </form>
175
+ ```
176
+
177
+ ## Pre-filled Content
178
+
179
+ Server-rendered HTML in the textarea value is used as initial editor content:
180
+
181
+ ```html
182
+ <div data-ln-editor>
183
+ <div role="toolbar" aria-label="Text formatting"><!-- toolbar --></div>
184
+ <textarea name="content"><p>This <strong>pre-filled</strong> content appears in the editor.</p></textarea>
185
+ </div>
186
+ ```
187
+
188
+ ## Paste Handling
189
+
190
+ Content pasted from external sources (Word, web pages) is sanitized to a safe HTML subset — unsafe tags and attributes are stripped and links are made safe automatically. For the exact tag whitelist and sanitization algorithm see [🔧 Internals — Paste sanitization](#paste-sanitization).
191
+
192
+ ## Minimal Toolbar
193
+
194
+ Include only the buttons you need:
195
+
196
+ ```html
197
+ <div data-ln-editor>
198
+ <div role="toolbar" aria-label="Text formatting">
199
+ <ul>
200
+ <li><button type="button" data-ln-editor-action="bold" aria-label="Bold">
201
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-bold"></use></svg>
202
+ </button></li>
203
+ <li><button type="button" data-ln-editor-action="italic" aria-label="Italic">
204
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-italic"></use></svg>
205
+ </button></li>
206
+ <li><button type="button" data-ln-editor-action="link" aria-label="Link">
207
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-link"></use></svg>
208
+ </button></li>
209
+ </ul>
210
+ </div>
211
+ <textarea name="comment" placeholder="Write a comment..."></textarea>
212
+ </div>
213
+ ```
214
+
215
+ ## SCSS
216
+
217
+ Visual styling uses the two-layer architecture:
218
+
219
+ - **Mixin:** `scss/config/mixins/_editor.scss` — `@mixin editor`
220
+ - **Component:** `scss/components/_editor.scss` — applies to `[data-ln-editor]`
221
+ - **Co-located:** `js/ln-editor/ln-editor.scss` — link popover (runtime-cloned from the authored `<template data-ln-template="ln-editor-link-popover">`)
222
+
223
+ The editing surface uses `@include prose` — content looks identical to how it will render in a `.prose` container.
224
+
225
+ Override with your own selector:
226
+
227
+ ```scss
228
+ #my-editor {
229
+ @include editor;
230
+ // Custom overrides...
231
+
232
+ > .ln-editor__surface {
233
+ min-height: 20rem;
234
+ }
235
+ }
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 🔧 Internals
241
+
242
+ Source: `js/ln-editor/ln-editor.js`. No cached formatting state — every toolbar sync re-queries `document.queryCommandState` and DOM ancestry on `selectionchange`.
243
+
244
+ ### Construction
245
+
246
+ Finds the `<textarea>`, reads its value as the initial HTML, builds a `contentEditable` `<div class="ln-editor__surface">` with a stable id, transfers any `<label for>` association via `aria-labelledby`, marks the textarea `data-ln-editor-source` (CSS-hidden), inserts the surface after the toolbar, wires `aria-controls` from toolbar to surface, and seeds `aria-pressed="false"` on toggle-format buttons.
247
+
248
+ ### Content sync
249
+
250
+ One-way, continuous: every surface `input` event copies `innerHTML` into the textarea's `value` and dispatches `ln-editor:changed`. The textarea is never the source of truth after construction — it is a submit-time mirror.
251
+
252
+ ### Formatting flow
253
+
254
+ `mousedown` on a toolbar button calls `preventDefault()` first, to preserve the current selection before the surface loses focus; the actual command runs on `click`: dispatch cancelable `ln-editor:before-change` → `surface.focus()` + `document.execCommand(...)` → the resulting native `input` event does the sync and dispatches `ln-editor:changed` exactly once → toolbar active/`aria-pressed` states resync.
255
+
256
+ ### Active state tracking
257
+
258
+ On every document `selectionchange`: bail if the selection isn't inside the surface, then per toggle button check `queryCommandState` (inline), a block-ancestor walk (headings/blockquote), `queryCommandState('insertOrderedList'/...)` (lists), or anchor ancestry (link). The handler also early-returns if the surface has been detached from the document without `destroy()` (e.g. an SPA subtree swap) — a later reattachment resumes working without re-init.
259
+
260
+ ### Paste sanitization
261
+
262
+ `e.preventDefault()`, read `clipboardData` HTML (falling back to plain text with `\n` → `<br>`/`<p>`), parse into a detached `<div>`, then a recursive walk: allowed tags keep only `href` (validated against `https?:`/`mailto:`/`/`/`#`) and recurse into children; disallowed tags are unwrapped (replaced by their children) and recursion continues; text nodes pass through untouched. The result is inserted via `execCommand('insertHTML', ...)`. Allowed tags: `P BR STRONG B EM I U S A UL OL LI H2 H3 H4 BLOCKQUOTE PRE CODE DIV`.
263
+
264
+ ### Link insertion
265
+
266
+ Requires a page-authored `<template data-ln-template="ln-editor-link-popover">` — the editor never generates this markup. Flow: save the current selection range, detect if already inside an `<a>` (pre-fill), clone the template via `cloneTemplateScoped`, insert it after the toolbar; on confirm, restore the saved range and either update the existing link's `href` or run `execCommand('createLink')` (adding `rel="noopener noreferrer"` for new links) — either path dispatches `ln-editor:changed` exactly once. A missing template makes the link action a silent no-op.
267
+
268
+ ### Destroy
269
+
270
+ Removes all surface/toolbar/document listeners, removes the surface node, restores textarea visibility (removes `data-ln-editor-source`), removes any open link popover, dispatches `ln-editor:destroyed`.
271
+
272
+ ### Permanent constraints
273
+
274
+ `execCommand` is deprecated but functional across current browsers; no custom undo/redo stack (relies on native); no table or media embedding support.
@@ -0,0 +1 @@
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 k={};function O(e,t){k[e]||(k[e]=document.querySelector('[data-ln-template="'+e+'"]'));const i=k[e];return i?i.content.cloneNode(!0):(console.warn("["+(t||"ln-core")+'] Template "'+e+'" not found'),null)}function T(e,t,i){e.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:i||{}}))}function G(e,t,i){const f=new CustomEvent(t,{bubbles:!0,cancelable:!0,detail:i||{}});return e.dispatchEvent(f),f}function q(e,t){if(!e||!t)return e;const i=e.querySelectorAll("[data-ln-field]");for(let l=0;l<i.length;l++){const c=i[l],a=c.getAttribute("data-ln-field");t[a]!=null&&(c.textContent=t[a])}const f=e.querySelectorAll("[data-ln-attr]");for(let l=0;l<f.length;l++){const c=f[l],a=c.getAttribute("data-ln-attr").split(",");for(let h=0;h<a.length;h++){const p=a[h].trim().split(":");if(p.length!==2)continue;const b=p[0].trim(),y=p[1].trim();t[y]!=null&&c.setAttribute(b,t[y])}}const m=e.querySelectorAll("[data-ln-show]");for(let l=0;l<m.length;l++){const c=m[l],a=c.getAttribute("data-ln-show");a in t&&c.classList.toggle("hidden",!t[a])}const d=e.querySelectorAll("[data-ln-class]");for(let l=0;l<d.length;l++){const c=d[l],a=c.getAttribute("data-ln-class").split(",");for(let h=0;h<a.length;h++){const p=a[h].trim().split(":");if(p.length!==2)continue;const b=p[0].trim(),y=p[1].trim();y in t&&c.classList.toggle(b,!!t[y])}}return e}function z(e,t){e.matches&&e.matches("[data-ln-form], [data-ln-fillable]")&&e.dispatchEvent(new CustomEvent("ln-fill",{detail:t??null,bubbles:!0}));const i=e.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let f=0;f<i.length;f++)i[f].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)q(e.target,e.detail);else{const t=e.target.querySelectorAll("[data-ln-field]");for(let i=0;i<t.length;i++)t[i].textContent=""}})));function D(e,t){if(!e||!t)return e;const i=document.createTreeWalker(e,NodeFilter.SHOW_TEXT);for(;i.nextNode();){const d=i.currentNode;d.textContent.indexOf("{{")!==-1&&(d.textContent=d.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(l,c){return t[c]!==void 0?t[c]:""}))}const f=function(d,l){return t[l]!==void 0?t[l]:""},m=Array.from(e.querySelectorAll("*"));e.nodeType===1&&m.push(e);for(let d=0;d<m.length;d++){const l=m[d],c=l.attributes;for(let a=0;a<c.length;a++){const h=c[a];h.value.indexOf("{{")!==-1&&l.setAttribute(h.name,h.value.replace(/\{\{\s*(\w+)\s*\}\}/g,f))}}return e}function Q(e,t,i,f,m,d){const l={};for(let a=0;a<e.children.length;a++){const h=e.children[a],p=h.getAttribute("data-ln-key");p&&(l[p]=h)}const c=document.createDocumentFragment();for(let a=0;a<t.length;a++){const h=t[a],p=String(f(h));let b=l[p];if(b)m(b,h,a);else{const y=O(i,d);if(!y||(D(y,h),b=y.firstElementChild,!b))continue;b.setAttribute("data-ln-key",p),m(b,h,a)}c.appendChild(b)}e.textContent="",e.appendChild(c)}function B(e,t){if(!document.body){document.addEventListener("DOMContentLoaded",function(){B(e,t)}),console.warn("["+t+'] Script loaded before <body> — add "defer" to your <script> tag');return}e()}function V(e,t,i){if(e){const f=e.querySelector('[data-ln-template="'+t+'"]');if(f)return f.content.cloneNode(!0)}return O(t,i)}function E(e,t,i,f){if(e.nodeType!==1)return;const d=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",l=Array.from(e.querySelectorAll(d));e.matches&&e.matches(d)&&l.push(e);for(const c of l)c[i]||(c[i]=new f(c))}function X(e,t,i,f,m={}){const d=m.extraAttributes||[],l=m.onAttributeChange||null,c=m.onInit||null;function a(h){const p=h||document.body;E(p,e,t,i),c&&c(p)}return B(function(){const h=new MutationObserver(function(b){for(let y=0;y<b.length;y++){const C=b[y];if(C.type==="childList"){for(let A=0;A<C.addedNodes.length;A++){const L=C.addedNodes[A];L.nodeType===1&&(E(L,e,t,i),c&&c(L))}for(let A=0;A<C.removedNodes.length;A++){const L=C.removedNodes[A];if(L.nodeType===1){const o=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",r=Array.from(L.querySelectorAll(o));L.matches&&L.matches(o)&&r.push(L);for(let s=0;s<r.length;s++){const w=r[s];if(!document.contains(w)){const g=w[t];g&&typeof g.destroy=="function"&&g.destroy()}}}}}else C.type==="attributes"&&(l&&C.target[t]?l(C.target,C.attributeName):(E(C.target,e,t,i),c&&c(C.target)))}});let p=[];if(e.indexOf("[")!==-1){const b=/\[([\w-]+)/g;let y;for(;(y=b.exec(e))!==null;)p.push(y[1])}else p.push(e);h.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:p.concat(d)})},f),window[t]=a,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){a(document.body)}):a(document.body),a}const H={};function J(e,t){H[e]=t}function Y(e){return H[e]||{ingress:t=>t,egress:t=>t}}const R={};function I(e,t){if(!e||typeof t!="object")return;const i=e.toLowerCase().split("-")[0];R[i]=t}function Z(e){if(!e)return null;const t=e.toLowerCase().split("-")[0];return R[t]||null}I("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=J,window.lnCore.getDataMapper=Y,window.lnCore.registerLocaleFallback=I,window.lnCore.getLocaleFallback=Z,window.lnCore.fillTemplate=D,window.lnCore.fill=q,window.lnCore.lnFill=z,window.lnCore.renderList=Q);function F(e){return(e||"").replace(/^#/,"")}function N(e){const t=e===void 0?location.hash:e,i={},f=F(t);if(!f)return i;const m=f.split("&");for(let d=0;d<m.length;d++){const l=m[d];if(!l)continue;const c=l.indexOf(":"),a=c>-1?l.slice(0,c):l,h=c>-1?l.slice(c+1):"";if(a)try{i[a]=decodeURIComponent(h)}catch{i[a]=h}}return i}function $(e){if(!e)return null;const t=N();return e in t?t[e]:null}function ee(e,t){if(!e)return;const i=N();t==null?delete i[e]:i[e]=String(t);const m=Object.keys(i).map(function(d){const l=i[d];return l===""?d:d+":"+encodeURIComponent(l)}).join("&");F(location.hash)!==m&&(location.hash=m)}function te(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=N,window.lnCore.hashGet=$,window.lnCore.hashSet=ee,window.lnCore.hashLinkClick=te),(function(){const e="data-ln-editor",t="lnEditor";if(window[t]!==void 0)return;const i={P:!0,BR:!0,STRONG:!0,B:!0,EM:!0,I:!0,U:!0,S:!0,A:!0,UL:!0,OL:!0,LI:!0,H2:!0,H3:!0,H4:!0,BLOCKQUOTE:!0,PRE:!0,CODE:!0,DIV:!0},f={bold:"bold",italic:"italic",underline:"underline",strikethrough:"strikeThrough"},m={"heading-2":"h2","heading-3":"h3","heading-4":"h4",blockquote:"blockquote",code:"pre",paragraph:"p"},d={"ordered-list":"insertOrderedList","unordered-list":"insertUnorderedList"};let l=0;function c(n){return!!(f[n]||m[n]||d[n]||n==="link")}function a(n){this.dom=n;const o=this;if(this._textarea=n.querySelector("textarea"),!this._textarea)return console.warn("[ln-editor] No <textarea> found inside",n),this;const r=this._textarea.getAttribute("placeholder")||"";this._textarea.setAttribute("data-ln-editor-source",""),this._surface=document.createElement("div"),this._surface.className="ln-editor__surface",this._surface.setAttribute("contenteditable","true"),this._surface.setAttribute("role","textbox"),this._surface.setAttribute("aria-multiline","true"),r&&this._surface.setAttribute("data-placeholder",r);const s=this._textarea.id;if(s){const u=n.querySelector('label[for="'+s+'"]');u&&(u.id||(u.id=s+"-label"),this._surface.setAttribute("aria-labelledby",u.id))}this._surface.id=s?s+"-surface":"ln-editor-surface-"+ ++l;const w=this._textarea.value.trim();w&&(this._surface.innerHTML=w);const g=n.querySelector('[role="toolbar"]');if(g&&g.nextSibling?n.insertBefore(this._surface,g.nextSibling):n.appendChild(this._surface),g){g.setAttribute("aria-controls",this._surface.id);const u=g.querySelectorAll("[data-ln-editor-action]");for(let v=0;v<u.length;v++){const S=u[v].getAttribute("data-ln-editor-action");c(S)&&u[v].setAttribute("aria-pressed","false")}}this._onInput=function(){o._syncToTextarea(),T(o.dom,"ln-editor:changed",{html:o._textarea.value,target:o.dom})},this._onMousedownToolbar=function(u){u.target.closest("[data-ln-editor-action]")&&u.preventDefault()},this._onClickToolbar=function(u){const v=u.target.closest("[data-ln-editor-action]");if(!v)return;const S=v.getAttribute("data-ln-editor-action");o._execAction(S)},this._onPaste=function(u){b(o,u)},this._onKeydown=function(u){A(o,u)},this._onSelectionChange=function(){document.contains(o._surface)&&o._updateActiveStates()},this._onFocus=function(){T(o.dom,"ln-editor:focus",{target:o.dom})},this._onBlur=function(){o._syncToTextarea(),T(o.dom,"ln-editor:blur",{target:o.dom})},this._surface.addEventListener("input",this._onInput),this._surface.addEventListener("paste",this._onPaste),this._surface.addEventListener("keydown",this._onKeydown),this._surface.addEventListener("focus",this._onFocus),this._surface.addEventListener("blur",this._onBlur),g&&(g.addEventListener("mousedown",this._onMousedownToolbar),g.addEventListener("click",this._onClickToolbar)),document.addEventListener("selectionchange",this._onSelectionChange),this._onSetContent=function(u){const v=u.detail&&u.detail.html;v!==void 0&&(o._surface.innerHTML=v,o._syncToTextarea(),T(o.dom,"ln-editor:changed",{html:o._textarea.value,target:o.dom}))},n.addEventListener("ln-editor:set-content",this._onSetContent);const _=this._textarea.form;return _&&(this._onFormReset=function(){setTimeout(function(){o._surface.innerHTML=o._textarea.value,T(n,"ln-editor:changed",{html:o._textarea.value,target:n})},0)},_.addEventListener("reset",this._onFormReset)),this}a.prototype._syncToTextarea=function(){this._textarea&&(this._textarea.value=this._surface.innerHTML)},a.prototype._execAction=function(n){if(!(!n||G(this.dom,"ln-editor:before-change",{action:n,target:this.dom}).defaultPrevented)){if(this._surface.focus(),f[n])document.execCommand(f[n],!1,null);else if(m[n]){const r=m[n],s=h(this._surface);s&&s.toLowerCase()===r?document.execCommand("formatBlock",!1,"<p>"):document.execCommand("formatBlock",!1,"<"+r+">")}else d[n]?document.execCommand(d[n],!1,null):n==="link"?L(this):n==="unlink"?document.execCommand("unlink",!1,null):n==="clear"&&(document.execCommand("removeFormat",!1,null),document.execCommand("formatBlock",!1,"<p>"));this._syncToTextarea(),this._updateActiveStates()}},a.prototype._updateActiveStates=function(){const n=this.dom.querySelector('[role="toolbar"]');if(!n)return;const o=window.getSelection();if(!o||o.rangeCount===0)return;const r=o.anchorNode;if(!r||!this._surface.contains(r))return;const s=n.querySelectorAll("[data-ln-editor-action]");for(let w=0;w<s.length;w++){const g=s[w],_=g.getAttribute("data-ln-editor-action");let u=!1;if(f[_])try{u=document.queryCommandState(f[_])}catch{}else if(m[_]){const v=h(this._surface);u=v&&v.toLowerCase()===m[_]}else if(d[_])try{u=document.queryCommandState(d[_])}catch{}else _==="link"&&(u=!!p(o.anchorNode,"A",this._surface));c(_)&&g.setAttribute("aria-pressed",String(u)),u?g.classList.add("ln-editor-active"):g.classList.remove("ln-editor-active")}},a.prototype.getHTML=function(){return this._surface?this._surface.innerHTML:""},a.prototype.setHTML=function(n){this._surface&&(this._surface.innerHTML=n,this._syncToTextarea(),T(this.dom,"ln-editor:changed",{html:this._textarea.value,target:this.dom}))},a.prototype.destroy=function(){if(!this.dom[t])return;this._surface&&(this._surface.removeEventListener("input",this._onInput),this._surface.removeEventListener("paste",this._onPaste),this._surface.removeEventListener("keydown",this._onKeydown),this._surface.removeEventListener("focus",this._onFocus),this._surface.removeEventListener("blur",this._onBlur),this._surface.remove());const n=this.dom.querySelector('[role="toolbar"]');n&&(n.removeEventListener("mousedown",this._onMousedownToolbar),n.removeEventListener("click",this._onClickToolbar)),document.removeEventListener("selectionchange",this._onSelectionChange),this.dom.removeEventListener("ln-editor:set-content",this._onSetContent);const o=this._textarea?this._textarea.form:null;o&&this._onFormReset&&o.removeEventListener("reset",this._onFormReset),this._textarea&&this._textarea.removeAttribute("data-ln-editor-source");const r=this.dom.querySelector(".ln-editor__link-popover");r&&r.remove(),T(this.dom,"ln-editor:destroyed",{target:this.dom}),delete this.dom[t]};function h(n){const o=window.getSelection();if(!o||o.rangeCount===0)return null;let r=o.anchorNode;if(!r)return null;for(;r&&r!==n;){if(r.nodeType===1){const s=r.tagName;if(s==="H2"||s==="H3"||s==="H4"||s==="BLOCKQUOTE"||s==="PRE"||s==="P")return s}r=r.parentNode}return null}function p(n,o,r){for(;n&&n!==r;){if(n.nodeType===1&&n.tagName===o)return n;n=n.parentNode}return null}function b(n,o){o.preventDefault();let r="";if(o.clipboardData&&(r=o.clipboardData.getData("text/html"),!r)){const w=o.clipboardData.getData("text/plain");w&&(r=w.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/\n\n/g,"</p><p>").replace(/\n/g,"<br>"),r="<p>"+r+"</p>")}if(!r)return;const s=y(r);s&&document.execCommand("insertHTML",!1,s)}function y(n){const o=document.createElement("div");return o.innerHTML=n,C(o),o.innerHTML}function C(n){const o=Array.from(n.childNodes);for(let r=0;r<o.length;r++){const s=o[r];if(s.nodeType!==3){if(s.nodeType!==1){n.removeChild(s);continue}if(i[s.tagName]){const w=Array.from(s.attributes);for(let g=0;g<w.length;g++){const _=w[g].name;if(s.tagName==="A"&&_==="href"){const u=s.getAttribute("href")||"";/^(https?:|mailto:|\/|#)/.test(u)||s.removeAttribute("href")}else s.removeAttribute(_)}s.tagName==="A"&&s.setAttribute("rel","noopener noreferrer"),C(s)}else{for(;s.firstChild;)n.insertBefore(s.firstChild,s);n.removeChild(s)}}}}function A(n,o){if(!(o.ctrlKey||o.metaKey))return;let r=null;switch(o.key.toLowerCase()){case"b":r="bold";break;case"i":r="italic";break;case"u":r="underline";break;case"k":r="link";break}r&&(o.preventDefault(),n._execAction(r))}function L(n){const o=window.getSelection();if(!o||o.rangeCount===0)return;const r=p(o.anchorNode,"A",n._surface),s=o.getRangeAt(0).cloneRange(),w=n.dom.querySelector(".ln-editor__link-popover");w&&w.remove();const g=V(n.dom,"ln-editor-link-popover","ln-editor");if(!g)return;const _=g.firstElementChild;if(!_)return;const u=_.querySelector('input[type="url"]'),v=_.querySelector('[data-ln-editor-action="confirm-link"]'),S=_.querySelector('[data-ln-editor-action="cancel-link"]');r&&(u.value=r.getAttribute("href")||"");const K=n.dom.querySelector('[role="toolbar"]');K?K.after(_):n.dom.insertBefore(_,n._surface),u.focus();function P(){const x=window.getSelection();x.removeAllRanges(),x.addRange(s)}function j(){const x=u.value.trim();if(_.remove(),P(),n._surface.focus(),x)if(r)r.setAttribute("href",x),r.setAttribute("rel","noopener noreferrer"),n._syncToTextarea(),T(n.dom,"ln-editor:changed",{html:n._textarea.value,target:n.dom});else{document.execCommand("createLink",!1,x);const M=window.getSelection();if(M&&M.anchorNode){const W=p(M.anchorNode,"A",n._surface);W&&(W.setAttribute("rel","noopener noreferrer"),n._syncToTextarea())}}else r&&document.execCommand("unlink",!1,null)}function U(){_.remove(),P(),n._surface.focus()}v.addEventListener("click",j),S.addEventListener("click",U),u.addEventListener("keydown",function(x){x.key==="Enter"?(x.preventDefault(),j()):x.key==="Escape"&&(x.preventDefault(),U())})}X(e,t,a,"ln-editor")})()})();
@@ -0,0 +1,43 @@
1
+ @use '../../scss/config/mixins' as *;
2
+
3
+ // ln-editor co-located styles — JS-state-driven CSS only.
4
+ // Visual styling lives in scss/config/mixins/_editor.scss.
5
+
6
+ // ─── Link popover (JS-created element) ────────────────────────
7
+ // Inline URL input that appears below the toolbar when inserting
8
+ // or editing a link. Created/removed by JS, not authored HTML.
9
+
10
+ .ln-editor__link-popover {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: var(--size-xs);
14
+ padding: var(--size-xs) var(--size-sm);
15
+ background: var(--bg-sunken);
16
+ border-bottom: var(--border-width) solid var(--color-border);
17
+
18
+ > input {
19
+ flex: 1;
20
+ min-width: 0;
21
+ padding: var(--size-2xs) var(--size-sm);
22
+ @include typography(body-sm);
23
+ background: var(--bg-base);
24
+ border: var(--border-width) solid var(--color-border);
25
+ border-radius: var(--radius-sm);
26
+ color: var(--color-fg);
27
+ outline: none;
28
+
29
+ &:focus-visible {
30
+ border-color: var(--color-accent);
31
+ }
32
+ }
33
+
34
+ > button {
35
+ padding: var(--size-2xs);
36
+ border-radius: var(--radius-sm);
37
+ background: transparent;
38
+
39
+ &:hover {
40
+ background: var(--bg-base);
41
+ }
42
+ }
43
+ }