@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
@@ -10,11 +10,11 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
10
10
  const DEFAULT_ITEM_TEMPLATE_HTML =
11
11
  '<template data-ln-template="ln-upload-item">' +
12
12
  '<li class="ln-upload__item" data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">' +
13
- '<svg class="ln-icon" aria-hidden="true"><use data-ln-attr="href:iconHref" href="#ln-file"></use></svg>' +
13
+ '<svg class="ln-icon" aria-hidden="true"><use data-ln-attr="href:iconHref" href="#ln-icon-file"></use></svg>' +
14
14
  '<span class="ln-upload__name" data-ln-field="name"></span>' +
15
15
  '<span class="ln-upload__size" data-ln-field="sizeText"></span>' +
16
16
  '<button type="button" class="ln-upload__remove" data-ln-upload-action="remove" data-ln-attr="aria-label:removeLabel, title:removeLabel">' +
17
- '<svg class="ln-icon" aria-hidden="true"><use href="#ln-x"></use></svg>' +
17
+ '<svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>' +
18
18
  '</button>' +
19
19
  '<div class="ln-upload__progress"><div class="ln-upload__progress-bar"></div></div>' +
20
20
  '</li>' +
@@ -47,7 +47,7 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
47
47
  function _getIconId(extension) {
48
48
  if (extension === 'docx') extension = 'doc';
49
49
  const supported = ['pdf', 'doc', 'epub'];
50
- return supported.includes(extension) ? 'lnc-file-' + extension : 'ln-file';
50
+ return supported.includes(extension) ? 'ln-icon-custom-file-' + extension : 'ln-icon-file';
51
51
  }
52
52
 
53
53
  function _isValidFile(file, acceptString) {
@@ -58,8 +58,7 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
58
58
  }
59
59
 
60
60
  function _initUpload(container) {
61
- if (container.hasAttribute('data-ln-upload-initialized')) return;
62
- container.setAttribute('data-ln-upload-initialized', 'true');
61
+ if (container.lnUploadAPI) return;
63
62
 
64
63
  _ensureDefaultItemTemplate();
65
64
  const dict = buildDict(container, DICT_SELECTOR);
@@ -87,7 +86,9 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
87
86
  container.appendChild(input);
88
87
  }
89
88
  const uploadUrl = container.getAttribute(DOM_SELECTOR) || '/files/upload';
90
- const uploadContext = container.getAttribute(CONTEXT_ATTR) || '';
89
+ const uploadContextFallback = container.getAttribute(CONTEXT_ATTR) || '';
90
+ const deleteUrlPattern = container.getAttribute('data-ln-upload-delete') ||
91
+ (uploadUrl.includes('/upload') ? uploadUrl.replace(/\/upload\/?$/, '/{id}') : uploadUrl + '/{id}');
91
92
 
92
93
  const uploadedFiles = new Map();
93
94
  let fileIdCounter = 0;
@@ -142,7 +143,21 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
142
143
 
143
144
  const formData = new FormData();
144
145
  formData.append('file', file);
145
- formData.append('context', uploadContext);
146
+
147
+ const gatheredNames = new Set();
148
+ container.querySelectorAll('input, select, textarea').forEach(function (el) {
149
+ if (el.name && el.name !== 'file_ids[]' && el.type !== 'file') {
150
+ if ((el.type === 'checkbox' || el.type === 'radio') && !el.checked) {
151
+ return;
152
+ }
153
+ formData.append(el.name, el.value);
154
+ gatheredNames.add(el.name);
155
+ }
156
+ });
157
+
158
+ if (!gatheredNames.has('context') && uploadContextFallback) {
159
+ formData.append('context', uploadContextFallback);
160
+ }
146
161
 
147
162
  const xhr = new XMLHttpRequest();
148
163
 
@@ -244,7 +259,8 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
244
259
 
245
260
  if (item) fill(item, { deleting: true });
246
261
 
247
- fetch('/files/' + fileData.serverId, {
262
+ const deleteUrl = deleteUrlPattern.replace('{id}', fileData.serverId);
263
+ fetch(deleteUrl, {
248
264
  method: 'DELETE',
249
265
  headers: {
250
266
  'X-CSRF-TOKEN': getCsrfToken(),
@@ -322,7 +338,8 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
322
338
  clear: function () {
323
339
  for (const [, fileData] of uploadedFiles) {
324
340
  if (fileData.serverId) {
325
- fetch('/files/' + fileData.serverId, {
341
+ const deleteUrl = deleteUrlPattern.replace('{id}', fileData.serverId);
342
+ fetch(deleteUrl, {
326
343
  method: 'DELETE',
327
344
  headers: {
328
345
  'X-CSRF-TOKEN': getCsrfToken(),
@@ -347,7 +364,6 @@ import { guardBody, dispatch, buildDict, cloneTemplateScoped, fill } from '../..
347
364
  uploadedFiles.clear();
348
365
  list.innerHTML = '';
349
366
  updateHiddenInput();
350
- container.removeAttribute('data-ln-upload-initialized');
351
367
  delete container.lnUploadAPI;
352
368
  }
353
369
  };
@@ -11,6 +11,22 @@ It maintains no custom rules in JavaScript; instead, it relies fully on native H
11
11
  1. **Platform First:** Browsers compile and execute rules in native C++ instantly. `ln-validate` simply acts as a visual layer: translating native `ValidityState` properties into visible HTML elements on demand.
12
12
  2. **The Touch Gate:** To prevent annoying page-load errors, validation is completely visual-silent until a field receives its first user interaction (`input` or `change`), setting its internal state `_touched = true`.
13
13
  3. **The Custom Escape Hatch:** Validation rules the browser cannot express (such as checking if an email is taken via a server lookup, or confirming passwords match) are routed through asynchronous `set-custom` / `clear-custom` events.
14
+ 4. **Owns Browser Validation:** The moment a form contains at least one
15
+ `data-ln-validate` field, that field's constructor injects
16
+ `novalidate` on the host `<form>` (via `field.form`, so `form="id"`
17
+ attribute association works too). Authors never write `novalidate`
18
+ by hand. A form with zero `data-ln-validate` fields is untouched and
19
+ keeps native browser validation as the default. The injection is
20
+ idempotent and one-way — it is never removed on field `destroy()`,
21
+ since other validated fields on the same form may still own the
22
+ gate.
23
+ 5. **Owns the Submit Gate:** The same field that injects `novalidate`
24
+ also attaches (once per form) a `submit` listener: on submit, it
25
+ dispatches `ln-validate:request-validate` to collect invalid fields;
26
+ if any exist, it calls `preventDefault()`, sorts them by document
27
+ position, and focuses the first one. Runs on every HTTP method — GET
28
+ included, exactly like the native validation it replaces. `ln-form`
29
+ has no role in this at all.
14
30
 
15
31
  ---
16
32
 
@@ -98,6 +114,7 @@ input.lnValidate.destroy();
98
114
  | :--- | :--- | :--- |
99
115
  | `ln-validate:set-custom` | `{ error: String }` | Injects a custom error key, highlights the field as invalid. |
100
116
  | `ln-validate:clear-custom` | `{ error: String }` / `{}` | Clears a specific custom error, or all custom errors at once. |
117
+ | `ln-validate:request-validate` | `{ invalidFields: Array }` | Dispatched by `ln-validate`'s own submit gate (see Philosophy §5). Forces validation (sets `_touched = true`, calls `validate()`) and pushes the field element to the `invalidFields` array if invalid. |
101
118
 
102
119
  ---
103
120
 
@@ -106,3 +123,58 @@ input.lnValidate.destroy();
106
123
  - **Skipping the `.form-element` wrapper:** `ln-validate` locates sibling error elements relative to the closest `.form-element` ancestor. If you omit the wrapper class, no error lists will be toggled.
107
124
  - **Relying on Native browser CSS (`:invalid`):** The browser applies `:invalid` to empty required fields on page load. **Always** use our custom classes `.ln-validate-valid` and `.ln-validate-invalid` for visual borders and focus states.
108
125
  - **Injecting custom errors without validating:** Dispatching `ln-validate:set-custom` updates visual classes, but it **does not** bubble an `:invalid` event. If you need form-level coordinators (`ln-form`) to react immediately, call `input.lnValidate.validate()` right after injecting.
126
+ - **Expecting `novalidate` in your own markup:** Don't write it by hand
127
+ — `ln-validate` injects it on the host form as soon as one field
128
+ initializes. Writing it yourself is harmless (the injection checks
129
+ `hasAttribute` first) but unnecessary.
130
+
131
+ ---
132
+
133
+ ## 🔧 Internals
134
+
135
+ Source: `js/ln-validate/ln-validate.js` (~160 lines). Imports only `dispatch`/`registerComponent` from `ln-core` — no sibling-component imports.
136
+
137
+ ### State
138
+
139
+ Three fields per instance (`dom.lnValidate`): `dom`, `_touched` (bool), `_customErrors` (Set). No cached error-`<li>` array, no cached `.form-element` ancestor reference, no debounce — every `validate()` call re-queries `parent.querySelectorAll` and recomputes from `dom.checkValidity()`. Cheap because the query is scoped to one field's wrapper and validity checks are microsecond-fast.
140
+
141
+ ### Listener discriminator
142
+
143
+ `input`+`change` fire on `<input>`/`<textarea>`; `change`-only fires on `<select>`, checkbox, radio (`input` never fires on these). This exact fork is mirrored in `ln-form.fill()`, `ln-form.reset()`, and `ln-autosave`'s restore path — not enforced by a shared helper, just a documented invariant all three must keep in sync.
144
+
145
+ ### `validate()` flow
146
+
147
+ 1. `nativeValid = dom.checkValidity()`; `isValid = nativeValid && _customErrors.size === 0`.
148
+ 2. Walks the field's `.form-element`'s `[data-ln-validate-error]` `<li>`s; keys present in `ERROR_MAP` (`required`/`typeMismatch`/`tooShort`/`tooLong`/`patternMismatch`/`rangeUnderflow`/`rangeOverflow`) toggle `hidden` off `validity[prop]`; keys NOT in the map are skipped — that's the mechanism that lets native and custom errors share one `<ul>` without conflict.
149
+ 3. Toggles `.ln-validate-valid`/`.ln-validate-invalid`, dispatches `:valid`/`:invalid` `{ target, field: dom.name }`.
150
+ 4. If the field has no `.form-element` ancestor, error rendering is skipped but the class toggle and dispatch still run.
151
+
152
+ `validate()` never reads `_touched` — it always renders. `_touched` instead gates whether the *listener handlers* call it, so the submit gate can force-validate an untouched field on demand.
153
+
154
+ ### Custom-error asymmetry
155
+
156
+ `set-custom` (`_onSetCustom`) adds to `_customErrors`, unhides the matching `<li>`, flips classes to invalid — but does **not** call `validate()` or dispatch `:invalid`. The caller already knows the field is invalid; a coordinator that needs `ln-form`'s submit gate to react must call `instance.validate()` itself afterwards.
157
+
158
+ `clear-custom` (`_onClearCustom`) is the counterweight: clear-one or clear-all, then `if (_touched) validate()` — removing one custom error can leave others active, leave native errors active, or leave the field fully valid, and only `validate()` can recompute which.
159
+
160
+ ### The submit gate
161
+
162
+ The first `data-ln-validate` field on a form injects `novalidate` (idempotent, one-way, never removed on `destroy()`) and attaches one `submit` listener to the form, guarded by a `form._lnValidateGateBound` marker so N fields produce exactly one listener. On submit: dispatches `ln-validate:request-validate`; every field's own handler force-validates and pushes itself into `invalidFields` if invalid; if any exist, `preventDefault()`, sort by document position, focus the first. Runs on every HTTP method, exactly like the native validation it replaces. `ln-form` has no involvement — it never listens for `submit`. `ln-data-coordinator`'s own method gate reads `e.defaultPrevented` first, so it never claims a submit this gate already blocked.
163
+
164
+ ### `isValid` getter
165
+
166
+ Pure read (`dom.checkValidity() && _customErrors.size === 0`) — no side effects, no rendering, no `_touched` mutation. For "is this field currently valid?" checks that shouldn't trigger the render pipeline.
167
+
168
+ ### `reset()` / `destroy()`
169
+
170
+ `reset()`: `_touched = false`, clears `_customErrors`, removes valid/invalid classes, hides every `[data-ln-validate-error]` (native and custom alike). Does NOT dispatch `:valid`/`:invalid` (the field is "untouched," not "newly valid") and does NOT touch `dom.value` — the form element owns that; `ln-form.reset()` calls the native `dom.reset()` first, then dispatches synthetic `input`/`change` per field, then calls `instance.reset()`.
171
+
172
+ `destroy()` removes the four listeners (`input`, `change`, `set-custom`, `clear-custom`), removes the CSS classes (but leaves error `<li>` visibility as-is — a different intent than `reset()`), dispatches `:destroyed`, deletes the instance. Idempotent via a `DOM_ATTRIBUTE` guard.
173
+
174
+ ### MutationObserver
175
+
176
+ `registerComponent` wires the shared observer: `childList` upgrades newly-inserted `[data-ln-validate]` fields, `attributes` (filtered to `data-ln-validate`) upgrades the attribute added to an existing element. Removal is not observed — call `instance.destroy()` before detaching. Changes to `data-ln-validate-error`/`-errors` are not observed either (typically static server-rendered markup) — a manual `instance.validate()` call re-renders if that markup changes at runtime.
177
+
178
+ ### Cross-component contract
179
+
180
+ `ln-form` listens for bubbling `:valid`/`:invalid` to re-evaluate submit-button state, and dispatches synthetic `input`/`change` on `fill()`/`reset()` using the identical listener discriminator; `ln-autosave` does the same on restore. None of these components import each other — the wiring is pure platform event flow, so keeping the discriminator in sync across all three files is a manual invariant, not a compile-time one.
@@ -0,0 +1,15 @@
1
+ @use '../../scss/config/mixins' as *;
2
+
3
+ [data-ln-debug] {
4
+ // 1. Validation Input Missing ID: input, select, textarea elements utilizing data-ln-validate must have an ID
5
+ input[data-ln-validate]:not([id]),
6
+ select[data-ln-validate]:not([id]),
7
+ textarea[data-ln-validate]:not([id]) {
8
+ @include dev-inline-error("data-ln-validate element is missing required 'id' attribute");
9
+ }
10
+
11
+ // 2. Validation Nesting check: data-ln-validate must be wrapped inside .form-element or data-ln-form
12
+ [data-ln-validate]:not(.form-element [data-ln-validate]):not([data-ln-form] [data-ln-validate]) {
13
+ @include dev-dom-error("data-ln-validate must be a child of .form-element or data-ln-form wrapper");
14
+ }
15
+ }
@@ -1 +1 @@
1
- (function(){"use strict";function C(t,e,f){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:f||{}}))}function w(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){w(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function L(t,e,f,h){if(t.nodeType!==1)return;const v=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",p=Array.from(t.querySelectorAll(v));t.matches&&t.matches(v)&&p.push(t);for(const a of p)a[f]||(a[f]=new h(a))}function O(t,e,f,h,m={}){const v=m.extraAttributes||[],p=m.onAttributeChange||null,a=m.onInit||null;function n(i){const c=i||document.body;L(c,t,e,f),a&&a(c)}return w(function(){const i=new MutationObserver(function(l){for(let u=0;u<l.length;u++){const s=l[u];if(s.type==="childList"){for(let r=0;r<s.addedNodes.length;r++){const o=s.addedNodes[r];o.nodeType===1&&(L(o,t,e,f),a&&a(o))}for(let r=0;r<s.removedNodes.length;r++){const o=s.removedNodes[r];if(o.nodeType===1){const y=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",g=Array.from(o.querySelectorAll(y));o.matches&&o.matches(y)&&g.push(o);for(let _=0;_<g.length;_++){const b=g[_];if(!document.contains(b)){const E=b[e];E&&typeof E.destroy=="function"&&E.destroy()}}}}}else s.type==="attributes"&&(p&&s.target[e]?p(s.target,s.attributeName):(L(s.target,t,e,f),a&&a(s.target)))}});let c=[];if(t.indexOf("[")!==-1){const l=/\[([\w-]+)/g;let u;for(;(u=l.exec(t))!==null;)c.push(u[1])}else c.push(t);i.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:c.concat(v)})},h),window[e]=n,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){n(document.body)}):n(document.body),n}const S={};function A(t,e){S[t]=e}function M(t){return S[t]||{ingress:e=>e,egress:e=>e}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=A,window.lnCore.getDataMapper=M),(function(){const t="data-ln-validate",e="lnValidate",f="data-ln-validate-errors",h="data-ln-validate-error",m="ln-validate-valid",v="ln-validate-invalid",p={required:"valueMissing",typeMismatch:"typeMismatch",tooShort:"tooShort",tooLong:"tooLong",patternMismatch:"patternMismatch",rangeUnderflow:"rangeUnderflow",rangeOverflow:"rangeOverflow"};if(window[e]!==void 0)return;function a(n){this.dom=n,this._touched=!1,this._customErrors=new Set;const i=this,c=n.tagName,l=n.type,u=c==="SELECT"||l==="checkbox"||l==="radio";return this._onInput=function(){i._touched=!0,i.validate()},this._onChange=function(){i._touched=!0,i.validate()},this._onSetCustom=function(s){const r=s.detail&&s.detail.error;if(!r)return;i._customErrors.add(r),i._touched=!0;const o=n.closest(".form-element");if(o){const d=o.querySelector("["+h+'="'+r+'"]');d&&d.classList.remove("hidden")}n.classList.remove(m),n.classList.add(v)},this._onClearCustom=function(s){const r=s.detail&&s.detail.error,o=n.closest(".form-element");if(r){if(i._customErrors.delete(r),o){const d=o.querySelector("["+h+'="'+r+'"]');d&&d.classList.add("hidden")}}else i._customErrors.forEach(function(d){if(o){const y=o.querySelector("["+h+'="'+d+'"]');y&&y.classList.add("hidden")}}),i._customErrors.clear();i._touched&&i.validate()},u||n.addEventListener("input",this._onInput),n.addEventListener("change",this._onChange),n.addEventListener("ln-validate:set-custom",this._onSetCustom),n.addEventListener("ln-validate:clear-custom",this._onClearCustom),this}a.prototype.validate=function(){const n=this.dom,i=n.validity,l=n.checkValidity()&&this._customErrors.size===0,u=n.closest(".form-element");if(u){const r=u.querySelector("["+f+"]");if(r){const o=r.querySelectorAll("["+h+"]");for(let d=0;d<o.length;d++){const y=o[d].getAttribute(h),g=p[y];g&&(i[g]?o[d].classList.remove("hidden"):o[d].classList.add("hidden"))}}}return n.classList.toggle(m,l),n.classList.toggle(v,!l),C(n,l?"ln-validate:valid":"ln-validate:invalid",{target:n,field:n.name}),l},a.prototype.reset=function(){this._touched=!1,this._customErrors.clear(),this.dom.classList.remove(m,v);const n=this.dom.closest(".form-element");if(n){const i=n.querySelectorAll("["+h+"]");for(let c=0;c<i.length;c++)i[c].classList.add("hidden")}},Object.defineProperty(a.prototype,"isValid",{get:function(){return this.dom.checkValidity()&&this._customErrors.size===0}}),a.prototype.destroy=function(){this.dom[e]&&(this.dom.removeEventListener("input",this._onInput),this.dom.removeEventListener("change",this._onChange),this.dom.removeEventListener("ln-validate:set-custom",this._onSetCustom),this.dom.removeEventListener("ln-validate:clear-custom",this._onClearCustom),this.dom.classList.remove(m,v),C(this.dom,"ln-validate:destroyed",{target:this.dom}),delete this.dom[e])},O(t,e,a,"ln-validate")})()})();
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 w={};function D(t,e){w[t]||(w[t]=document.querySelector('[data-ln-template="'+t+'"]'));const r=w[t];return r?r.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function C(t,e,r){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:r||{}}))}function S(t,e){if(!t||!e)return t;const r=t.querySelectorAll("[data-ln-field]");for(let o=0;o<r.length;o++){const s=r[o],n=s.getAttribute("data-ln-field");e[n]!=null&&(s.textContent=e[n])}const c=t.querySelectorAll("[data-ln-attr]");for(let o=0;o<c.length;o++){const s=c[o],n=s.getAttribute("data-ln-attr").split(",");for(let i=0;i<n.length;i++){const a=n[i].trim().split(":");if(a.length!==2)continue;const f=a[0].trim(),p=a[1].trim();e[p]!=null&&s.setAttribute(f,e[p])}}const u=t.querySelectorAll("[data-ln-show]");for(let o=0;o<u.length;o++){const s=u[o],n=s.getAttribute("data-ln-show");n in e&&s.classList.toggle("hidden",!e[n])}const l=t.querySelectorAll("[data-ln-class]");for(let o=0;o<l.length;o++){const s=l[o],n=s.getAttribute("data-ln-class").split(",");for(let i=0;i<n.length;i++){const a=n[i].trim().split(":");if(a.length!==2)continue;const f=a[0].trim(),p=a[1].trim();p in e&&s.classList.toggle(f,!!e[p])}}return t}function M(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const r=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let c=0;c<r.length;c++)r[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)S(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let r=0;r<e.length;r++)e[r].textContent=""}})));function A(t,e){if(!t||!e)return t;const r=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;r.nextNode();){const l=r.currentNode;l.textContent.indexOf("{{")!==-1&&(l.textContent=l.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(o,s){return e[s]!==void 0?e[s]:""}))}const c=function(l,o){return e[o]!==void 0?e[o]:""},u=Array.from(t.querySelectorAll("*"));t.nodeType===1&&u.push(t);for(let l=0;l<u.length;l++){const o=u[l],s=o.attributes;for(let n=0;n<s.length;n++){const i=s[n];i.value.indexOf("{{")!==-1&&o.setAttribute(i.name,i.value.replace(/\{\{\s*(\w+)\s*\}\}/g,c))}}return t}function N(t,e,r,c,u,l){const o={};for(let n=0;n<t.children.length;n++){const i=t.children[n],a=i.getAttribute("data-ln-key");a&&(o[a]=i)}const s=document.createDocumentFragment();for(let n=0;n<e.length;n++){const i=e[n],a=String(c(i));let f=o[a];if(f)u(f,i,n);else{const p=D(r,l);if(!p||(A(p,i),f=p.firstElementChild,!f))continue;f.setAttribute("data-ln-key",a),u(f,i,n)}s.appendChild(f)}t.textContent="",t.appendChild(s)}function O(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){O(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function b(t,e,r,c){if(t.nodeType!==1)return;const l=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",o=Array.from(t.querySelectorAll(l));t.matches&&t.matches(l)&&o.push(t);for(const s of o)s[r]||(s[r]=new c(s))}function V(t,e,r,c,u={}){const l=u.extraAttributes||[],o=u.onAttributeChange||null,s=u.onInit||null;function n(i){const a=i||document.body;b(a,t,e,r),s&&s(a)}return O(function(){const i=new MutationObserver(function(f){for(let p=0;p<f.length;p++){const h=f[p];if(h.type==="childList"){for(let m=0;m<h.addedNodes.length;m++){const d=h.addedNodes[m];d.nodeType===1&&(b(d,t,e,r),s&&s(d))}for(let m=0;m<h.removedNodes.length;m++){const d=h.removedNodes[m];if(d.nodeType===1){const g=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",y=Array.from(d.querySelectorAll(g));d.matches&&d.matches(g)&&y.push(d);for(let _=0;_<y.length;_++){const T=y[_];if(!document.contains(T)){const E=T[e];E&&typeof E.destroy=="function"&&E.destroy()}}}}}else h.type==="attributes"&&(o&&h.target[e]?o(h.target,h.attributeName):(b(h.target,t,e,r),s&&s(h.target)))}});let a=[];if(t.indexOf("[")!==-1){const f=/\[([\w-]+)/g;let p;for(;(p=f.exec(t))!==null;)a.push(p[1])}else a.push(t);i.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:a.concat(l)})},c),window[e]=n,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){n(document.body)}):n(document.body),n}const x={};function F(t,e){x[t]=e}function I(t){return x[t]||{ingress:e=>e,egress:e=>e}}const q={};function R(t,e){if(!t||typeof e!="object")return;const r=t.toLowerCase().split("-")[0];q[r]=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=F,window.lnCore.getDataMapper=I,window.lnCore.registerLocaleFallback=R,window.lnCore.getLocaleFallback=j,window.lnCore.fillTemplate=A,window.lnCore.fill=S,window.lnCore.lnFill=M,window.lnCore.renderList=N);function k(t){return(t||"").replace(/^#/,"")}function L(t){const e=t===void 0?location.hash:t,r={},c=k(e);if(!c)return r;const u=c.split("&");for(let l=0;l<u.length;l++){const o=u[l];if(!o)continue;const s=o.indexOf(":"),n=s>-1?o.slice(0,s):o,i=s>-1?o.slice(s+1):"";if(n)try{r[n]=decodeURIComponent(i)}catch{r[n]=i}}return r}function B(t){if(!t)return null;const e=L();return t in e?e[t]:null}function P(t,e){if(!t)return;const r=L();e==null?delete r[t]:r[t]=String(e);const u=Object.keys(r).map(function(l){const o=r[l];return o===""?l:l+":"+encodeURIComponent(o)}).join("&");k(location.hash)!==u&&(location.hash=u)}function W(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=L,window.lnCore.hashGet=B,window.lnCore.hashSet=P,window.lnCore.hashLinkClick=W),(function(){const t="data-ln-validate",e="lnValidate",r="data-ln-validate-errors",c="data-ln-validate-error",u="ln-validate-valid",l="ln-validate-invalid",o={required:"valueMissing",typeMismatch:"typeMismatch",tooShort:"tooShort",tooLong:"tooLong",patternMismatch:"patternMismatch",rangeUnderflow:"rangeUnderflow",rangeOverflow:"rangeOverflow"};if(window[e]!==void 0)return;function s(n){this.dom=n,this._touched=!1,this._customErrors=new Set;const i=this,a=n.tagName,f=n.type,p=a==="SELECT"||f==="checkbox"||f==="radio";this._onInput=function(){i._touched=!0,i.validate()},this._onChange=function(){i._touched=!0,i.validate()},this._onSetCustom=function(m){const d=m.detail&&m.detail.error;if(!d)return;i._customErrors.add(d),i._touched=!0;const v=n.closest(".form-element");if(v){const g=v.querySelector("["+c+'="'+d+'"]');g&&g.classList.remove("hidden")}n.classList.remove(u),n.classList.add(l),n.setAttribute("aria-invalid","true")},this._onClearCustom=function(m){const d=m.detail&&m.detail.error,v=n.closest(".form-element");if(d){if(i._customErrors.delete(d),v){const g=v.querySelector("["+c+'="'+d+'"]');g&&g.classList.add("hidden")}}else i._customErrors.forEach(function(g){if(v){const y=v.querySelector("["+c+'="'+g+'"]');y&&y.classList.add("hidden")}}),i._customErrors.clear();i._touched&&i.validate()},p||n.addEventListener("input",this._onInput),n.addEventListener("change",this._onChange),n.addEventListener("ln-validate:set-custom",this._onSetCustom),n.addEventListener("ln-validate:clear-custom",this._onClearCustom);const h=n.form;return h&&(h.hasAttribute("novalidate")||h.setAttribute("novalidate",""),this._onFormReset=function(){i.reset()},this._onValidateRequest=function(m){i._touched=!0,!i.validate()&&m.detail&&m.detail.invalidFields&&m.detail.invalidFields.push(i.dom)},h.addEventListener("reset",this._onFormReset),h.addEventListener("ln-validate:request-validate",this._onValidateRequest),h._lnValidateGateBound||(h._lnValidateGateBound=!0,h.addEventListener("submit",function(m){const d={invalidFields:[]};C(h,"ln-validate:request-validate",d),d.invalidFields.length>0&&(m.preventDefault(),d.invalidFields.sort((v,g)=>v.compareDocumentPosition(g)&Node.DOCUMENT_POSITION_PRECEDING?-1:1),d.invalidFields[0].focus())}))),this}s.prototype.validate=function(){const n=this.dom,i=n.validity,f=n.checkValidity()&&this._customErrors.size===0,p=n.closest(".form-element");if(p){const m=p.querySelector("["+r+"]");if(m){const d=m.querySelectorAll("["+c+"]");for(let v=0;v<d.length;v++){const g=d[v].getAttribute(c),y=o[g];y&&(i[y]?d[v].classList.remove("hidden"):d[v].classList.add("hidden"))}}}return n.classList.toggle(u,f),n.classList.toggle(l,!f),n.setAttribute("aria-invalid",f?"false":"true"),C(n,f?"ln-validate:valid":"ln-validate:invalid",{target:n,field:n.name}),f},s.prototype.reset=function(){this._touched=!1,this._customErrors.clear(),this.dom.classList.remove(u,l),this.dom.removeAttribute("aria-invalid");const n=this.dom.closest(".form-element");if(n){const i=n.querySelectorAll("["+c+"]");for(let a=0;a<i.length;a++)i[a].classList.add("hidden")}},Object.defineProperty(s.prototype,"isValid",{get:function(){return this.dom.checkValidity()&&this._customErrors.size===0}}),s.prototype.destroy=function(){if(!this.dom[e])return;this.dom.removeEventListener("input",this._onInput),this.dom.removeEventListener("change",this._onChange),this.dom.removeEventListener("ln-validate:set-custom",this._onSetCustom),this.dom.removeEventListener("ln-validate:clear-custom",this._onClearCustom);const n=this.dom.form;n&&(this._onFormReset&&n.removeEventListener("reset",this._onFormReset),this._onValidateRequest&&n.removeEventListener("ln-validate:request-validate",this._onValidateRequest)),this.dom.classList.remove(u,l),this.dom.removeAttribute("aria-invalid"),C(this.dom,"ln-validate:destroyed",{target:this.dom}),delete this.dom[e]},V(t,e,s,"ln-validate")})()})();
@@ -54,6 +54,7 @@ import { dispatch, registerComponent } from '../../ln-core';
54
54
  }
55
55
  dom.classList.remove(CSS_VALID);
56
56
  dom.classList.add(CSS_INVALID);
57
+ dom.setAttribute('aria-invalid', 'true');
57
58
  };
58
59
 
59
60
  this._onClearCustom = function (e) {
@@ -86,6 +87,57 @@ import { dispatch, registerComponent } from '../../ln-core';
86
87
  dom.addEventListener('ln-validate:set-custom', this._onSetCustom);
87
88
  dom.addEventListener('ln-validate:clear-custom', this._onClearCustom);
88
89
 
90
+ const form = dom.form;
91
+ if (form) {
92
+ // One validated field is enough to own the form's gate —
93
+ // browser bubbles must yield to ln-validate's own error display.
94
+ if (!form.hasAttribute('novalidate')) {
95
+ form.setAttribute('novalidate', '');
96
+ }
97
+
98
+ this._onFormReset = function () {
99
+ self.reset();
100
+ };
101
+ this._onValidateRequest = function (e) {
102
+ self._touched = true;
103
+ const isValid = self.validate();
104
+ if (!isValid && e.detail && e.detail.invalidFields) {
105
+ e.detail.invalidFields.push(self.dom);
106
+ }
107
+ };
108
+ form.addEventListener('reset', this._onFormReset);
109
+ form.addEventListener('ln-validate:request-validate', this._onValidateRequest);
110
+
111
+ // Submit gate — attached once per form, by whichever validated
112
+ // field initializes first (marker lives on the form itself,
113
+ // same one-shot-guard shape as ln-fill's _fillBound). Runs on
114
+ // every method, GET included — this is a straight replacement
115
+ // for the native browser validation `novalidate` just silenced
116
+ // above, and native validation never had a method condition
117
+ // either. ln-data-coordinator's own POST/PUT/PATCH method gate
118
+ // for the write pipeline is a separate, later decision — it
119
+ // reads e.defaultPrevented first, so it never claims a submit
120
+ // this gate already blocked.
121
+ if (!form._lnValidateGateBound) {
122
+ form._lnValidateGateBound = true;
123
+ form.addEventListener('submit', function (e) {
124
+ const validationDetail = { invalidFields: [] };
125
+ dispatch(form, 'ln-validate:request-validate', validationDetail);
126
+
127
+ if (validationDetail.invalidFields.length > 0) {
128
+ e.preventDefault();
129
+ validationDetail.invalidFields.sort((a, b) => {
130
+ return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING ? -1 : 1;
131
+ });
132
+ validationDetail.invalidFields[0].focus();
133
+ }
134
+ // Valid — nothing left to do. Native submit continues;
135
+ // ln-data-coordinator (document, bubble phase) or plain
136
+ // HTML/ln-ajax decides what happens to it next.
137
+ });
138
+ }
139
+ }
140
+
89
141
  return this;
90
142
  }
91
143
 
@@ -118,6 +170,9 @@ import { dispatch, registerComponent } from '../../ln-core';
118
170
  dom.classList.toggle(CSS_VALID, isValid);
119
171
  dom.classList.toggle(CSS_INVALID, !isValid);
120
172
 
173
+ // Manage ARIA invalid attribute
174
+ dom.setAttribute('aria-invalid', isValid ? 'false' : 'true');
175
+
121
176
  // Emit event
122
177
  const eventName = isValid ? 'ln-validate:valid' : 'ln-validate:invalid';
123
178
  dispatch(dom, eventName, { target: dom, field: dom.name });
@@ -129,6 +184,7 @@ import { dispatch, registerComponent } from '../../ln-core';
129
184
  this._touched = false;
130
185
  this._customErrors.clear();
131
186
  this.dom.classList.remove(CSS_VALID, CSS_INVALID);
187
+ this.dom.removeAttribute('aria-invalid');
132
188
 
133
189
  const parent = this.dom.closest('.form-element');
134
190
  if (parent) {
@@ -149,7 +205,15 @@ import { dispatch, registerComponent } from '../../ln-core';
149
205
  this.dom.removeEventListener('change', this._onChange);
150
206
  this.dom.removeEventListener('ln-validate:set-custom', this._onSetCustom);
151
207
  this.dom.removeEventListener('ln-validate:clear-custom', this._onClearCustom);
208
+
209
+ const form = this.dom.form;
210
+ if (form) {
211
+ if (this._onFormReset) form.removeEventListener('reset', this._onFormReset);
212
+ if (this._onValidateRequest) form.removeEventListener('ln-validate:request-validate', this._onValidateRequest);
213
+ }
214
+
152
215
  this.dom.classList.remove(CSS_VALID, CSS_INVALID);
216
+ this.dom.removeAttribute('aria-invalid');
153
217
  dispatch(this.dom, 'ln-validate:destroyed', { target: this.dom });
154
218
  delete this.dom[DOM_ATTRIBUTE];
155
219
  };
package/package.json CHANGED
@@ -1,56 +1,61 @@
1
- {
2
- "name": "@livenetworks/ashlar",
3
- "version": "1.5.1",
4
- "description": "LiveNetworks unified frontend library - CSS framework and vanilla JS components",
5
- "license": "MIT",
6
- "author": "Live Networks",
7
- "type": "module",
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
- "repository": {
12
- "type": "git",
13
- "url": "git+https://github.com/livenetworks/ln-ashlar.git"
14
- },
15
- "homepage": "https://github.com/livenetworks/ln-ashlar#readme",
16
- "bugs": "https://github.com/livenetworks/ln-ashlar/issues",
17
- "keywords": [
18
- "css",
19
- "scss",
20
- "design-system",
21
- "frontend",
22
- "components",
23
- "vanilla-js"
24
- ],
25
- "files": [
26
- "scss/",
27
- "js/"
28
- ],
29
- "exports": {
30
- "./scss/*": "./scss/*",
31
- "./js/*": "./js/*"
32
- },
33
- "scripts": {
34
- "dev": "node scripts/build.mjs --watch",
35
- "build": "node scripts/build.mjs && npm run build:dev-css && npm run build:demos",
36
- "build:demos": "npm run build:demo-index && npm run build:demo-admin && npm run build:demo-docuflow",
37
- "build:dev-css": "sass scss/ln-ashlar-dev.scss demo/dist/ln-ashlar-dev.css --no-source-map --style=compressed",
38
- "build:demo-index": "sass demo/index.scss demo/index.css --no-source-map --style=compressed",
39
- "build:demo-admin": "sass demo/admin/src/admin.scss demo/admin/dist/admin.css --no-source-map --style=compressed && cp demo/admin/src/demo.js demo/admin/dist/demo.js && node demo/admin/src/build-pages.mjs",
40
- "build:demo-docuflow": "sass demo/docuflow/docuflow.scss demo/docuflow/dist/docuflow.css --no-source-map --style=compressed"
41
- },
42
- "devDependencies": {
43
- "@tabler/icons": "^3.41.1",
44
- "flag-icons": "^7.5.0",
45
- "sass-embedded": "^1.83.0",
46
- "vite": "^6.0.0"
47
- },
48
- "peerDependencies": {
49
- "tom-select": "^2.0.0"
50
- },
51
- "peerDependenciesMeta": {
52
- "tom-select": {
53
- "optional": true
54
- }
55
- }
56
- }
1
+ {
2
+ "name": "@livenetworks/ashlar",
3
+ "version": "1.6.0",
4
+ "description": "LiveNetworks unified frontend library - CSS framework and vanilla JS components",
5
+ "license": "MIT",
6
+ "author": "Live Networks",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/livenetworks/ln-ashlar.git"
14
+ },
15
+ "homepage": "https://github.com/livenetworks/ln-ashlar#readme",
16
+ "bugs": "https://github.com/livenetworks/ln-ashlar/issues",
17
+ "keywords": [
18
+ "css",
19
+ "scss",
20
+ "design-system",
21
+ "frontend",
22
+ "components",
23
+ "vanilla-js"
24
+ ],
25
+ "files": [
26
+ "scss/",
27
+ "js/"
28
+ ],
29
+ "exports": {
30
+ "./scss/*": "./scss/*",
31
+ "./js/*": "./js/*"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test \"tests/*.test.js\"",
35
+ "test:p0": "node --test tests/p0-data-integrity.test.js",
36
+ "dev": "node scripts/build.mjs --watch",
37
+ "build": "node scripts/build.mjs && npm run build:dev-css && npm run build:demos",
38
+ "build:demos": "npm run build:demo-index && npm run build:demo-admin && npm run build:demo-docuflow && npm run build:demo-spa",
39
+ "build:dev-css": "sass scss/ln-ashlar-dev.scss demo/dist/ln-ashlar-dev.css --no-source-map --style=compressed",
40
+ "build:demo-index": "sass demo/index.scss demo/index.css --no-source-map --style=compressed",
41
+ "build:demo-admin": "sass demo/admin/src/admin.scss demo/admin/dist/admin.css --no-source-map --style=compressed && cp demo/admin/src/demo.js demo/admin/dist/demo.js && node demo/admin/src/build-pages.mjs",
42
+ "build:demo-docuflow": "sass demo/docuflow/docuflow.scss demo/docuflow/dist/docuflow.css --no-source-map --style=compressed",
43
+ "build:demo-spa": "node demo/spa/build.mjs"
44
+ },
45
+ "devDependencies": {
46
+ "@tabler/icons": "^3.41.1",
47
+ "fake-indexeddb": "^6.2.4",
48
+ "flag-icons": "^7.5.0",
49
+ "puppeteer-core": "^25.1.0",
50
+ "sass-embedded": "^1.83.0",
51
+ "vite": "^6.0.0"
52
+ },
53
+ "peerDependencies": {
54
+ "tom-select": "^2.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "tom-select": {
58
+ "optional": true
59
+ }
60
+ }
61
+ }
@@ -14,8 +14,8 @@ body {
14
14
  }
15
15
 
16
16
  body {
17
- font-size: var(--text-body-md);
18
- line-height: var(--lh-body-md);
17
+ font-size: var(--font-size);
18
+ line-height: var(--line-height);
19
19
  color: var(--color-fg);
20
20
  -webkit-font-smoothing: antialiased;
21
21
  -moz-osx-font-smoothing: grayscale;
@@ -52,9 +52,8 @@ h6 {
52
52
  }
53
53
 
54
54
  p {
55
- font-size: var(--text-body-md);
55
+ @include typography(body-md);
56
56
  color: var(--color-fg);
57
- line-height: 1.75;
58
57
  margin-bottom: var(--size-md);
59
58
 
60
59
  &:last-child {
@@ -19,3 +19,4 @@
19
19
  pointer-events: none;
20
20
  }
21
21
  }
22
+
@@ -6,11 +6,6 @@
6
6
 
7
7
  [data-ln-dropdown-menu] {
8
8
  @include dropdown-menu;
9
-
10
- // Reset right-alignment when teleported to body to prevent 100% width stretching
11
- body > & {
12
- right: auto;
13
- }
14
9
  }
15
10
 
16
11
  @keyframes ln-dropdown-in {
@@ -0,0 +1,5 @@
1
+ @use '../config/mixins' as *;
2
+
3
+ [data-ln-editor] {
4
+ @include editor;
5
+ }
@@ -1,25 +1,26 @@
1
- @use '../config/mixins' as *;
2
-
3
- // Filter dropdown content chrome (search input + options list; NO clear button —
4
- // the data-ln-filter-reset "All" sentinel is the reset affordance).
5
- // Floating shell is supplied separately:
6
- // • popover-driven (ln-filter static): @mixin popover on the [data-ln-popover] wrapper
7
- // • ln-data-table dynamic clone: floating-panel + box below
8
- .column-filter-dropdown {
9
- @include filter-dropdown;
10
- }
11
-
12
- // ln-data-table appends its cloned dropdown directly into the <th> and
13
- // manages its own open/close — it does NOT route through ln-popover, so it
14
- // needs the floating shell applied here (the popover path gets it from
15
- // @mixin popover instead).
16
- [data-ln-data-table] .column-filter-dropdown {
17
- @include floating-panel;
18
- position: absolute;
19
- top: 100%;
20
- right: 0;
21
- left: auto;
22
- margin-top: var(--size-xs);
23
- min-width: 14rem;
24
- padding: var(--size-sm);
25
- }
1
+ @use '../config/mixins' as *;
2
+
3
+ // Filter dropdown content chrome (search input + options list; NO clear button —
4
+ // the data-ln-filter-reset "All" sentinel is the reset affordance).
5
+ // Floating shell is supplied separately:
6
+ // • popover-driven (ln-filter static): @mixin popover on the [data-ln-popover] wrapper
7
+ // ln-table dynamic clone: floating-panel + box below
8
+ .column-filter-dropdown {
9
+ @include filter-dropdown;
10
+ }
11
+
12
+ // ln-table appends its cloned dropdown directly into the <th> and
13
+ // manages its own open/close — it does NOT route through ln-popover, so it
14
+ // needs the floating shell applied here (the popover path gets it from
15
+ // @mixin popover instead).
16
+ [data-ln-table] .column-filter-dropdown {
17
+ @include floating-panel;
18
+ position: absolute;
19
+ z-index: var(--z-dropdown);
20
+ top: 100%;
21
+ right: 0;
22
+ left: auto;
23
+ margin-top: var(--size-xs);
24
+ min-width: 14rem;
25
+ padding: var(--size-sm);
26
+ }
@@ -68,6 +68,10 @@ label:not(:has(> input)) {
68
68
  @include pills-switch;
69
69
  }
70
70
 
71
+ .pills-segmented {
72
+ @include pills-segmented;
73
+ }
74
+
71
75
  .pill {
72
76
  @include pill;
73
77
  }
@@ -80,6 +84,9 @@ label:not(:has(> input)) {
80
84
  @include pill-switch;
81
85
  }
82
86
 
87
+ .search {
88
+ @include search;
89
+ }
83
90
 
84
91
  .form-actions {
85
92
  @include form-actions;
@@ -8,12 +8,12 @@
8
8
  [data-ln-table] thead > tr:first-child:has(> th[colspan]) > th {
9
9
  @include ln-table-thead-toolbar;
10
10
  }
11
-
11
+ [data-ln-table] > header,
12
12
  .ln-table__toolbar {
13
13
  @include ln-table-toolbar;
14
14
  }
15
15
 
16
- .ln-table__toolbar label:has(> input[type="search"]) > input {
16
+ .ln-table__toolbar label:has(> input[type="search"]) {
17
17
  @include ln-table-toolbar-search;
18
18
  }
19
19
 
@@ -24,7 +24,7 @@
24
24
  .table-container {
25
25
  @include ln-table-card-mode;
26
26
  }
27
-
27
+ [data-ln-table] > footer,
28
28
  .ln-table__footer {
29
29
  @include ln-table-footer;
30
30
  }
@@ -57,3 +57,13 @@
57
57
  @include ln-table-spacer-row;
58
58
  }
59
59
 
60
+ .ln-table__placeholder {
61
+ @include ln-table-placeholder-row;
62
+ }
63
+
64
+ .ln-table--loading tbody {
65
+ opacity: 0.5;
66
+ pointer-events: none;
67
+ }
68
+
69
+
@@ -0,0 +1,10 @@
1
+ @use '../config/mixins' as *;
2
+
3
+ [data-ln-sort] { @include sort; }
4
+
5
+ th:hover [data-ln-sort] { opacity: 0.7; }
6
+
7
+ [data-ln-sort][data-ln-sort-state="asc"],
8
+ [data-ln-sort][data-ln-sort-state="desc"] {
9
+ @include sort-active;
10
+ }