@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.
- package/README.md +189 -177
- package/js/COMPONENTS.md +55 -49
- package/js/index.js +22 -5
- package/js/ln-accordion/README.md +186 -137
- package/js/ln-accordion/ln-accordion.js +1 -1
- package/js/ln-ajax/README.md +73 -4
- package/js/ln-ajax/ln-ajax.js +1 -1
- package/js/ln-ajax/src/ln-ajax.js +13 -13
- package/js/ln-api-connector/README.md +261 -150
- package/js/ln-api-connector/ln-api-connector.js +1 -1
- package/js/ln-api-connector/src/ln-api-connector.js +368 -265
- package/js/ln-api-queue/README.md +219 -0
- package/js/ln-api-queue/ln-api-queue.js +1 -0
- package/js/ln-api-queue/src/ln-api-queue.js +320 -0
- package/js/ln-api-queue/src/queue-storage.js +455 -0
- package/js/ln-autoresize/README.md +47 -0
- package/js/ln-autoresize/ln-autoresize.js +1 -1
- package/js/ln-autosave/README.md +33 -1
- package/js/ln-autosave/ln-autosave.js +1 -1
- package/js/ln-autosave/src/ln-autosave.js +4 -4
- package/js/ln-circular-progress/README.md +27 -13
- package/js/ln-circular-progress/ln-circular-progress.js +1 -1
- package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
- package/js/ln-confirm/README.md +165 -86
- package/js/ln-confirm/_ln-confirm.scss +11 -0
- package/js/ln-confirm/ln-confirm.js +1 -1
- package/js/ln-confirm/src/ln-confirm.js +82 -42
- package/js/ln-core/README.md +896 -0
- package/js/ln-core/hash.js +92 -0
- package/js/ln-core/helpers.js +337 -8
- package/js/ln-core/index.js +4 -2
- package/js/ln-core/persist.js +29 -3
- package/js/ln-core/positioning.js +1 -35
- package/js/ln-core/window-cache.js +213 -0
- package/js/ln-couchdb-connector/README.md +177 -156
- package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
- package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
- package/js/ln-data-coordinator/README.md +536 -165
- package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
- package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
- package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
- package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
- package/js/ln-data-store/README.md +241 -94
- package/js/ln-data-store/ln-data-store.js +1 -1
- package/js/ln-data-store/src/ln-data-store.js +694 -699
- package/js/ln-date/README.md +39 -1
- package/js/ln-date/ln-date-dev.scss +14 -0
- package/js/ln-date/ln-date.js +1 -1
- package/js/ln-date/src/ln-date.js +236 -178
- package/js/ln-debug/README.md +58 -0
- package/js/ln-debug/ln-debug.js +1 -0
- package/js/ln-debug/src/ln-debug.js +19 -0
- package/js/ln-dropdown/README.md +31 -8
- package/js/ln-dropdown/ln-dropdown.js +1 -1
- package/js/ln-dropdown/src/ln-dropdown.js +30 -13
- package/js/ln-editor/README.md +274 -0
- package/js/ln-editor/ln-editor.js +1 -0
- package/js/ln-editor/ln-editor.scss +43 -0
- package/js/ln-editor/src/ln-editor.js +619 -0
- package/js/ln-external-links/README.md +44 -5
- package/js/ln-external-links/ln-external-links.js +1 -1
- package/js/ln-fill/README.md +199 -0
- package/js/ln-fill/ln-fill.js +1 -0
- package/js/ln-fill/src/ln-fill.js +87 -0
- package/js/ln-filter/README.md +92 -2
- package/js/ln-filter/ln-filter-dev.scss +8 -0
- package/js/ln-filter/ln-filter.js +1 -1
- package/js/ln-filter/src/ln-filter.js +22 -7
- package/js/ln-form/README.md +212 -49
- package/js/ln-form/ln-form.js +1 -1
- package/js/ln-form/src/ln-form.js +99 -199
- package/js/ln-http/README.md +125 -89
- package/js/ln-http/ln-http.js +1 -1
- package/js/ln-http/src/ln-http.js +32 -13
- package/js/ln-icon/README.md +130 -0
- package/js/ln-icon/ln-icon.js +1 -0
- package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
- package/js/ln-link/README.md +47 -9
- package/js/ln-link/ln-link.js +1 -1
- package/js/ln-link/src/ln-link.js +22 -6
- package/js/ln-list/README.md +129 -0
- package/js/ln-list/ln-list.js +1 -0
- package/js/ln-list/ln-list.scss +28 -0
- package/js/ln-list/src/ln-list.js +1216 -0
- package/js/ln-modal/README.md +185 -154
- package/js/ln-modal/ln-modal-dev.scss +13 -0
- package/js/ln-modal/ln-modal.js +1 -1
- package/js/ln-modal/ln-modal.scss +28 -4
- package/js/ln-modal/src/ln-modal.js +137 -201
- package/js/ln-modal-coordinator/README.md +99 -0
- package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
- package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
- package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
- package/js/ln-nav/README.md +46 -3
- package/js/ln-nav/ln-nav.js +1 -1
- package/js/ln-nav/src/ln-nav.js +92 -127
- package/js/ln-number/README.md +28 -1
- package/js/ln-number/ln-number-dev.scss +13 -0
- package/js/ln-number/ln-number.js +1 -1
- package/js/ln-number/src/ln-number.js +443 -350
- package/js/ln-options/README.md +66 -0
- package/js/ln-options/ln-options.js +1 -0
- package/js/ln-options/src/ln-options.js +74 -0
- package/js/ln-popover/README.md +40 -5
- package/js/ln-popover/ln-popover.js +1 -1
- package/js/ln-popover/src/ln-popover.js +38 -11
- package/js/ln-progress/README.md +413 -442
- package/js/ln-progress/ln-progress.js +1 -1
- package/js/ln-progress/src/ln-progress.js +98 -150
- package/js/ln-router/README.md +432 -0
- package/js/ln-router/ln-router.js +1 -0
- package/js/ln-router/src/ln-router.js +539 -0
- package/js/ln-search/README.md +158 -83
- package/js/ln-search/ln-search-dev.scss +8 -0
- package/js/ln-search/ln-search.js +1 -1
- package/js/ln-search/src/ln-search.js +12 -9
- package/js/ln-slug/README.md +110 -0
- package/js/ln-slug/ln-slug.js +1 -0
- package/js/ln-slug/src/ln-slug.js +74 -0
- package/js/ln-sort/README.md +170 -0
- package/js/ln-sort/ln-sort-dev.scss +8 -0
- package/js/ln-sort/ln-sort.js +1 -0
- package/js/ln-sort/src/ln-sort.js +178 -0
- package/js/ln-sortable/README.md +133 -95
- package/js/ln-sortable/ln-sortable.js +1 -1
- package/js/ln-stat/README.md +55 -0
- package/js/ln-stat/ln-stat.js +1 -0
- package/js/ln-stat/src/ln-stat.js +55 -0
- package/js/ln-table/README.md +285 -101
- package/js/ln-table/ln-table-dev.scss +17 -0
- package/js/ln-table/ln-table.js +1 -1
- package/js/ln-table/ln-table.scss +1 -1
- package/js/ln-table/src/ln-table.js +1493 -473
- package/js/ln-table-coordinator/README.md +73 -0
- package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
- package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
- package/js/ln-tabs/README.md +221 -137
- package/js/ln-tabs/ln-tabs-dev.scss +18 -0
- package/js/ln-tabs/ln-tabs.js +1 -1
- package/js/ln-tabs/src/ln-tabs.js +41 -24
- package/js/ln-time/README.md +14 -0
- package/js/ln-time/ln-time.js +1 -1
- package/js/ln-time/src/ln-time.js +223 -192
- package/js/ln-toast/README.md +178 -122
- package/js/ln-toast/ln-toast.js +1 -15
- package/js/ln-toast/src/ln-toast.js +232 -210
- package/js/ln-toast/template.html +16 -12
- package/js/ln-toggle/README.md +61 -2
- package/js/ln-toggle/ln-toggle-dev.scss +13 -0
- package/js/ln-toggle/ln-toggle.js +1 -1
- package/js/ln-toggle/src/ln-toggle.js +56 -43
- package/js/ln-tooltip/README.md +95 -58
- package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
- package/js/ln-tooltip/ln-tooltip.js +1 -1
- package/js/ln-tooltip/src/ln-tooltip.js +45 -6
- package/js/ln-translations/README.md +118 -96
- package/js/ln-translations/ln-translations.js +1 -1
- package/js/ln-translations/src/ln-translations.js +10 -5
- package/js/ln-upload/README.md +85 -10
- package/js/ln-upload/ln-upload.js +1 -1
- package/js/ln-upload/src/ln-upload.js +26 -10
- package/js/ln-validate/README.md +72 -0
- package/js/ln-validate/ln-validate-dev.scss +15 -0
- package/js/ln-validate/ln-validate.js +1 -1
- package/js/ln-validate/src/ln-validate.js +64 -0
- package/package.json +61 -56
- package/scss/base/_global.scss +2 -2
- package/scss/base/_typography.scss +1 -2
- package/scss/components/_date.scss +1 -0
- package/scss/components/_dropdown.scss +0 -5
- package/scss/components/_editor.scss +5 -0
- package/scss/components/_filter.scss +26 -25
- package/scss/components/_form.scss +7 -0
- package/scss/components/_ln-table.scss +13 -3
- package/scss/components/_sort.scss +10 -0
- package/scss/components/_table.scss +36 -23
- package/scss/components/_toast.scss +68 -41
- package/scss/components/_toggle.scss +16 -0
- package/scss/config/_density.scss +205 -117
- package/scss/config/_icons.scss +10 -9
- package/scss/config/_tokens.scss +69 -47
- package/scss/config/mixins/_accordion.scss +2 -7
- package/scss/config/mixins/_alert.scss +4 -4
- package/scss/config/mixins/_breadcrumbs.scss +2 -2
- package/scss/config/mixins/_btn.scss +1 -1
- package/scss/config/mixins/_card.scss +89 -14
- package/scss/config/mixins/_collapsible.scss +3 -0
- package/scss/config/mixins/_colors.scss +1 -1
- package/scss/config/mixins/_diagnostics.scss +11 -0
- package/scss/config/mixins/_display.scss +1 -0
- package/scss/config/mixins/_dropdown.scss +3 -5
- package/scss/config/mixins/_editor.scss +124 -0
- package/scss/config/mixins/_empty-state.scss +7 -0
- package/scss/config/mixins/_filter.scss +3 -5
- package/scss/config/mixins/_footer.scss +1 -2
- package/scss/config/mixins/_form.scss +207 -21
- package/scss/config/mixins/_index.scss +2 -0
- package/scss/config/mixins/_ln-table.scss +41 -2
- package/scss/config/mixins/_modal.scss +149 -66
- package/scss/config/mixins/_nav.scss +3 -4
- package/scss/config/mixins/_popover.scss +39 -0
- package/scss/config/mixins/_sidebar.scss +1 -1
- package/scss/config/mixins/_sort.scss +42 -0
- package/scss/config/mixins/_table.scss +85 -60
- package/scss/config/mixins/_toast.scss +179 -152
- package/scss/config/mixins/_tooltip.scss +17 -7
- package/scss/config/mixins/_typography.scss +4 -2
- package/scss/ln-ashlar-dev.scss +27 -9
- package/scss/ln-ashlar.scss +7 -1
- package/js/ln-data-table/README.md +0 -110
- package/js/ln-data-table/ln-data-table.js +0 -1
- package/js/ln-data-table/ln-data-table.scss +0 -10
- package/js/ln-data-table/src/ln-data-table.js +0 -1087
- package/js/ln-icons/README.md +0 -88
- package/js/ln-icons/ln-icons.js +0 -1
- package/js/ln-table/ln-table-sort.js +0 -1
- 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
|
+
})();
|
package/js/ln-dropdown/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# ln-dropdown
|
|
2
2
|
|
|
3
|
-
> A menu-grade coordinator that adds click-outside,
|
|
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,
|
|
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),
|
|
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
|
|
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
|
|
78
|
-
| **`ln-dropdown:close`** | Yes | `{ target: menuElement }` | After menu is closed,
|
|
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
|
-
- **
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
64
|
-
self.toggleEl.
|
|
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.
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").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
|
+
}
|