@livenetworks/ashlar 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +189 -177
  2. package/js/COMPONENTS.md +55 -49
  3. package/js/index.js +22 -5
  4. package/js/ln-accordion/README.md +186 -137
  5. package/js/ln-accordion/ln-accordion.js +1 -1
  6. package/js/ln-ajax/README.md +73 -4
  7. package/js/ln-ajax/ln-ajax.js +1 -1
  8. package/js/ln-ajax/src/ln-ajax.js +13 -13
  9. package/js/ln-api-connector/README.md +261 -150
  10. package/js/ln-api-connector/ln-api-connector.js +1 -1
  11. package/js/ln-api-connector/src/ln-api-connector.js +368 -265
  12. package/js/ln-api-queue/README.md +219 -0
  13. package/js/ln-api-queue/ln-api-queue.js +1 -0
  14. package/js/ln-api-queue/src/ln-api-queue.js +320 -0
  15. package/js/ln-api-queue/src/queue-storage.js +455 -0
  16. package/js/ln-autoresize/README.md +47 -0
  17. package/js/ln-autoresize/ln-autoresize.js +1 -1
  18. package/js/ln-autosave/README.md +33 -1
  19. package/js/ln-autosave/ln-autosave.js +1 -1
  20. package/js/ln-autosave/src/ln-autosave.js +4 -4
  21. package/js/ln-circular-progress/README.md +27 -13
  22. package/js/ln-circular-progress/ln-circular-progress.js +1 -1
  23. package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
  24. package/js/ln-confirm/README.md +165 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +82 -42
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +92 -2
  66. package/js/ln-filter/ln-filter-dev.scss +8 -0
  67. package/js/ln-filter/ln-filter.js +1 -1
  68. package/js/ln-filter/src/ln-filter.js +22 -7
  69. package/js/ln-form/README.md +212 -49
  70. package/js/ln-form/ln-form.js +1 -1
  71. package/js/ln-form/src/ln-form.js +99 -199
  72. package/js/ln-http/README.md +125 -89
  73. package/js/ln-http/ln-http.js +1 -1
  74. package/js/ln-http/src/ln-http.js +32 -13
  75. package/js/ln-icon/README.md +130 -0
  76. package/js/ln-icon/ln-icon.js +1 -0
  77. package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
  78. package/js/ln-link/README.md +47 -9
  79. package/js/ln-link/ln-link.js +1 -1
  80. package/js/ln-link/src/ln-link.js +22 -6
  81. package/js/ln-list/README.md +129 -0
  82. package/js/ln-list/ln-list.js +1 -0
  83. package/js/ln-list/ln-list.scss +28 -0
  84. package/js/ln-list/src/ln-list.js +1216 -0
  85. package/js/ln-modal/README.md +185 -154
  86. package/js/ln-modal/ln-modal-dev.scss +13 -0
  87. package/js/ln-modal/ln-modal.js +1 -1
  88. package/js/ln-modal/ln-modal.scss +28 -4
  89. package/js/ln-modal/src/ln-modal.js +137 -201
  90. package/js/ln-modal-coordinator/README.md +99 -0
  91. package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
  92. package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
  93. package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
  94. package/js/ln-nav/README.md +46 -3
  95. package/js/ln-nav/ln-nav.js +1 -1
  96. package/js/ln-nav/src/ln-nav.js +92 -127
  97. package/js/ln-number/README.md +28 -1
  98. package/js/ln-number/ln-number-dev.scss +13 -0
  99. package/js/ln-number/ln-number.js +1 -1
  100. package/js/ln-number/src/ln-number.js +443 -350
  101. package/js/ln-options/README.md +66 -0
  102. package/js/ln-options/ln-options.js +1 -0
  103. package/js/ln-options/src/ln-options.js +74 -0
  104. package/js/ln-popover/README.md +40 -5
  105. package/js/ln-popover/ln-popover.js +1 -1
  106. package/js/ln-popover/src/ln-popover.js +38 -11
  107. package/js/ln-progress/README.md +413 -442
  108. package/js/ln-progress/ln-progress.js +1 -1
  109. package/js/ln-progress/src/ln-progress.js +98 -150
  110. package/js/ln-router/README.md +432 -0
  111. package/js/ln-router/ln-router.js +1 -0
  112. package/js/ln-router/src/ln-router.js +539 -0
  113. package/js/ln-search/README.md +158 -83
  114. package/js/ln-search/ln-search-dev.scss +8 -0
  115. package/js/ln-search/ln-search.js +1 -1
  116. package/js/ln-search/src/ln-search.js +12 -9
  117. package/js/ln-slug/README.md +110 -0
  118. package/js/ln-slug/ln-slug.js +1 -0
  119. package/js/ln-slug/src/ln-slug.js +74 -0
  120. package/js/ln-sort/README.md +170 -0
  121. package/js/ln-sort/ln-sort-dev.scss +8 -0
  122. package/js/ln-sort/ln-sort.js +1 -0
  123. package/js/ln-sort/src/ln-sort.js +178 -0
  124. package/js/ln-sortable/README.md +133 -95
  125. package/js/ln-sortable/ln-sortable.js +1 -1
  126. package/js/ln-stat/README.md +55 -0
  127. package/js/ln-stat/ln-stat.js +1 -0
  128. package/js/ln-stat/src/ln-stat.js +55 -0
  129. package/js/ln-table/README.md +285 -101
  130. package/js/ln-table/ln-table-dev.scss +17 -0
  131. package/js/ln-table/ln-table.js +1 -1
  132. package/js/ln-table/ln-table.scss +1 -1
  133. package/js/ln-table/src/ln-table.js +1493 -473
  134. package/js/ln-table-coordinator/README.md +73 -0
  135. package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
  136. package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
  137. package/js/ln-tabs/README.md +221 -137
  138. package/js/ln-tabs/ln-tabs-dev.scss +18 -0
  139. package/js/ln-tabs/ln-tabs.js +1 -1
  140. package/js/ln-tabs/src/ln-tabs.js +41 -24
  141. package/js/ln-time/README.md +14 -0
  142. package/js/ln-time/ln-time.js +1 -1
  143. package/js/ln-time/src/ln-time.js +223 -192
  144. package/js/ln-toast/README.md +178 -122
  145. package/js/ln-toast/ln-toast.js +1 -15
  146. package/js/ln-toast/src/ln-toast.js +232 -210
  147. package/js/ln-toast/template.html +16 -12
  148. package/js/ln-toggle/README.md +61 -2
  149. package/js/ln-toggle/ln-toggle-dev.scss +13 -0
  150. package/js/ln-toggle/ln-toggle.js +1 -1
  151. package/js/ln-toggle/src/ln-toggle.js +56 -43
  152. package/js/ln-tooltip/README.md +95 -58
  153. package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
  154. package/js/ln-tooltip/ln-tooltip.js +1 -1
  155. package/js/ln-tooltip/src/ln-tooltip.js +45 -6
  156. package/js/ln-translations/README.md +118 -96
  157. package/js/ln-translations/ln-translations.js +1 -1
  158. package/js/ln-translations/src/ln-translations.js +10 -5
  159. package/js/ln-upload/README.md +85 -10
  160. package/js/ln-upload/ln-upload.js +1 -1
  161. package/js/ln-upload/src/ln-upload.js +26 -10
  162. package/js/ln-validate/README.md +72 -0
  163. package/js/ln-validate/ln-validate-dev.scss +15 -0
  164. package/js/ln-validate/ln-validate.js +1 -1
  165. package/js/ln-validate/src/ln-validate.js +64 -0
  166. package/package.json +61 -56
  167. package/scss/base/_global.scss +2 -2
  168. package/scss/base/_typography.scss +1 -2
  169. package/scss/components/_date.scss +1 -0
  170. package/scss/components/_dropdown.scss +0 -5
  171. package/scss/components/_editor.scss +5 -0
  172. package/scss/components/_filter.scss +26 -25
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +13 -3
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +36 -23
  177. package/scss/components/_toast.scss +68 -41
  178. package/scss/components/_toggle.scss +16 -0
  179. package/scss/config/_density.scss +205 -117
  180. package/scss/config/_icons.scss +10 -9
  181. package/scss/config/_tokens.scss +69 -47
  182. package/scss/config/mixins/_accordion.scss +2 -7
  183. package/scss/config/mixins/_alert.scss +4 -4
  184. package/scss/config/mixins/_breadcrumbs.scss +2 -2
  185. package/scss/config/mixins/_btn.scss +1 -1
  186. package/scss/config/mixins/_card.scss +89 -14
  187. package/scss/config/mixins/_collapsible.scss +3 -0
  188. package/scss/config/mixins/_colors.scss +1 -1
  189. package/scss/config/mixins/_diagnostics.scss +11 -0
  190. package/scss/config/mixins/_display.scss +1 -0
  191. package/scss/config/mixins/_dropdown.scss +3 -5
  192. package/scss/config/mixins/_editor.scss +124 -0
  193. package/scss/config/mixins/_empty-state.scss +7 -0
  194. package/scss/config/mixins/_filter.scss +3 -5
  195. package/scss/config/mixins/_footer.scss +1 -2
  196. package/scss/config/mixins/_form.scss +207 -21
  197. package/scss/config/mixins/_index.scss +2 -0
  198. package/scss/config/mixins/_ln-table.scss +41 -2
  199. package/scss/config/mixins/_modal.scss +149 -66
  200. package/scss/config/mixins/_nav.scss +3 -4
  201. package/scss/config/mixins/_popover.scss +39 -0
  202. package/scss/config/mixins/_sidebar.scss +1 -1
  203. package/scss/config/mixins/_sort.scss +42 -0
  204. package/scss/config/mixins/_table.scss +85 -60
  205. package/scss/config/mixins/_toast.scss +179 -152
  206. package/scss/config/mixins/_tooltip.scss +17 -7
  207. package/scss/config/mixins/_typography.scss +4 -2
  208. package/scss/ln-ashlar-dev.scss +27 -9
  209. package/scss/ln-ashlar.scss +7 -1
  210. package/js/ln-data-table/README.md +0 -110
  211. package/js/ln-data-table/ln-data-table.js +0 -1
  212. package/js/ln-data-table/ln-data-table.scss +0 -10
  213. package/js/ln-data-table/src/ln-data-table.js +0 -1087
  214. package/js/ln-icons/README.md +0 -88
  215. package/js/ln-icons/ln-icons.js +0 -1
  216. package/js/ln-table/ln-table-sort.js +0 -1
  217. package/js/ln-table/src/ln-table-sort.js +0 -156
@@ -0,0 +1,73 @@
1
+ # ln-table-coordinator
2
+
3
+ A zero-dependency, per-instance, host-scoped coordinator component that wires external search hosts (`ln-search`), column filter popovers (`ln-filter`), header filter indicators, and clear button triggers to a single `ln-table`.
4
+
5
+ `ln-table` itself is a Layer 1 primitive: it manages internal data state, virtual scroll rendering, pagination/windowing cache, row template cloning, cell formatting, and selection DOM state. `ln-table-coordinator` is the Layer 2 policy and mediator that wires external UI controls to `ln-table`.
6
+
7
+ ---
8
+
9
+ ## 🧭 Philosophy & Architecture
10
+
11
+ 1. **Layer Separation.** `ln-table` manages its own internal table state and DOM subtree only — no external DOM querying, no external input mirroring, and no visual indicator class toggles on header buttons. `ln-table-coordinator` handles all project UI wiring on top.
12
+ 2. **Host-Scoped, Per-Instance.** The coordinator is a real component: `[data-ln-table-coordinator]` is a **required wrapper**. Its handlers are bound to its own host element (`self.dom`), not `document`. The table, its `[data-ln-filter]` popovers, and its clear button(s) must all be descendants of the same coordinator host — outside its own subtree, the coordinator does not exist and does not look.
13
+ 3. **Event-Driven Communication.** Communication with `ln-table` occurs strictly via CustomEvents (`ln-table:set-search`, `ln-table:set-filter`, `ln-table:request-clear-filters`).
14
+
15
+ **Breaking change:** a data-driven table's search/filter/clear-all only works when wrapped in `[data-ln-table-coordinator]`. There is no page-wide fallback — the coordinator does not resolve a table by scanning the whole document.
16
+
17
+ ---
18
+
19
+ ## 🛠️ Declarative API & Wire Contract
20
+
21
+ | Target / Trigger | Event / Trigger | Behavior |
22
+ | :--- | :--- | :--- |
23
+ | `[data-ln-search="tableId"]` (delivered by `ln-search` directly to the table) | `ln-search:change` | Guards `e.target` carries `data-ln-table` and `data-ln-table-source` (data-driven only), dispatches `ln-table:set-search`. |
24
+ | `[data-ln-filter="tableId"]` (delivered by `ln-filter` directly to the table) | `ln-filter:changed` | Guards `e.target` carries `data-ln-table`, toggles `.ln-filter-active` indicator on filter button, dispatches `ln-table:set-filter`. |
25
+ | `[data-ln-table-clear]`, `[data-ln-table-clear-all]` | `click` | Resolves the table structurally within this host, clears the linked search input and filter reset checkboxes, removes `.ln-filter-active` visual classes, dispatches `ln-table:request-clear-filters`. |
26
+ | Document | `keydown` (`'/'`) | Focuses the search input inside the active coordinator wrapper on the page (or the first `[data-ln-search]` if none). |
27
+
28
+ Column filters (`data-ln-table-filter-col`) and clear-all (`data-ln-table-clear` / `data-ln-table-clear-all`) route through `ln-table-coordinator` in both SSR and data-driven modes — an SSR table using them still requires the `[data-ln-table-coordinator]` wrapper, even though SSR search and sort do not (see below).
29
+
30
+ Search is the one asymmetric control: the coordinator only intercepts search for **data-driven** tables. SSR tables self-bind `ln-search:change` directly (see [`js/ln-table/README.md`](../ln-table/README.md)) and never need the wrapper for search alone.
31
+
32
+ ---
33
+
34
+ ## ⚡ Consumed & Dispatched Events
35
+
36
+ ### Consumed Events
37
+ - `ln-search:change` (on `self.dom`, the coordinator host)
38
+ - `ln-filter:changed` (on `self.dom`)
39
+ - `click` (on `self.dom` — targets `[data-ln-table-clear]` and `[data-ln-table-clear-all]`)
40
+ - `keydown` (`document` — key `'/'`, the sole document-level exception, see below)
41
+
42
+ ### Dispatched Events
43
+ - `ln-table:set-search` (Target: the table inside this host, Payload: `{ query, term, table }`)
44
+ - `ln-table:set-filter` (Target: the table inside this host, Payload: `{ key, values, table }`)
45
+ - `ln-table:request-clear-filters` (Target: the table inside this host, Payload: `{ table }`)
46
+
47
+ The coordinator does not dispatch anything from its own host element — it has no outward/notification event. It only relays inward, to the table.
48
+
49
+ ---
50
+
51
+ ## 🔧 Internals
52
+
53
+ Source: `js/ln-table-coordinator/src/ln-table-coordinator.js`. This is a real per-instance component: `_component(dom)` calls `_bindEvents(self)`, which stores its three listeners on `self._handlers` and attaches them to `self.dom` — mirroring the `js/ln-data-coordinator` idiom. `destroy()` removes all three symmetrically and clears `self._handlers`. `registerComponent` also supplies the `window.lnTableCoordinator` double-load guard.
54
+
55
+ ### Search
56
+
57
+ `ln-search` dispatches `ln-search:change` directly on its resolved target (`document.getElementById(targetId)`) — when that target IS the table, `e.target` already carries `data-ln-table`. The coordinator does no ID resolution for search, only a guard: `e.target` must carry both `data-ln-table` and `data-ln-table-source` (data-driven). An SSR table caught by this listener is a markup mistake — it self-binds `ln-search:change` on its own, and wrapping it in `[data-ln-table-coordinator]` would double-fire; the `data-ln-table-source` guard prevents that.
58
+
59
+ ### Column-filter indicator
60
+
61
+ `ln-filter` dispatches `ln-filter:changed` on both its own `<ul data-ln-filter>` root and, if different, directly on the table via `getElementById` — again, no ID resolution needed, only the `data-ln-table` guard. On a match, the coordinator finds the `th[data-ln-table-filter-col="key"]` and toggles `.ln-filter-active` on that header's `[data-ln-table-col-filter]` button based on `values.length > 0`, then dispatches `ln-table:set-filter`. This header-indicator class is Layer-2 policy — which is exactly why `ln-table` itself never sets it.
62
+
63
+ ### Clear
64
+
65
+ A `click` on `[data-ln-table-clear]` / `[data-ln-table-clear-all]` has no ID binding of its own, so it resolves structurally: `clearBtn.closest('[data-ln-table]')`, falling back to `dom.querySelector('[data-ln-table]')` — always scoped to this host, never document-wide. It then runs: (1) strip `.ln-filter-active` off every filter button in the table, (2) blank the linked search input (scoped to this host), (3) check each linked filter popover's `[data-ln-filter-reset]` and fire a bubbling `change`, (4) dispatch `ln-table:request-clear-filters`.
66
+
67
+ ### `/` focus shortcut
68
+
69
+ The sole deliberate exception to host-scoping. It remains a single `document`-level `keydown` listener registered once at module scope (outside `_component`/`_bindEvents`) — a page-level keyboard affordance, not coordination between a host and its children. Registering it per-instance would fire it once per coordinator on the page. It focuses the search input inside the active `[data-ln-table-coordinator]` wrapper, or the first `[data-ln-search]` on the page if none. Ignored when focus is already in an `INPUT`/`TEXTAREA`, or when the event was already `defaultPrevented`.
70
+
71
+ ### Multi-coordinator isolation
72
+
73
+ Each `_component(dom)` instance closures its handlers over its own `dom`; `addEventListener` on a specific host node only fires for events whose bubble path passes through that node. Two coordinator hosts on the same page cannot see each other's descendants' events — no shared/global state exists to leak. The `/` shortcut is the sole intentional exception to this isolation — it is document-scoped by design.
@@ -0,0 +1 @@
1
+ (function(){"use strict";if(typeof window<"u"){const t=console.warn;console.warn=function(...e){typeof e[0]=="string"&&(e[0].startsWith("[ln-")||e[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||t.apply(console,e)}}const b={};function O(t,e){b[t]||(b[t]=document.querySelector('[data-ln-template="'+t+'"]'));const i=b[t];return i?i.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function g(t,e,i){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:i||{}}))}function E(t,e){if(!t||!e)return t;const i=t.querySelectorAll("[data-ln-field]");for(let n=0;n<i.length;n++){const l=i[n],r=l.getAttribute("data-ln-field");e[r]!=null&&(l.textContent=e[r])}const f=t.querySelectorAll("[data-ln-attr]");for(let n=0;n<f.length;n++){const l=f[n],r=l.getAttribute("data-ln-attr").split(",");for(let a=0;a<r.length;a++){const d=r[a].trim().split(":");if(d.length!==2)continue;const u=d[0].trim(),c=d[1].trim();e[c]!=null&&l.setAttribute(u,e[c])}}const s=t.querySelectorAll("[data-ln-show]");for(let n=0;n<s.length;n++){const l=s[n],r=l.getAttribute("data-ln-show");r in e&&l.classList.toggle("hidden",!e[r])}const o=t.querySelectorAll("[data-ln-class]");for(let n=0;n<o.length;n++){const l=o[n],r=l.getAttribute("data-ln-class").split(",");for(let a=0;a<r.length;a++){const d=r[a].trim().split(":");if(d.length!==2)continue;const u=d[0].trim(),c=d[1].trim();c in e&&l.classList.toggle(u,!!e[c])}}return t}function D(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const i=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let f=0;f<i.length;f++)i[f].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)E(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let i=0;i<e.length;i++)e[i].textContent=""}})));function S(t,e){if(!t||!e)return t;const i=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;i.nextNode();){const o=i.currentNode;o.textContent.indexOf("{{")!==-1&&(o.textContent=o.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(n,l){return e[l]!==void 0?e[l]:""}))}const f=function(o,n){return e[n]!==void 0?e[n]:""},s=Array.from(t.querySelectorAll("*"));t.nodeType===1&&s.push(t);for(let o=0;o<s.length;o++){const n=s[o],l=n.attributes;for(let r=0;r<l.length;r++){const a=l[r];a.value.indexOf("{{")!==-1&&n.setAttribute(a.name,a.value.replace(/\{\{\s*(\w+)\s*\}\}/g,f))}}return t}function I(t,e,i,f,s,o){const n={};for(let r=0;r<t.children.length;r++){const a=t.children[r],d=a.getAttribute("data-ln-key");d&&(n[d]=a)}const l=document.createDocumentFragment();for(let r=0;r<e.length;r++){const a=e[r],d=String(f(a));let u=n[d];if(u)s(u,a,r);else{const c=O(i,o);if(!c||(S(c,a),u=c.firstElementChild,!u))continue;u.setAttribute("data-ln-key",d),s(u,a,r)}l.appendChild(u)}t.textContent="",t.appendChild(l)}function L(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){L(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function y(t,e,i,f){if(t.nodeType!==1)return;const o=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",n=Array.from(t.querySelectorAll(o));t.matches&&t.matches(o)&&n.push(t);for(const l of n)l[i]||(l[i]=new f(l))}function M(t,e,i,f,s={}){const o=s.extraAttributes||[],n=s.onAttributeChange||null,l=s.onInit||null;function r(a){const d=a||document.body;y(d,t,e,i),l&&l(d)}return L(function(){const a=new MutationObserver(function(u){for(let c=0;c<u.length;c++){const m=u[c];if(m.type==="childList"){for(let h=0;h<m.addedNodes.length;h++){const p=m.addedNodes[h];p.nodeType===1&&(y(p,t,e,i),l&&l(p))}for(let h=0;h<m.removedNodes.length;h++){const p=m.removedNodes[h];if(p.nodeType===1){const _=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",C=Array.from(p.querySelectorAll(_));p.matches&&p.matches(_)&&C.push(p);for(let v=0;v<C.length;v++){const N=C[v];if(!document.contains(N)){const A=N[e];A&&typeof A.destroy=="function"&&A.destroy()}}}}}else m.type==="attributes"&&(n&&m.target[e]?n(m.target,m.attributeName):(y(m.target,t,e,i),l&&l(m.target)))}});let d=[];if(t.indexOf("[")!==-1){const u=/\[([\w-]+)/g;let c;for(;(c=u.exec(t))!==null;)d.push(c[1])}else d.push(t);a.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:d.concat(o)})},f),window[e]=r,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){r(document.body)}):r(document.body),r}const T={};function j(t,e){T[t]=e}function B(t){return T[t]||{ingress:e=>e,egress:e=>e}}const q={};function x(t,e){if(!t||typeof e!="object")return;const i=t.toLowerCase().split("-")[0];q[i]=e}function R(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return q[e]||null}x("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=j,window.lnCore.getDataMapper=B,window.lnCore.registerLocaleFallback=x,window.lnCore.getLocaleFallback=R,window.lnCore.fillTemplate=S,window.lnCore.fill=E,window.lnCore.lnFill=D,window.lnCore.renderList=I);function k(t){return(t||"").replace(/^#/,"")}function w(t){const e=t===void 0?location.hash:t,i={},f=k(e);if(!f)return i;const s=f.split("&");for(let o=0;o<s.length;o++){const n=s[o];if(!n)continue;const l=n.indexOf(":"),r=l>-1?n.slice(0,l):n,a=l>-1?n.slice(l+1):"";if(r)try{i[r]=decodeURIComponent(a)}catch{i[r]=a}}return i}function W(t){if(!t)return null;const e=w();return t in e?e[t]:null}function F(t,e){if(!t)return;const i=w();e==null?delete i[t]:i[t]=String(e);const s=Object.keys(i).map(function(o){const n=i[o];return n===""?o:o+":"+encodeURIComponent(n)}).join("&");k(location.hash)!==s&&(location.hash=s)}function U(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=w,window.lnCore.hashGet=W,window.lnCore.hashSet=F,window.lnCore.hashLinkClick=U),(function(){const t="data-ln-table-coordinator",e="lnTableCoordinator";if(window[e]!==void 0)return;document.addEventListener("keydown",function(s){if(s.key!=="/"||s.defaultPrevented||document.activeElement&&(document.activeElement.tagName==="INPUT"||document.activeElement.tagName==="TEXTAREA"))return;const o=document.querySelector("["+t+"] [data-ln-search]")||document.querySelector("[data-ln-search]");if(!o)return;const n=o.tagName==="INPUT"||o.tagName==="TEXTAREA"?o:o.querySelector('input[type="search"], input[type="text"], input');n&&(s.preventDefault(),n.focus())});function i(s){return this.dom=s,f(this),this}function f(s){const o=s.dom;s._handlers={search:function(n){const l=n.target;if(!l.hasAttribute||!l.hasAttribute("data-ln-table")||!l.lnTable||!l.hasAttribute("data-ln-table-source"))return;n.preventDefault();const r=n.detail&&n.detail.term!=null?n.detail.term:"",a=l.lnTable.name||l.id;g(l,"ln-table:set-search",{query:r,term:r,table:a})},filter:function(n){if(!n.detail)return;const l=n.target;if(!l.hasAttribute||!l.hasAttribute("data-ln-table")||!l.lnTable)return;const r=n.detail.key,a=n.detail.values||[],d=l.lnTable.name||l.id,u=l.querySelectorAll("th");for(let c=0;c<u.length;c++)if(u[c].getAttribute("data-ln-table-filter-col")===r){const m=u[c].querySelector("[data-ln-table-col-filter]");m&&m.classList.toggle("ln-filter-active",a.length>0);break}g(l,"ln-table:set-filter",{key:r,values:a,table:d})},clear:function(n){const l=n.target.closest("[data-ln-table-clear], [data-ln-table-clear-all]");if(!l)return;const r=l.closest("[data-ln-table]")||o.querySelector("[data-ln-table]");if(!r||!r.lnTable)return;const a=r.lnTable.name||r.id,d=r.querySelectorAll("th");for(let h=0;h<d.length;h++){const p=d[h].querySelector("[data-ln-table-col-filter]");p&&p.classList.remove("ln-filter-active")}const u=r.id,c=u&&o.querySelector('[data-ln-search="'+u+'"]')||o.querySelector("[data-ln-search]");if(c){const h=c.tagName==="INPUT"||c.tagName==="TEXTAREA"?c:c.querySelector("input");h&&(h.value="")}const m=u&&o.querySelectorAll('[data-ln-filter="'+u+'"]')||o.querySelectorAll("[data-ln-filter]");for(let h=0;h<m.length;h++){const p=m[h].querySelector("[data-ln-filter-reset]");p&&(p.checked=!0,p.dispatchEvent(new Event("change",{bubbles:!0})))}g(r,"ln-table:request-clear-filters",{table:a})}},o.addEventListener("ln-search:change",s._handlers.search),o.addEventListener("ln-filter:changed",s._handlers.filter),o.addEventListener("click",s._handlers.clear)}i.prototype.destroy=function(){this.dom[e]&&(this._handlers&&(this.dom.removeEventListener("ln-search:change",this._handlers.search),this.dom.removeEventListener("ln-filter:changed",this._handlers.filter),this.dom.removeEventListener("click",this._handlers.clear),this._handlers=null),delete this.dom[e])},M(t,e,i,"ln-table-coordinator")})()})();
@@ -0,0 +1,160 @@
1
+ import { registerComponent, dispatch } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-table-coordinator';
5
+ const DOM_ATTRIBUTE = 'lnTableCoordinator';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ // ─── Keyboard Navigation Search Focus ('/' shortcut) ──
10
+ // Deliberate, user-approved exception to host-scoping: this is a
11
+ // page-level keyboard affordance, not coordination between a host and
12
+ // its children, so it stays a single module-scope document listener
13
+ // exactly as it behaves today (unchanged resolution order). Registering
14
+ // it per-instance inside _bindEvents would fire it once per coordinator
15
+ // on the page — this is why it lives outside _component/_bindEvents.
16
+
17
+ document.addEventListener('keydown', function (e) {
18
+ if (e.key !== '/') return;
19
+ if (e.defaultPrevented) return;
20
+ if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) return;
21
+
22
+ // Resolve search input inside active coordinator wrapper or first visible search
23
+ const searchHost = document.querySelector('[' + DOM_SELECTOR + '] [data-ln-search]') || document.querySelector('[data-ln-search]');
24
+ if (!searchHost) return;
25
+
26
+ const input = (searchHost.tagName === 'INPUT' || searchHost.tagName === 'TEXTAREA')
27
+ ? searchHost
28
+ : searchHost.querySelector('input[type="search"], input[type="text"], input');
29
+
30
+ if (input) {
31
+ e.preventDefault();
32
+ input.focus();
33
+ }
34
+ });
35
+
36
+ // ─── Component Constructor ─────────────────────────────
37
+
38
+ function _component(dom) {
39
+ this.dom = dom;
40
+ _bindEvents(this);
41
+ return this;
42
+ }
43
+
44
+ // ─── Event Binding (host-scoped — own subtree only) ────
45
+
46
+ function _bindEvents(self) {
47
+ const dom = self.dom;
48
+
49
+ self._handlers = {
50
+ // ln-search dispatches ln-search:change directly on its resolved
51
+ // target — when that target IS the table, e.target already carries
52
+ // data-ln-table. No ID resolution needed here, only a guard.
53
+ search: function (e) {
54
+ const table = e.target;
55
+ if (!table.hasAttribute || !table.hasAttribute('data-ln-table')) return;
56
+ if (!table.lnTable) return;
57
+ // SSR tables self-bind ln-search:change directly (see ln-table.js) —
58
+ // the coordinator only owns the data-driven path. A table wrapped
59
+ // here that happens to be SSR is a markup mistake (rule: coordinator
60
+ // is data-driven-only); this guard prevents a double-fire rather
61
+ // than silently mishandling it.
62
+ if (!table.hasAttribute('data-ln-table-source')) return;
63
+
64
+ e.preventDefault();
65
+ const term = e.detail && e.detail.term != null ? e.detail.term : '';
66
+ const name = table.lnTable.name || table.id;
67
+
68
+ dispatch(table, 'ln-table:set-search', { query: term, term: term, table: name });
69
+ },
70
+
71
+ // ln-filter dispatches ln-filter:changed on both its own <ul> root
72
+ // and (if different) directly on the table via getElementById — same
73
+ // reasoning: no ID resolution needed, only a guard.
74
+ filter: function (e) {
75
+ if (!e.detail) return;
76
+ const table = e.target;
77
+ if (!table.hasAttribute || !table.hasAttribute('data-ln-table')) return;
78
+ if (!table.lnTable) return;
79
+
80
+ const key = e.detail.key;
81
+ const values = e.detail.values || [];
82
+ const name = table.lnTable.name || table.id;
83
+
84
+ // Header filter-button indicator — Layer 2 policy; ln-table itself
85
+ // never sets this class.
86
+ const ths = table.querySelectorAll('th');
87
+ for (let i = 0; i < ths.length; i++) {
88
+ if (ths[i].getAttribute('data-ln-table-filter-col') === key) {
89
+ const btn = ths[i].querySelector('[data-ln-table-col-filter]');
90
+ if (btn) btn.classList.toggle('ln-filter-active', values.length > 0);
91
+ break;
92
+ }
93
+ }
94
+
95
+ dispatch(table, 'ln-table:set-filter', { key: key, values: values, table: name });
96
+ },
97
+
98
+ // Clear-all has no ID binding of its own — resolve structurally,
99
+ // scoped to this host only (never document-wide).
100
+ clear: function (e) {
101
+ const clearBtn = e.target.closest('[data-ln-table-clear], [data-ln-table-clear-all]');
102
+ if (!clearBtn) return;
103
+
104
+ const table = clearBtn.closest('[data-ln-table]') || dom.querySelector('[data-ln-table]');
105
+ if (!table || !table.lnTable) return;
106
+
107
+ const name = table.lnTable.name || table.id;
108
+
109
+ const ths = table.querySelectorAll('th');
110
+ for (let i = 0; i < ths.length; i++) {
111
+ const filterBtn = ths[i].querySelector('[data-ln-table-col-filter]');
112
+ if (filterBtn) filterBtn.classList.remove('ln-filter-active');
113
+ }
114
+
115
+ const tableId = table.id;
116
+ const searchEl = (tableId && dom.querySelector('[data-ln-search="' + tableId + '"]'))
117
+ || dom.querySelector('[data-ln-search]');
118
+ if (searchEl) {
119
+ const input = (searchEl.tagName === 'INPUT' || searchEl.tagName === 'TEXTAREA')
120
+ ? searchEl
121
+ : searchEl.querySelector('input');
122
+ if (input) input.value = '';
123
+ }
124
+
125
+ const filters = (tableId && dom.querySelectorAll('[data-ln-filter="' + tableId + '"]'))
126
+ || dom.querySelectorAll('[data-ln-filter]');
127
+ for (let i = 0; i < filters.length; i++) {
128
+ const resetInput = filters[i].querySelector('[data-ln-filter-reset]');
129
+ if (resetInput) {
130
+ resetInput.checked = true;
131
+ resetInput.dispatchEvent(new Event('change', { bubbles: true }));
132
+ }
133
+ }
134
+
135
+ dispatch(table, 'ln-table:request-clear-filters', { table: name });
136
+ }
137
+ };
138
+
139
+ dom.addEventListener('ln-search:change', self._handlers.search);
140
+ dom.addEventListener('ln-filter:changed', self._handlers.filter);
141
+ dom.addEventListener('click', self._handlers.clear);
142
+ }
143
+
144
+ // ─── Destroy ────────────────────────────────────────────
145
+
146
+ _component.prototype.destroy = function () {
147
+ if (!this.dom[DOM_ATTRIBUTE]) return;
148
+
149
+ if (this._handlers) {
150
+ this.dom.removeEventListener('ln-search:change', this._handlers.search);
151
+ this.dom.removeEventListener('ln-filter:changed', this._handlers.filter);
152
+ this.dom.removeEventListener('click', this._handlers.clear);
153
+ this._handlers = null;
154
+ }
155
+
156
+ delete this.dom[DOM_ATTRIBUTE];
157
+ };
158
+
159
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-table-coordinator');
160
+ })();
@@ -1,137 +1,221 @@
1
- # ln-tabs
2
-
3
- > N-way exclusive panel selection on a single container, managed reactively via the DOM.
4
-
5
- ---
6
-
7
- ## 1. Philosophy & The Tabs Mindset
8
-
9
- In `ln-ashlar`, the core design principle is **orthogonality**. Rather than creating heavy components that mix state, visual presentation, and layout, `ln-tabs` separates them into isolated concerns:
10
-
11
- 1. **State & ARIA (JavaScript)**: The `ln-tabs` component (145 lines) only manages the active tab key in the DOM, maps namespace URL hashes, handles optional `localStorage` persistence, and synchronizes ARIA accessibility. It possesses zero visual styles.
12
- 2. **Visual Presentation (CSS)**: Visual layouts, borders, alignments, and active indicator designs are handled in Vanilla CSS. The library ships mixins like `@mixin tabs-nav`, `@mixin tabs-tab`, and `@mixin tabs-panel` to handle this elegantly.
13
- 3. **Decoupled Binding (HTML)**: Tab triggers and panels are paired purely by string keys (`data-ln-tab="key"` and `data-ln-panel="key"`), decoupled from their relative DOM positions.
14
-
15
- ### Why not built on `ln-toggle`?
16
- While similar on the surface, their contracts diverge. `ln-toggle` is a binary disclosure primitive (using `aria-expanded`). `ln-tabs` is an N-way exclusive tablist (using `aria-selected` and `aria-hidden`) that supports advanced, namespace-scoped URL deep-linking out of the box.
17
-
18
- ---
19
-
20
- ## 2. Minimal Blueprint
21
-
22
- Triggers and panels are bound via matching keys inside a wrapper. Inactive panels must carry `class="hidden"` to prevent a layout flash before initialization.
23
-
24
- ```html
25
- <section id="user-tabs" data-ln-tabs data-ln-tabs-default="info">
26
- <!-- Tab list (triggers) -->
27
- <nav>
28
- <button type="button" data-ln-tab="info">Information</button>
29
- <button type="button" data-ln-tab="settings">Settings</button>
30
- </nav>
31
-
32
- <!-- Tab panels -->
33
- <section data-ln-panel="info">
34
- <p>This is the info panel.</p>
35
- </section>
36
- <section data-ln-panel="settings" class="hidden">
37
- <p>This is the settings panel.</p>
38
- </section>
39
- </section>
40
- ```
41
-
42
- ### Key Anatomy Rules
43
- - **The Wrapper (`data-ln-tabs`)**: Creates the tabs root instance.
44
- - **The Trigger (`data-ln-tab="key"`)**: Marks the element as a click target. Must be a `<button>` (with `type="button"` inside forms) or an `<a>` anchor.
45
- - **The Panel (`data-ln-panel="key"`)**: Matches the trigger by key. Inactive panels must carry `class="hidden"`.
46
-
47
- ---
48
-
49
- ## 3. The Declarative API & State Contract
50
-
51
- There are no imperative JavaScript methods (like `activate()` or `open()`) on the component instance. **The HTML attribute is the sole contract.**
52
-
53
- Clicks, URL hash changes, localStorage restorations, and external scripts all change state by writing the active attribute on the wrapper:
54
-
55
- ```js
56
- const tabs = document.getElementById('user-tabs');
57
-
58
- // Canonical write — switches the active tab
59
- tabs.setAttribute('data-ln-tabs-active', 'settings');
60
-
61
- // Read-only state query
62
- tabs.getAttribute('data-ln-tabs-active'); // Returns currently active key
63
- ```
64
-
65
- ### Attributes
66
- - `data-ln-tabs`: Placed on the wrapper to create the instance.
67
- - `data-ln-tabs-active`: Currently active key (written by the component, watched by the observer).
68
- - `data-ln-tabs-default="key"`: Default key selected on load. Falls back to the first tab trigger if omitted.
69
- - `data-ln-tabs-focus="false"`: Opt out of auto-focusing the first focusable element inside the active panel. Default: enabled.
70
- - `data-ln-tabs-key="name"`: Hash namespace. Falls back to wrapper `id` if omitted.
71
- - `id="name"`: Enables hash sync. The wrapper `id` acts as the URL namespace key.
72
- - `data-ln-persist`: Saves the active tab key in `localStorage` (effective only when hash sync is OFF).
73
-
74
- ---
75
-
76
- ## 4. Transition Events
77
-
78
- All events bubble. The dispatch target is the wrapper element.
79
-
80
- | Event | Cancelable | `detail` | Dispatched When |
81
- |---|---|---|---|
82
- | **`ln-tabs:change`** | No | `{ key, tab, panel }` | After the active panel is swapped, ARIA synced, focus moved (if enabled), and localStorage updated. |
83
- | **`ln-tabs:destroyed`** | No | `{ target }` | Inside `destroy()`, after removing click and hashchange listeners. |
84
-
85
- ```js
86
- // Example: Listen for tab changes
87
- document.addEventListener('ln-tabs:change', (e) => {
88
- console.log(`Active tab in ${e.target.id} changed to: ${e.detail.key}`);
89
- });
90
- ```
91
-
92
- ---
93
-
94
- ## 5. Integration Patterns
95
-
96
- ### A. Hash-Deep-Linkable Tabs (URL-as-State)
97
- Add an `id` to the wrapper. Clicking tabs automatically writes to the URL hash (e.g. `#user-tabs:settings`). Sharing, bookmarking, or using back/forward buttons restores the active tab on load.
98
- ```html
99
- <section id="user-tabs" data-ln-tabs data-ln-tabs-default="info">...</section>
100
- ```
101
-
102
- ### B. Anchor Triggers (Deep Links)
103
- Use `<a>` triggers with matching `href` format and boolean `data-ln-tab` attributes. Right-click copy link and middle-click "open in new tab" work out of the box.
104
- ```html
105
- <a href="#user-tabs:info" data-ln-tab>Information</a>
106
- <a href="#user-tabs:settings" data-ln-tab>Settings</a>
107
- ```
108
-
109
- ### C. Multiple Independent Tabsets
110
- Multiple independent tabsets on the same page will coexist cleanly in the URL hash, namespaced by their respective wrapper `id`s (e.g., `#user-tabs:settings&project-tabs:members`).
111
-
112
- ### D. Persistent Tabs (Without URL Hash)
113
- Omit the wrapper `id` and add `data-ln-persist="key"` to remember the active tab in `localStorage` without changing the URL hash.
114
- ```html
115
- <section data-ln-tabs data-ln-persist="settings-tabs" data-ln-tabs-default="general">...</section>
116
- ```
117
-
118
- ---
119
-
120
- ## 6. Integration & Source Files
121
-
122
- - **Unified Bundle**: Loaded automatically with the main bundle:
123
- ```html
124
- <script src="dist/ln-ashlar.iife.js" defer></script>
125
- ```
126
- - **Standalone IIFE**: For lightweight pages, load the standalone, self-registering IIFE version:
127
- ```html
128
- <script src="js/ln-tabs/ln-tabs.js" defer></script>
129
- ```
130
- - **Active Source (ESM)**: Development source is located at [js/ln-tabs/src/ln-tabs.js](file:///c:/laragon/www/ln-ashlar/js/ln-tabs/src/ln-tabs.js).
131
-
132
- ---
133
-
134
- ## Related
135
- - **[`ln-toggle`](../ln-toggle/README.md)** — Binary disclosure state primitive.
136
- - **[`ln-accordion`](../ln-accordion/README.md)** — Single-open coordinator built on `ln-toggle`.
137
- - **Architecture deep-dive** — [`docs/js/tabs.md`](../../docs/js/tabs.md).
1
+ # ln-tabs
2
+
3
+ > N-way exclusive panel selection on a single container, managed reactively via the DOM.
4
+
5
+ ---
6
+
7
+ ## 1. Philosophy & The Tabs Mindset
8
+
9
+ In `ln-ashlar`, the core design principle is **orthogonality**. Rather than creating heavy components that mix state, visual presentation, and layout, `ln-tabs` separates them into isolated concerns:
10
+
11
+ 1. **State & ARIA (JavaScript)**: The `ln-tabs` component (145 lines) only manages the active tab key in the DOM, maps namespace URL hashes, handles optional `localStorage` persistence, and synchronizes ARIA accessibility. It possesses zero visual styles.
12
+ 2. **Visual Presentation (CSS)**: Visual layouts, borders, alignments, and active indicator designs are handled in Vanilla CSS. The library ships mixins like `@mixin tabs-nav`, `@mixin tabs-tab`, and `@mixin tabs-panel` to handle this elegantly.
13
+ 3. **Decoupled Binding (HTML)**: Tab triggers and panels are paired purely by string keys (`data-ln-tab="key"` and `data-ln-panel="key"`), decoupled from their relative DOM positions.
14
+
15
+ ### Why not built on `ln-toggle`?
16
+ While similar on the surface, their contracts diverge. `ln-toggle` is a binary disclosure primitive (using `aria-expanded`). `ln-tabs` is an N-way exclusive tablist (using `aria-selected` and `aria-hidden`) that supports advanced, namespace-scoped URL deep-linking out of the box.
17
+
18
+ ---
19
+
20
+ ## 2. Minimal Blueprint
21
+
22
+ Triggers and panels are bound via matching keys inside a wrapper. Inactive panels must carry `class="hidden"` to prevent a layout flash before initialization.
23
+
24
+ ```html
25
+ <section id="user-tabs" data-ln-tabs data-ln-tabs-default="info">
26
+ <!-- Tab list (triggers) -->
27
+ <nav>
28
+ <button type="button" data-ln-tab="info">Information</button>
29
+ <button type="button" data-ln-tab="settings">Settings</button>
30
+ </nav>
31
+
32
+ <!-- Tab panels -->
33
+ <section data-ln-panel="info">
34
+ <p>This is the info panel.</p>
35
+ </section>
36
+ <section data-ln-panel="settings" class="hidden">
37
+ <p>This is the settings panel.</p>
38
+ </section>
39
+ </section>
40
+ ```
41
+
42
+ ### Key Anatomy Rules
43
+ - **The Wrapper (`data-ln-tabs`)**: Creates the tabs root instance.
44
+ - **The Trigger (`data-ln-tab="key"`)**: Marks the element as a click target. Must be a `<button>` (with `type="button"` inside forms) or an `<a>` anchor.
45
+ - **The Panel (`data-ln-panel="key"`)**: Matches the trigger by key. Inactive panels must carry `class="hidden"`.
46
+
47
+ ---
48
+
49
+ ## 3. The Declarative API & State Contract
50
+
51
+ There are no imperative JavaScript methods (like `activate()` or `open()`) on the component instance. **The HTML attribute is the sole contract.**
52
+
53
+ Clicks, URL hash changes, localStorage restorations, and external scripts all change state by writing the active attribute on the wrapper:
54
+
55
+ ```js
56
+ const tabs = document.getElementById('user-tabs');
57
+
58
+ // Canonical write — switches the active tab
59
+ tabs.setAttribute('data-ln-tabs-active', 'settings');
60
+
61
+ // Read-only state query
62
+ tabs.getAttribute('data-ln-tabs-active'); // Returns currently active key
63
+ ```
64
+
65
+ ### Mode is set by the trigger type
66
+
67
+ The wrapper runs in one of two modes, chosen by **what the triggers are** — not by any wrapper attribute:
68
+
69
+ - **Anchor triggers** (`<a href="#…">`) → **URL hash sync**: shareable, bookmarkable, back/forward aware. Needs a namespace (`id` or `data-ln-tabs-key`) on the wrapper.
70
+ - **Button triggers** (`<button>`) → **`localStorage` persist**, opt-in via `data-ln-persist`. The URL is never touched.
71
+
72
+ Mixing both trigger types in one group falls back to persist mode and logs a console warning. An `id` on a button-driven group no longer forces hash mode — it is free to be a plain DOM id or the persist storage key.
73
+
74
+ ### Attributes
75
+ - `data-ln-tabs`: Placed on the wrapper to create the instance.
76
+ - `data-ln-tabs-active`: Currently active key (written by the component, watched by the observer).
77
+ - `data-ln-tabs-default="key"`: Default key selected on load. Falls back to the first tab trigger if omitted.
78
+ - `data-ln-tabs-focus="false"`: Opt out of auto-focusing the first focusable element inside the active panel. Default: enabled.
79
+ - `data-ln-tabs-key="name"`: Hash namespace for anchor-trigger groups. Falls back to wrapper `id` if omitted.
80
+ - `id="name"`: Doubles as the hash namespace for anchor-trigger groups when `data-ln-tabs-key` is absent. Does **not** select the mode — the trigger type does.
81
+ - `data-ln-persist`: Saves the active tab key in `localStorage` for button-trigger groups. Boolean form keys off the wrapper `id`; explicit form is `data-ln-persist="custom-key"`.
82
+
83
+ ---
84
+
85
+ ## 4. Transition Events
86
+
87
+ All events bubble. The dispatch target is the wrapper element.
88
+
89
+ | Event | Cancelable | `detail` | Dispatched When |
90
+ |---|---|---|---|
91
+ | **`ln-tabs:change`** | No | `{ key, tab, panel }` | After the active panel is swapped, ARIA synced, focus moved (if enabled), and localStorage updated. |
92
+ | **`ln-tabs:destroyed`** | No | `{ target }` | Inside `destroy()`, after removing click and hashchange listeners. |
93
+
94
+ ```js
95
+ // Example: Listen for tab changes
96
+ document.addEventListener('ln-tabs:change', (e) => {
97
+ console.log(`Active tab in ${e.target.id} changed to: ${e.detail.key}`);
98
+ });
99
+ ```
100
+
101
+ ---
102
+
103
+ ## 5. Integration Patterns
104
+
105
+ ### A. Hash-Deep-Linkable Tabs (URL-as-State)
106
+ Use **anchor triggers** and give the wrapper a namespace (`id` or `data-ln-tabs-key`). Clicking tabs writes to the URL hash (e.g. `#user-tabs:settings`); sharing, bookmarking, or using back/forward buttons restores the active tab on load.
107
+ ```html
108
+ <section id="user-tabs" data-ln-tabs data-ln-tabs-default="info">
109
+ <nav>
110
+ <a href="#user-tabs:info" data-ln-tab>Information</a>
111
+ <a href="#user-tabs:settings" data-ln-tab>Settings</a>
112
+ </nav>
113
+ ...
114
+ </section>
115
+ ```
116
+
117
+ ### B. Anchor Triggers (Deep Links)
118
+ Use `<a>` triggers with matching `href` format and boolean `data-ln-tab` attributes. Right-click copy link and middle-click "open in new tab" work out of the box.
119
+ ```html
120
+ <a href="#user-tabs:info" data-ln-tab>Information</a>
121
+ <a href="#user-tabs:settings" data-ln-tab>Settings</a>
122
+ ```
123
+
124
+ ### C. Multiple Independent Tabsets
125
+ Multiple independent tabsets on the same page will coexist cleanly in the URL hash, namespaced by their respective wrapper `id`s (e.g., `#user-tabs:settings&project-tabs:members`).
126
+
127
+ ### D. Persistent Tabs (Without URL Hash)
128
+ Use **button triggers** and add `data-ln-persist="key"` to remember the active tab in `localStorage` without touching the URL. An `id` on the wrapper is fine — it no longer forces hash mode.
129
+ ```html
130
+ <section data-ln-tabs data-ln-persist="settings-tabs" data-ln-tabs-default="general">
131
+ <nav>
132
+ <button type="button" data-ln-tab="general">General</button>
133
+ <button type="button" data-ln-tab="security">Security</button>
134
+ </nav>
135
+ ...
136
+ </section>
137
+ ```
138
+
139
+ ---
140
+
141
+ ## 6. Integration & Source Files
142
+
143
+ - **Unified Bundle**: Loaded automatically with the main bundle:
144
+ ```html
145
+ <script src="dist/ln-ashlar.iife.js" defer></script>
146
+ ```
147
+ - **Standalone IIFE**: For lightweight pages, load the standalone, self-registering IIFE version:
148
+ ```html
149
+ <script src="js/ln-tabs/ln-tabs.js" defer></script>
150
+ ```
151
+ - **Active Source (ESM)**: Development source is located at [js/ln-tabs/src/ln-tabs.js](file:///c:/laragon/www/ln-ashlar/js/ln-tabs/src/ln-tabs.js).
152
+
153
+ ---
154
+
155
+ ## Hash codec (ln-core shared)
156
+
157
+ ln-tabs uses the shared ln-core hash codec (`hashGet` / `hashSet` /
158
+ `hashParse` from `js/ln-core/hash.js`) instead of a private parser. The
159
+ grammar and behaviour are unchanged — anchor-trigger groups still produce
160
+ `#nsKey:activeKey` fragments, still respond to `hashchange`, and still
161
+ support back/forward navigation.
162
+
163
+ **Foreign-segment preservation.** Because `hashSet` is a
164
+ read-modify-write that updates only the tabs namespace, switching a tab
165
+ now preserves any hash segment written by another component. For example,
166
+ if a hash-bound modal is open (`#demo-edit:5`), switching a tab produces
167
+ `#demo-edit:5&demo-tab:members` — the modal segment is not cleared.
168
+
169
+ No markup or API change — this is an internal implementation improvement.
170
+
171
+ ---
172
+
173
+ ## 🔧 Internals
174
+
175
+ Source: `js/ln-tabs/ln-tabs.js`. Zero cross-component imports — only `ln-core/helpers.js` (`registerComponent`, `dispatch`) and `ln-core/persist.js`.
176
+
177
+ ### State
178
+
179
+ Each instance caches `tabs[]`/`panels[]` and derived `mapTabs`/`mapPanels` (keyed by lowercase-trimmed `data-ln-tab` value, or the resolved hash fragment for boolean anchors) once at init — re-querying happens only through `destroy()` + re-init, never on every activation. `nsKey`/`hashEnabled` are resolved *before* `mapTabs` is built, because anchor key derivation needs the namespace to pick the right hash fragment.
180
+
181
+ ### Init flow
182
+
183
+ 1. `registerComponent` scans for `[data-ln-tabs]`, with `extraAttributes: ['data-ln-tabs-active']` so the shared observer also watches writes to the active-key attribute (the real state channel), not just the marker attribute.
184
+ 2. The constructor caches tabs/panels, detects mode, builds the maps, resolves `defaultKey` and `autoFocus`, wires click handlers (guarded against double-attach).
185
+ 3. It branches on mode: hash-enabled groups attach `hashchange` and seed from the current URL immediately; persist groups optionally restore from `localStorage` *before* the first attribute write, so there's no flash of default-then-restored.
186
+ 4. The resulting `setAttribute('data-ln-tabs-active', …)` is what triggers `_applyActive` through the observer — the write, not a direct call, is the single activation path.
187
+
188
+ ### Activation order (`_applyActive`)
189
+
190
+ 1. An invalid key (typo, removed panel) silently resolves to `defaultKey` — permissive by design, the component is downstream of attribute writes.
191
+ 2. Tab buttons flip `data-active`/`aria-selected` first, then panels flip `.hidden`/`aria-hidden` — natural read order for assistive tech.
192
+ 3. Auto-focus is deferred one `setTimeout(0)` (the panel was just un-hidden; layout hasn't settled) with `{ preventScroll: true }`.
193
+ 4. Event dispatch happens after all DOM/ARIA writes; persistence save happens last, only when `data-ln-persist` is present and hash mode is off.
194
+
195
+ ### The hash round-trip
196
+
197
+ Clicking an already-active anchor tab does not fire `hashchange` (the browser dedupes identical hash writes), so the click handler special-cases `location.hash === '#' + newHash` and force-writes the attribute directly instead of relying on the event. The same edge applies at boot: `_hashHandler()` is invoked once manually to seed state from a hash already present in the URL.
198
+
199
+ ### Anchor key derivation
200
+
201
+ A non-empty `data-ln-tab` value wins if present; otherwise the `href` is split on `&`, matched against `nsKey`, and the substring after `:` is used; an anchor with no resolvable key is skipped with `console.warn`.
202
+
203
+ ### Destroy
204
+
205
+ Idempotent. Detaches every click handler and clears their double-attach guards, detaches `hashchange` only if hash-enabled, dispatches `ln-tabs:destroyed`, deletes `dom.lnTabs`. Does NOT reset visual state (`data-active`/`aria-selected` survive), clear `localStorage`, or remove `data-ln-tabs-active` — a future re-init resumes from whatever is left.
206
+
207
+ ### Failure modes
208
+
209
+ | Scenario | Behavior |
210
+ |---|---|
211
+ | Active key has no matching panel | Falls back to `defaultKey` silently |
212
+ | Mixed anchor + button triggers in one group | Falls back to persist mode, `console.warn` |
213
+ | `location.hash` references a foreign namespace | Ignored — `_hashHandler` reads only its own `nsKey` |
214
+ | `localStorage` write throws (quota, private mode) | Swallowed in `persistSet`'s `try/catch` |
215
+ | Tab button missing `type="button"` inside a form | Page navigates on click — markup bug, not defendable |
216
+
217
+ ---
218
+
219
+ ## Related
220
+ - **[`ln-toggle`](../ln-toggle/README.md)** — Binary disclosure state primitive.
221
+ - **[`ln-accordion`](../ln-accordion/README.md)** — Single-open coordinator built on `ln-toggle`.