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