@livenetworks/ashlar 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +162 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +77 -41
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +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,619 @@
1
+ import { dispatch, dispatchCancelable, registerComponent, cloneTemplateScoped } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-editor';
5
+ const DOM_ATTRIBUTE = 'lnEditor';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ // ─── Allowed tags for paste sanitization ───────────────────
10
+ const ALLOWED_TAGS = {
11
+ P: true, BR: true, STRONG: true, B: true, EM: true, I: true,
12
+ U: true, S: true, A: true, UL: true, OL: true, LI: true,
13
+ H2: true, H3: true, H4: true, BLOCKQUOTE: true, PRE: true,
14
+ CODE: true, DIV: true
15
+ };
16
+
17
+ // ─── Action → execCommand mapping ─────────────────────────
18
+ const INLINE_COMMANDS = {
19
+ 'bold': 'bold',
20
+ 'italic': 'italic',
21
+ 'underline': 'underline',
22
+ 'strikethrough': 'strikeThrough'
23
+ };
24
+
25
+ const BLOCK_COMMANDS = {
26
+ 'heading-2': 'h2',
27
+ 'heading-3': 'h3',
28
+ 'heading-4': 'h4',
29
+ 'blockquote': 'blockquote',
30
+ 'code': 'pre',
31
+ 'paragraph': 'p'
32
+ };
33
+
34
+ const LIST_COMMANDS = {
35
+ 'ordered-list': 'insertOrderedList',
36
+ 'unordered-list': 'insertUnorderedList'
37
+ };
38
+
39
+ // Monotonic id source for surfaces whose textarea has no id
40
+ let _uid = 0;
41
+
42
+ // Toggle-able formatting actions get aria-pressed; one-shot actions
43
+ // (unlink, clear, confirm-link, cancel-link) do not.
44
+ function _isToggleAction(action) {
45
+ return !!(INLINE_COMMANDS[action] || BLOCK_COMMANDS[action] ||
46
+ LIST_COMMANDS[action] || action === 'link');
47
+ }
48
+
49
+ // ─── Component ────────────────────────────────────────────
50
+ function _component(dom) {
51
+ this.dom = dom;
52
+ const self = this;
53
+
54
+ // Find the textarea inside the container
55
+ this._textarea = dom.querySelector('textarea');
56
+ if (!this._textarea) {
57
+ console.warn('[ln-editor] No <textarea> found inside', dom);
58
+ return this;
59
+ }
60
+
61
+ // Read placeholder from textarea
62
+ const placeholder = this._textarea.getAttribute('placeholder') || '';
63
+
64
+ // Mark textarea as source (CSS hides it)
65
+ this._textarea.setAttribute('data-ln-editor-source', '');
66
+
67
+ // Create the editing surface
68
+ this._surface = document.createElement('div');
69
+ this._surface.className = 'ln-editor__surface';
70
+ this._surface.setAttribute('contenteditable', 'true');
71
+ this._surface.setAttribute('role', 'textbox');
72
+ this._surface.setAttribute('aria-multiline', 'true');
73
+ if (placeholder) {
74
+ this._surface.setAttribute('data-placeholder', placeholder);
75
+ }
76
+
77
+ // Transfer textarea label association via aria-labelledby
78
+ const textareaId = this._textarea.id;
79
+ if (textareaId) {
80
+ const label = dom.querySelector('label[for="' + textareaId + '"]');
81
+ if (label) {
82
+ if (!label.id) label.id = textareaId + '-label';
83
+ this._surface.setAttribute('aria-labelledby', label.id);
84
+ }
85
+ }
86
+
87
+ // Stable id so the toolbar can reference the surface via aria-controls
88
+ this._surface.id = textareaId ? textareaId + '-surface' : 'ln-editor-surface-' + (++_uid);
89
+
90
+ // Set initial content from textarea value
91
+ const initialHtml = this._textarea.value.trim();
92
+ if (initialHtml) {
93
+ this._surface.innerHTML = initialHtml;
94
+ }
95
+
96
+ // Insert the surface into the DOM (after the toolbar, or at end)
97
+ const toolbar = dom.querySelector('[role="toolbar"]');
98
+ if (toolbar && toolbar.nextSibling) {
99
+ dom.insertBefore(this._surface, toolbar.nextSibling);
100
+ } else {
101
+ dom.appendChild(this._surface);
102
+ }
103
+
104
+ // Wire toolbar → surface relationship and seed toggle-button states
105
+ if (toolbar) {
106
+ toolbar.setAttribute('aria-controls', this._surface.id);
107
+ const _tbBtns = toolbar.querySelectorAll('[data-ln-editor-action]');
108
+ for (let _b = 0; _b < _tbBtns.length; _b++) {
109
+ const _act = _tbBtns[_b].getAttribute('data-ln-editor-action');
110
+ if (_isToggleAction(_act)) _tbBtns[_b].setAttribute('aria-pressed', 'false');
111
+ }
112
+ }
113
+
114
+ // ─── Event handlers (bound references for cleanup) ────
115
+ this._onInput = function () {
116
+ self._syncToTextarea();
117
+ dispatch(self.dom, 'ln-editor:changed', {
118
+ html: self._textarea.value,
119
+ target: self.dom
120
+ });
121
+ };
122
+
123
+ this._onMousedownToolbar = function (e) {
124
+ // Prevent toolbar click from stealing focus from surface
125
+ const btn = e.target.closest('[data-ln-editor-action]');
126
+ if (btn) e.preventDefault();
127
+ };
128
+
129
+ this._onClickToolbar = function (e) {
130
+ const btn = e.target.closest('[data-ln-editor-action]');
131
+ if (!btn) return;
132
+ const action = btn.getAttribute('data-ln-editor-action');
133
+ self._execAction(action);
134
+ };
135
+
136
+ this._onPaste = function (e) {
137
+ _handlePaste(self, e);
138
+ };
139
+
140
+ this._onKeydown = function (e) {
141
+ _handleKeydown(self, e);
142
+ };
143
+
144
+ this._onSelectionChange = function () {
145
+ // Document-level listener: no-op when the surface has been removed
146
+ // from the DOM without destroy() (e.g. SPA subtree swap). The
147
+ // listener is intentionally NOT removed here so a temporarily
148
+ // detached-then-reattached surface keeps working.
149
+ if (!document.contains(self._surface)) return;
150
+ self._updateActiveStates();
151
+ };
152
+
153
+ this._onFocus = function () {
154
+ dispatch(self.dom, 'ln-editor:focus', { target: self.dom });
155
+ };
156
+
157
+ this._onBlur = function () {
158
+ // Sync on blur to ensure textarea is up-to-date
159
+ self._syncToTextarea();
160
+ dispatch(self.dom, 'ln-editor:blur', { target: self.dom });
161
+ };
162
+
163
+ // ─── Bind events ──────────────────────────────────────
164
+ this._surface.addEventListener('input', this._onInput);
165
+ this._surface.addEventListener('paste', this._onPaste);
166
+ this._surface.addEventListener('keydown', this._onKeydown);
167
+ this._surface.addEventListener('focus', this._onFocus);
168
+ this._surface.addEventListener('blur', this._onBlur);
169
+
170
+ if (toolbar) {
171
+ toolbar.addEventListener('mousedown', this._onMousedownToolbar);
172
+ toolbar.addEventListener('click', this._onClickToolbar);
173
+ }
174
+
175
+ document.addEventListener('selectionchange', this._onSelectionChange);
176
+
177
+ // Request event — set content programmatically
178
+ this._onSetContent = function (e) {
179
+ const html = e.detail && e.detail.html;
180
+ if (html !== undefined) {
181
+ // Direct innerHTML set fires no native input — dispatch manually.
182
+ self._surface.innerHTML = html;
183
+ self._syncToTextarea();
184
+ dispatch(self.dom, 'ln-editor:changed', {
185
+ html: self._textarea.value,
186
+ target: self.dom
187
+ });
188
+ }
189
+ };
190
+ dom.addEventListener('ln-editor:set-content', this._onSetContent);
191
+
192
+ // Handle parent form reset
193
+ const form = this._textarea.form;
194
+ if (form) {
195
+ this._onFormReset = function () {
196
+ setTimeout(function () {
197
+ self._surface.innerHTML = self._textarea.value;
198
+ dispatch(dom, 'ln-editor:changed', {
199
+ html: self._textarea.value,
200
+ target: dom
201
+ });
202
+ }, 0);
203
+ };
204
+ form.addEventListener('reset', this._onFormReset);
205
+ }
206
+
207
+ return this;
208
+ }
209
+
210
+ // ─── Prototype Methods ────────────────────────────────────
211
+
212
+ _component.prototype._syncToTextarea = function () {
213
+ if (!this._textarea) return;
214
+ this._textarea.value = this._surface.innerHTML;
215
+ };
216
+
217
+ _component.prototype._execAction = function (action) {
218
+ if (!action) return;
219
+
220
+ const before = dispatchCancelable(this.dom, 'ln-editor:before-change', {
221
+ action: action,
222
+ target: this.dom
223
+ });
224
+ if (before.defaultPrevented) return;
225
+
226
+ // Ensure focus is on the surface
227
+ this._surface.focus();
228
+
229
+ if (INLINE_COMMANDS[action]) {
230
+ document.execCommand(INLINE_COMMANDS[action], false, null);
231
+ } else if (BLOCK_COMMANDS[action]) {
232
+ // Toggle: if current block matches, revert to <p>
233
+ const tag = BLOCK_COMMANDS[action];
234
+ const current = _getActiveBlockTag(this._surface);
235
+ if (current && current.toLowerCase() === tag) {
236
+ document.execCommand('formatBlock', false, '<p>');
237
+ } else {
238
+ document.execCommand('formatBlock', false, '<' + tag + '>');
239
+ }
240
+ } else if (LIST_COMMANDS[action]) {
241
+ document.execCommand(LIST_COMMANDS[action], false, null);
242
+ } else if (action === 'link') {
243
+ _handleLink(this);
244
+ } else if (action === 'unlink') {
245
+ document.execCommand('unlink', false, null);
246
+ } else if (action === 'clear') {
247
+ document.execCommand('removeFormat', false, null);
248
+ document.execCommand('formatBlock', false, '<p>');
249
+ }
250
+
251
+ // execCommand-driven edits fire a native input → _onInput dispatches
252
+ // 'changed'. Do NOT dispatch here (was A1 double-fire). The 'link'
253
+ // action opens a popover and mutates nothing until _apply.
254
+ this._syncToTextarea();
255
+ this._updateActiveStates();
256
+ };
257
+
258
+ _component.prototype._updateActiveStates = function () {
259
+ const toolbar = this.dom.querySelector('[role="toolbar"]');
260
+ if (!toolbar) return;
261
+
262
+ // Only update if the selection is within our surface
263
+ const sel = window.getSelection();
264
+ if (!sel || sel.rangeCount === 0) return;
265
+ const anchorNode = sel.anchorNode;
266
+ if (!anchorNode || !this._surface.contains(anchorNode)) return;
267
+
268
+ const buttons = toolbar.querySelectorAll('[data-ln-editor-action]');
269
+ for (let i = 0; i < buttons.length; i++) {
270
+ const btn = buttons[i];
271
+ const action = btn.getAttribute('data-ln-editor-action');
272
+ let isActive = false;
273
+
274
+ if (INLINE_COMMANDS[action]) {
275
+ try {
276
+ isActive = document.queryCommandState(INLINE_COMMANDS[action]);
277
+ } catch (e) {
278
+ // Ignore — some commands may not be queryable
279
+ }
280
+ } else if (BLOCK_COMMANDS[action]) {
281
+ const currentBlock = _getActiveBlockTag(this._surface);
282
+ isActive = currentBlock && currentBlock.toLowerCase() === BLOCK_COMMANDS[action];
283
+ } else if (LIST_COMMANDS[action]) {
284
+ try {
285
+ isActive = document.queryCommandState(LIST_COMMANDS[action]);
286
+ } catch (e) {
287
+ // Ignore
288
+ }
289
+ } else if (action === 'link') {
290
+ // Check if selection is inside an <a>
291
+ isActive = !!_getAncestorTag(sel.anchorNode, 'A', this._surface);
292
+ }
293
+
294
+ if (_isToggleAction(action)) {
295
+ btn.setAttribute('aria-pressed', String(isActive));
296
+ }
297
+ if (isActive) {
298
+ btn.classList.add('ln-editor-active');
299
+ } else {
300
+ btn.classList.remove('ln-editor-active');
301
+ }
302
+ }
303
+ };
304
+
305
+ _component.prototype.getHTML = function () {
306
+ return this._surface ? this._surface.innerHTML : '';
307
+ };
308
+
309
+ _component.prototype.setHTML = function (html) {
310
+ if (!this._surface) return;
311
+ // Direct innerHTML set fires no native input — dispatch manually.
312
+ this._surface.innerHTML = html;
313
+ this._syncToTextarea();
314
+ dispatch(this.dom, 'ln-editor:changed', {
315
+ html: this._textarea.value,
316
+ target: this.dom
317
+ });
318
+ };
319
+
320
+ _component.prototype.destroy = function () {
321
+ if (!this.dom[DOM_ATTRIBUTE]) return;
322
+
323
+ // Remove event listeners
324
+ if (this._surface) {
325
+ this._surface.removeEventListener('input', this._onInput);
326
+ this._surface.removeEventListener('paste', this._onPaste);
327
+ this._surface.removeEventListener('keydown', this._onKeydown);
328
+ this._surface.removeEventListener('focus', this._onFocus);
329
+ this._surface.removeEventListener('blur', this._onBlur);
330
+ this._surface.remove();
331
+ }
332
+
333
+ const toolbar = this.dom.querySelector('[role="toolbar"]');
334
+ if (toolbar) {
335
+ toolbar.removeEventListener('mousedown', this._onMousedownToolbar);
336
+ toolbar.removeEventListener('click', this._onClickToolbar);
337
+ }
338
+
339
+ document.removeEventListener('selectionchange', this._onSelectionChange);
340
+ this.dom.removeEventListener('ln-editor:set-content', this._onSetContent);
341
+
342
+ const form = this._textarea ? this._textarea.form : null;
343
+ if (form && this._onFormReset) {
344
+ form.removeEventListener('reset', this._onFormReset);
345
+ }
346
+
347
+ // Restore textarea visibility
348
+ if (this._textarea) {
349
+ this._textarea.removeAttribute('data-ln-editor-source');
350
+ }
351
+
352
+ // Remove link popover if present
353
+ const popover = this.dom.querySelector('.ln-editor__link-popover');
354
+ if (popover) popover.remove();
355
+
356
+ dispatch(this.dom, 'ln-editor:destroyed', { target: this.dom });
357
+ delete this.dom[DOM_ATTRIBUTE];
358
+ };
359
+
360
+ // ─── Private helpers ──────────────────────────────────────
361
+
362
+ /**
363
+ * Walk up from node to find the active block-level tag within the surface.
364
+ */
365
+ function _getActiveBlockTag(surface) {
366
+ const sel = window.getSelection();
367
+ if (!sel || sel.rangeCount === 0) return null;
368
+
369
+ let node = sel.anchorNode;
370
+ if (!node) return null;
371
+
372
+ // Walk up to find block element
373
+ while (node && node !== surface) {
374
+ if (node.nodeType === 1) {
375
+ const tag = node.tagName;
376
+ if (tag === 'H2' || tag === 'H3' || tag === 'H4' ||
377
+ tag === 'BLOCKQUOTE' || tag === 'PRE' || tag === 'P') {
378
+ return tag;
379
+ }
380
+ }
381
+ node = node.parentNode;
382
+ }
383
+ return null;
384
+ }
385
+
386
+ /**
387
+ * Walk up from node to find an ancestor with the given tag.
388
+ */
389
+ function _getAncestorTag(node, tagName, boundary) {
390
+ while (node && node !== boundary) {
391
+ if (node.nodeType === 1 && node.tagName === tagName) {
392
+ return node;
393
+ }
394
+ node = node.parentNode;
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Sanitize pasted HTML — strip disallowed tags and attributes.
401
+ */
402
+ function _handlePaste(instance, e) {
403
+ e.preventDefault();
404
+
405
+ let html = '';
406
+ if (e.clipboardData) {
407
+ html = e.clipboardData.getData('text/html');
408
+ if (!html) {
409
+ // Fallback to plain text
410
+ const text = e.clipboardData.getData('text/plain');
411
+ if (text) {
412
+ // Convert line breaks to <br> for plain text paste
413
+ html = text
414
+ .replace(/&/g, '&amp;')
415
+ .replace(/</g, '&lt;')
416
+ .replace(/>/g, '&gt;')
417
+ .replace(/\n\n/g, '</p><p>')
418
+ .replace(/\n/g, '<br>');
419
+ html = '<p>' + html + '</p>';
420
+ }
421
+ }
422
+ }
423
+
424
+ if (!html) return;
425
+
426
+ const sanitized = _sanitizeHTML(html);
427
+ if (sanitized) {
428
+ document.execCommand('insertHTML', false, sanitized);
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Recursively sanitize an HTML string, keeping only allowed tags.
434
+ */
435
+ function _sanitizeHTML(html) {
436
+ const container = document.createElement('div');
437
+ container.innerHTML = html;
438
+ _walkAndSanitize(container);
439
+ return container.innerHTML;
440
+ }
441
+
442
+ function _walkAndSanitize(node) {
443
+ const children = Array.from(node.childNodes);
444
+ for (let i = 0; i < children.length; i++) {
445
+ const child = children[i];
446
+
447
+ // Keep text nodes
448
+ if (child.nodeType === 3) continue;
449
+
450
+ // Remove non-element nodes (comments, etc.)
451
+ if (child.nodeType !== 1) {
452
+ node.removeChild(child);
453
+ continue;
454
+ }
455
+
456
+ if (!ALLOWED_TAGS[child.tagName]) {
457
+ // Replace disallowed tag with its children (unwrap)
458
+ while (child.firstChild) {
459
+ node.insertBefore(child.firstChild, child);
460
+ }
461
+ node.removeChild(child);
462
+ } else {
463
+ // Strip all attributes except href on <a>
464
+ const attrs = Array.from(child.attributes);
465
+ for (let j = 0; j < attrs.length; j++) {
466
+ const attrName = attrs[j].name;
467
+ if (child.tagName === 'A' && attrName === 'href') {
468
+ // Sanitize href — only allow http/https/mailto
469
+ const href = child.getAttribute('href') || '';
470
+ if (!/^(https?:|mailto:|\/|#)/.test(href)) {
471
+ child.removeAttribute('href');
472
+ }
473
+ } else {
474
+ child.removeAttribute(attrName);
475
+ }
476
+ }
477
+
478
+ // Ensure links open safely
479
+ if (child.tagName === 'A') {
480
+ child.setAttribute('rel', 'noopener noreferrer');
481
+ }
482
+
483
+ // Recurse
484
+ _walkAndSanitize(child);
485
+ }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Handle keyboard shortcuts.
491
+ */
492
+ function _handleKeydown(instance, e) {
493
+ if (!(e.ctrlKey || e.metaKey)) return;
494
+
495
+ let action = null;
496
+ switch (e.key.toLowerCase()) {
497
+ case 'b': action = 'bold'; break;
498
+ case 'i': action = 'italic'; break;
499
+ case 'u': action = 'underline'; break;
500
+ case 'k': action = 'link'; break;
501
+ }
502
+
503
+ if (action) {
504
+ e.preventDefault();
505
+ instance._execAction(action);
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Handle link insertion — inline popover approach.
511
+ * Creates a small URL input inline at the toolbar level.
512
+ */
513
+ function _handleLink(instance) {
514
+ const sel = window.getSelection();
515
+ if (!sel || sel.rangeCount === 0) return;
516
+
517
+ // Check if already inside a link — if so, let user edit the href
518
+ const existingLink = _getAncestorTag(sel.anchorNode, 'A', instance._surface);
519
+
520
+ // Save the current selection range so we can restore it
521
+ const savedRange = sel.getRangeAt(0).cloneRange();
522
+
523
+ // Remove any existing popover
524
+ const existing = instance.dom.querySelector('.ln-editor__link-popover');
525
+ if (existing) existing.remove();
526
+
527
+ // Clone popover template
528
+ const fragment = cloneTemplateScoped(instance.dom, 'ln-editor-link-popover', 'ln-editor');
529
+ if (!fragment) return;
530
+ const popover = fragment.firstElementChild;
531
+ if (!popover) return;
532
+
533
+ const input = popover.querySelector('input[type="url"]');
534
+ const confirmBtn = popover.querySelector('[data-ln-editor-action="confirm-link"]');
535
+ const removeBtn = popover.querySelector('[data-ln-editor-action="cancel-link"]');
536
+
537
+ if (existingLink) {
538
+ input.value = existingLink.getAttribute('href') || '';
539
+ }
540
+
541
+ // Insert popover after the toolbar
542
+ const toolbar = instance.dom.querySelector('[role="toolbar"]');
543
+ if (toolbar) {
544
+ toolbar.after(popover);
545
+ } else {
546
+ instance.dom.insertBefore(popover, instance._surface);
547
+ }
548
+
549
+ input.focus();
550
+
551
+ function _restoreSelection() {
552
+ const sel = window.getSelection();
553
+ sel.removeAllRanges();
554
+ sel.addRange(savedRange);
555
+ }
556
+
557
+ function _apply() {
558
+ const url = input.value.trim();
559
+ popover.remove();
560
+
561
+ _restoreSelection();
562
+ instance._surface.focus();
563
+
564
+ if (url) {
565
+ if (existingLink) {
566
+ // Bare setAttribute — no execCommand, so no native input
567
+ // fires. Sync + dispatch 'changed' exactly once here.
568
+ existingLink.setAttribute('href', url);
569
+ existingLink.setAttribute('rel', 'noopener noreferrer');
570
+ instance._syncToTextarea();
571
+ dispatch(instance.dom, 'ln-editor:changed', {
572
+ html: instance._textarea.value,
573
+ target: instance.dom
574
+ });
575
+ } else {
576
+ // createLink is an execCommand → native input → _onInput
577
+ // syncs + dispatches 'changed'. Do NOT dispatch again.
578
+ document.execCommand('createLink', false, url);
579
+ const sel = window.getSelection();
580
+ if (sel && sel.anchorNode) {
581
+ const newLink = _getAncestorTag(sel.anchorNode, 'A', instance._surface);
582
+ if (newLink) {
583
+ // rel lands after _onInput already captured innerHTML —
584
+ // silently re-sync the textarea, no second dispatch.
585
+ newLink.setAttribute('rel', 'noopener noreferrer');
586
+ instance._syncToTextarea();
587
+ }
588
+ }
589
+ }
590
+ } else if (existingLink) {
591
+ // unlink is an execCommand → native input → _onInput dispatches.
592
+ document.execCommand('unlink', false, null);
593
+ }
594
+ }
595
+
596
+ function _cancel() {
597
+ popover.remove();
598
+ _restoreSelection();
599
+ instance._surface.focus();
600
+ }
601
+
602
+ confirmBtn.addEventListener('click', _apply);
603
+ removeBtn.addEventListener('click', _cancel);
604
+
605
+ input.addEventListener('keydown', function (e) {
606
+ if (e.key === 'Enter') {
607
+ e.preventDefault();
608
+ _apply();
609
+ } else if (e.key === 'Escape') {
610
+ e.preventDefault();
611
+ _cancel();
612
+ }
613
+ });
614
+ }
615
+
616
+ // ─── Init ─────────────────────────────────────────────────
617
+
618
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-editor');
619
+ })();
@@ -2,8 +2,6 @@
2
2
 
3
3
  Auto-decorates every cross-host `<a>` and `<area>` on the page with `target="_blank"`, merged `rel="noopener noreferrer"`, and a screen-reader hint span. Runs on page load and on every DOM mutation; no opt-in attribute, no init call, no API surface for consumers to wire.
4
4
 
5
- For the host-comparison decision table, script-load lifecycle, and architecture rationale, see [`docs/js/external-links.md`](../../docs/js/external-links.md).
6
-
7
5
  ## Markup anatomy
8
6
 
9
7
  There is no markup contract. Every `<a>` and `<area>` on the page
@@ -331,11 +329,52 @@ This self-contained script executes immediately and sets up the necessary hooks
331
329
  `ln-modal` with this component to show a "leaving the site"
332
330
  interstitial. The composition is project code, not library code;
333
331
  see the "Confirm-before-leaving interstitial" example.
334
- - **Architecture deep-dive:** [`docs/js/external-links.md`](../../docs/js/external-links.md)
335
- for the global-service pattern, the `_isExternalLink` decision
336
- table, and the script-load lifecycle.
337
332
  - **Cross-component principles:** [`docs/architecture/data-flow.md`](../../docs/architecture/data-flow.md)
338
333
  — ln-external-links sits OUTSIDE the four-layer data flow. It is
339
334
  not Data, Submit, Render, or Validate. It is a global decorator
340
335
  that mutates DOM in response to insertion events; the data flow
341
336
  story does not apply.
337
+
338
+ ---
339
+
340
+ ## 🔧 Internals
341
+
342
+ Source: `js/ln-external-links/ln-external-links.js`. A **global service** (js skill §10) — no `registerComponent`, no consumer-facing `data-ln-*` opt-in attribute. It loads, runs once, and sets up a click delegate + `MutationObserver` on `document.body`.
343
+
344
+ ### "External" test
345
+
346
+ The single test: truthy `link.hostname` AND `link.hostname !== window.location.hostname`. The `link.hostname &&` short-circuit is what makes every non-http(s) scheme (`mailto:`, `tel:`, `javascript:`, `data:`) resolve as "internal" — those schemes parse to an empty `hostname`, so the check fails before the comparison runs. A subdomain (`app.example.com` on a page served from `example.com`) counts as external — hostname equality is exact, not suffix-based. Port and scheme differences do not affect `hostname` (it strips both), so `https://host:8443` vs `https://host` counts as the same host.
347
+
348
+ ### Script-load lifecycle
349
+
350
+ Both the click delegate and the observer are wrapped in `guardBody` (`ln-core/helpers.js`): if `document.body` is `null` at script eval (e.g. loaded in `<head>` without `defer`), setup re-schedules itself for `DOMContentLoaded`. The initial `_processLinks()` scan follows the same `readyState === 'loading'` check. A `window.lnExternalLinks` sentinel guards against double-execution if the script loads twice.
351
+
352
+ ### Processing pipeline
353
+
354
+ `_processLink(link)`, on every pass (initial scan, observer callback, manual `.process()` call):
355
+
356
+ 1. `data-ln-external-link === 'processed'` → return (idempotency guard, runs first on every pass).
357
+ 2. `!_isExternalLink(link)` → return.
358
+ 3. `target = '_blank'` (clobbers any pre-existing value).
359
+ 4. `rel` tokens merged: `noopener` + `noreferrer` added, existing tokens (`me`, `nofollow`, etc.) survive.
360
+ 5. `<span class="sr-only">(opens in new tab)</span>` appended as last child.
361
+ 6. `data-ln-external-link="processed"` written (the marker for future passes).
362
+ 7. `ln-external-links:processed` dispatched (bubbles, not cancelable).
363
+
364
+ There is no "before-process" cancelable event — decoration is unconditional once the guards pass.
365
+
366
+ ### MutationObserver scope
367
+
368
+ Watches `document.body` with `childList: true, subtree: true, attributes: true, attributeFilter: ['href']`. Catches: new `<a>`/`<area>` insertions (direct match and nested via `querySelectorAll`), and `href` mutations on existing anchors (internal→external flips redecorate automatically). Does NOT catch **external→internal href flips on an already-processed link** — the marker guard short-circuits, so the link stays over-decorated (`target="_blank"` on now-internal URL); recovery is consumer-side (clear the marker, strip the written attributes/span manually). Also does not watch `removedNodes` — unnecessary, since all state lives on the element and is removed with it.
369
+
370
+ ### Click delegation
371
+
372
+ One delegate on `document.body`, resolving `e.target.closest('a, area')`. No `preventDefault()` — the browser's default action and all modifier-key semantics (Ctrl/Cmd/middle/Shift-click) pass through untouched. Firing of `:clicked` is gated on the `processed` marker, **not** a fresh `_isExternalLink` re-check — a link pre-marked `processed` in markup (the opt-out hatch) fires `:clicked` even if it's actually internal. `detail.text` reads `link.textContent`, which includes the appended sr-only span text.
373
+
374
+ ### Accessibility
375
+
376
+ The sr-only hint has no i18n hook — the string is hard-coded English. Projects needing localization must patch the compiled bundle or pre-mark every link to skip the component.
377
+
378
+ ### Cross-component coordination
379
+
380
+ Imports only `dispatch` and `guardBody` from `ln-core/helpers.js`. Does not listen for any `ln-*` event and is not signaled by `ln-ajax`/data-loading components when they inject markup — decoration is purely insertion-driven via the shared observer.