@livenetworks/ashlar 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +189 -177
  2. package/js/COMPONENTS.md +55 -49
  3. package/js/index.js +22 -5
  4. package/js/ln-accordion/README.md +186 -137
  5. package/js/ln-accordion/ln-accordion.js +1 -1
  6. package/js/ln-ajax/README.md +73 -4
  7. package/js/ln-ajax/ln-ajax.js +1 -1
  8. package/js/ln-ajax/src/ln-ajax.js +13 -13
  9. package/js/ln-api-connector/README.md +261 -150
  10. package/js/ln-api-connector/ln-api-connector.js +1 -1
  11. package/js/ln-api-connector/src/ln-api-connector.js +368 -265
  12. package/js/ln-api-queue/README.md +219 -0
  13. package/js/ln-api-queue/ln-api-queue.js +1 -0
  14. package/js/ln-api-queue/src/ln-api-queue.js +320 -0
  15. package/js/ln-api-queue/src/queue-storage.js +455 -0
  16. package/js/ln-autoresize/README.md +47 -0
  17. package/js/ln-autoresize/ln-autoresize.js +1 -1
  18. package/js/ln-autosave/README.md +33 -1
  19. package/js/ln-autosave/ln-autosave.js +1 -1
  20. package/js/ln-autosave/src/ln-autosave.js +4 -4
  21. package/js/ln-circular-progress/README.md +27 -13
  22. package/js/ln-circular-progress/ln-circular-progress.js +1 -1
  23. package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
  24. package/js/ln-confirm/README.md +162 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +77 -41
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +189 -99
  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 -55
  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 -0
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +14 -5
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +68 -1
  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 +48 -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 +47 -0
  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 +4 -1
  198. package/scss/config/mixins/_ln-table.scss +51 -93
  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 +232 -38
  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 +39 -0
  209. package/scss/ln-ashlar.scss +8 -2
  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 -1103
  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 -168
  218. package/scss/components/_data-table.scss +0 -83
  219. package/scss/config/mixins/_data-table.scss +0 -346
@@ -0,0 +1,170 @@
1
+ # ln-sort
2
+
3
+ A zero-dependency, event-driven **Sort Control Primitive** that announces a sort intent on a
4
+ target element via a cancelable event, letting tables, lists, or custom integrations intercept
5
+ it or fall back to a built-in DOM-reorder default.
6
+
7
+ It follows the same contract shape as `ln-search`: dispatch-only-to-target, cancelable,
8
+ default-DOM-behaviour fallback. Unlike `ln-filter`, it never dual-dispatches to itself.
9
+
10
+ ---
11
+
12
+ ## 🧭 Philosophy & Architecture
13
+
14
+ 1. **No cycle logic in JS.** A click reads `data-ln-sort-dir` off the clicked button, writes it to
15
+ `data-ln-sort-state` on the root, and dispatches. The circular order
16
+ `none → asc → desc → none` is produced entirely by CSS — only one `<button data-ln-sort-dir>`
17
+ is visible at a time, selected by `[data-ln-sort-state]` on the root. See
18
+ `scss/config/mixins/_sort.scss`.
19
+ 2. **Field is optional.** Author `data-ln-sort-field="name"` when the target is data-driven
20
+ (server needs a name, not an index). Omit it inside a `<th>` to fall back to
21
+ `th.cellIndex` — SSR/DOM-only, resolved once at construction, never bridged to `field`.
22
+ The dispatched payload always carries both keys, exactly one non-null:
23
+ `{ field: string|null, column: number|null, direction, targetId }`.
24
+ 3. **Single sort, mutual exclusion via the target.** Every instance listens for
25
+ `ln-sort:change` on its OWN target (not on itself — events bubble up, listening on self would
26
+ never hear a sibling). When an incoming event's field/column doesn't match this instance's own,
27
+ it resets its own `data-ln-sort-state` to `"none"`. No registry, no sibling awareness.
28
+ 4. **Type is inferred once per sort, not per pair.** `ln-core.detectValueType` scans the current
29
+ value set; if every non-empty value is a finite number, comparison is numeric, else
30
+ `localeCompare` via `ln-core.getLocale`. Per-pair type inference breaks comparator
31
+ transitivity and produces wrong ordering — never do it.
32
+
33
+ ---
34
+
35
+ ## ✅ Canonical Markup
36
+
37
+ ```html
38
+ <th>
39
+ <span>Name</span>
40
+ <ul data-ln-sort="users-table" data-ln-sort-field="name" data-ln-sort-state="none">
41
+ <li><button type="button" data-ln-sort-dir="asc" aria-label="Sort ascending"><svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-arrows-sort"></use></svg></button></li>
42
+ <li><button type="button" data-ln-sort-dir="desc" aria-label="Sort descending"><svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-arrow-up"></use></svg></button></li>
43
+ <li><button type="button" data-ln-sort-dir="none" aria-label="Remove sort"><svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-arrow-down"></use></svg></button></li>
44
+ </ul>
45
+ </th>
46
+ ```
47
+
48
+ **Icon convention — READ BEFORE COPYING THIS MARKUP.** Each button carries the icon of the sort
49
+ STATE it is visible in, never the icon of the direction it sets:
50
+
51
+ | Button | Visible when `data-ln-sort-state` = | Icon it carries |
52
+ |---|---|---|
53
+ | `data-ln-sort-dir="asc"` | `none` | `#ln-icon-arrows-sort` |
54
+ | `data-ln-sort-dir="desc"` | `asc` | `#ln-icon-arrow-up` |
55
+ | `data-ln-sort-dir="none"` | `desc` | `#ln-icon-arrow-down` |
56
+
57
+ **This is not a typo.** The `dir="desc"` button contains an UP arrow. The visible button always
58
+ performs the NEXT action (clicking `dir="desc"` sorts descending), but its icon always reports the
59
+ CURRENT state (the column is currently ascending — hence the up arrow). If the icon instead
60
+ matched the button's own `data-ln-sort-dir`, the header would visually lie about its own sort
61
+ order the instant the column reaches `state="asc"`: the user would see a down-arrow next to a
62
+ column that is actually sorted up. Do not "fix" the icons to match `data-ln-sort-dir` when
63
+ hand-authoring markup — that reintroduces the exact bug this convention exists to prevent. `content`
64
+ cannot substitute for this — these icons are an SVG sprite via `<use href>`, not a font glyph a
65
+ `::before { content }` rule could swap (see `docs/architecture/reference.md` §Icons).
66
+
67
+ - **SSR table column** — omit `data-ln-sort-field`; the `<ul>` must be a descendant of the `<th>`
68
+ so the `th.cellIndex` fallback resolves. `data-ln-sort-field` and the SSR index path are never
69
+ bridged — setting a field on an SSR-table column leaves `column` null and `ln-table`'s SSR sort
70
+ has nothing to key off. Don't do both.
71
+ - **Data-driven table column** — set `data-ln-sort-field` to the same value as the `<th>`'s
72
+ `data-ln-table-col`.
73
+ - **Plain list** — target a generic `<ul>`/`<li>` container by `id`; set
74
+ `data-ln-sort-items="<selector>"` for deep targeting (mirrors `data-ln-search-items`).
75
+
76
+ ---
77
+
78
+ ## 🛠️ Declarative API Contract
79
+
80
+ ### HTML Attributes
81
+
82
+ | Attribute | Elements | Description |
83
+ | :--- | :--- | :--- |
84
+ | `data-ln-sort` | `<ul>` (root) | Component root. Value is the `id` of the target being sorted. |
85
+ | `data-ln-sort-field` | Same as root | Opt-in. The record field name (data-driven). Omit for the `th.cellIndex` fallback (SSR/DOM only). |
86
+ | `data-ln-sort-state` | Same as root | *State*. `"none" \| "asc" \| "desc"`. Drives which trigger button is visible via CSS. |
87
+ | `data-ln-sort-items` | Same as root | Opt-in. Deep CSS selector for default-DOM-behaviour reordering (mirrors `data-ln-search-items`). |
88
+ | `data-ln-sort-dir` | `<button>` (inside root) | `"asc" \| "desc" \| "none"`. Identifies which action this trigger performs. |
89
+ | `data-ln-persist` | Same as root | Opt-in. Persists `{ field, column, direction }` to `localStorage`. Give each `[data-ln-sort]` its own value (or `id`) — persistence is per-instance, not per-table. |
90
+
91
+ ### JavaScript API (`el.lnSort`)
92
+
93
+ | Member | Type | Description |
94
+ | :--- | :--- | :--- |
95
+ | `targetId` | `string` | The `id` of the target element. |
96
+ | `field` | `string \| null` | The authored field name, or `null`. |
97
+ | `column` | `number \| null` | The resolved `th.cellIndex` fallback, or `null`. |
98
+ | `destroy()` | `() => void` | Removes listeners and tears down the instance. |
99
+
100
+ ---
101
+
102
+ ## ⚡ DOM Events
103
+
104
+ ### `ln-sort:change`
105
+
106
+ Dispatched on the **target** element (never on the sort control itself) whenever a trigger button
107
+ is clicked, or on persisted-state restore.
108
+ - **Cancelable**: Yes. `e.preventDefault()` disables the default DOM-reorder and hands the intent
109
+ fully to the consumer (e.g. `ln-table`, `ln-list`).
110
+ - **Payload (`detail`)**: `{ field: string|null, column: number|null, direction: 'asc'|'desc'|'none', targetId: string }`.
111
+
112
+ ---
113
+
114
+ ## ⚠️ Common Pitfalls
115
+
116
+ - **Setting both `data-ln-sort-field` and expecting the index fallback to also work.** They are
117
+ never bridged. Pick one per instance based on the mode (SSR → index fallback; data-driven →
118
+ field).
119
+ - **Multi-column sort.** Not supported — single sort only. Multiple `[data-ln-sort]` instances
120
+ targeting the same target enforce mutual exclusion automatically (see Philosophy §3).
121
+ - **Expecting JS to own the click cycle.** It doesn't — the circular order is CSS-driven via
122
+ `[data-ln-sort-state]`. If the three trigger buttons aren't authored per the canonical markup,
123
+ the cycle breaks visually even though the JS click handling still works.
124
+ - **Persisting without a stable key.** `data-ln-persist` with no value falls back to `el.id`; a
125
+ bare `<ul data-ln-sort data-ln-persist>` with no `id` silently skips persistence
126
+ (see `ln-core.persistGet`/`persistSet` — `console.warn`s once).
127
+
128
+ ---
129
+
130
+ ## 🔧 Internals
131
+
132
+ Source: `js/ln-sort/ln-sort.js`. One instance per `[data-ln-sort]`, stored at `element.lnSort`.
133
+
134
+ ### The three-step click (no cycle logic)
135
+
136
+ `_onClick` reads `data-ln-sort-dir` off the clicked button and calls `_apply(direction)`, which:
137
+ writes `data-ln-sort-state` on the root, dispatches `ln-sort:change` on the target, and — only if
138
+ not prevented — runs `_defaultSort`. There is no `if (current === 'asc') then 'desc'` branch
139
+ anywhere in this file; the SCSS cycle in `scss/config/mixins/_sort.scss` decides which button is
140
+ even clickable next.
141
+
142
+ ### Mutual exclusion
143
+
144
+ Each instance adds its `ln-sort:change` listener to the resolved **target** element (captured once
145
+ at construction as `this._target`), not to itself. A self-originated dispatch also re-enters this
146
+ same listener (dispatch is synchronous and the listener is on the same target) — but the
147
+ field/column comparison naturally evaluates "same" for a self-dispatch, so it's a safe no-op, not
148
+ an explicit self-check.
149
+
150
+ ### Default DOM behaviour
151
+
152
+ Only runs when the consumer does not call `preventDefault()`. Reorders `target.children` (or
153
+ `data-ln-sort-items` matches) by moving nodes into a `DocumentFragment` and re-appending — a single
154
+ reflow, not N individual moves. Value extraction: `readValue` on the `[data-ln-field="<field>"]`
155
+ descendant if `field` is set, else `readValue(item)` directly. The `column` index fallback is
156
+ **never** consulted here — it exists solely to populate the event payload for SSR/DOM table
157
+ consumers; see README "Field is optional" above.
158
+
159
+ ### Persistence
160
+
161
+ `data-ln-persist` on the root, restored in the constructor via `persistGet('sort', dom)` and
162
+ applied via `_apply(saved.direction, true)` — the `skipPersist` flag prevents re-writing the value
163
+ just read (same pattern as `ln-filter`'s restore). Cleared by setting `direction: 'none'`
164
+ (`persistSet('sort', dom, null)`).
165
+
166
+ ### Destroy
167
+
168
+ Removes the click listener (root) and the `ln-sort:change` listener (target, if resolved). Does
169
+ not restore the DOM to its pre-sort order — that's a rendering side effect, not the component's
170
+ own teardown responsibility (same doctrine as `ln-search.destroy()`).
@@ -0,0 +1,8 @@
1
+ @use '../../scss/config/mixins' as *;
2
+
3
+ [data-ln-debug] {
4
+ // 1. Sort Target Check: an empty target id resolves to getElementById('') → null, so the control is silently inert
5
+ [data-ln-sort=""] {
6
+ @include dev-inline-error("data-ln-sort target is empty");
7
+ }
8
+ }
@@ -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 S={};function B(t,e){S[t]||(S[t]=document.querySelector('[data-ln-template="'+t+'"]'));const n=S[t];return n?n.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function j(t,e,n){const i=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:n||{}});return t.dispatchEvent(i),i}function L(t,e){if(!t||!e)return t;const n=t.querySelectorAll("[data-ln-field]");for(let l=0;l<n.length;l++){const o=n[l],c=o.getAttribute("data-ln-field");e[c]!=null&&(o.textContent=e[c])}const i=t.querySelectorAll("[data-ln-attr]");for(let l=0;l<i.length;l++){const o=i[l],c=o.getAttribute("data-ln-attr").split(",");for(let r=0;r<c.length;r++){const u=c[r].trim().split(":");if(u.length!==2)continue;const s=u[0].trim(),d=u[1].trim();e[d]!=null&&o.setAttribute(s,e[d])}}const f=t.querySelectorAll("[data-ln-show]");for(let l=0;l<f.length;l++){const o=f[l],c=o.getAttribute("data-ln-show");c in e&&o.classList.toggle("hidden",!e[c])}const a=t.querySelectorAll("[data-ln-class]");for(let l=0;l<a.length;l++){const o=a[l],c=o.getAttribute("data-ln-class").split(",");for(let r=0;r<c.length;r++){const u=c[r].trim().split(":");if(u.length!==2)continue;const s=u[0].trim(),d=u[1].trim();d in e&&o.classList.toggle(s,!!e[d])}}return t}function K(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const n=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let i=0;i<n.length;i++)n[i].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)L(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let n=0;n<e.length;n++)e[n].textContent=""}})));function E(t,e){if(!t||!e)return t;const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;n.nextNode();){const a=n.currentNode;a.textContent.indexOf("{{")!==-1&&(a.textContent=a.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(l,o){return e[o]!==void 0?e[o]:""}))}const i=function(a,l){return e[l]!==void 0?e[l]:""},f=Array.from(t.querySelectorAll("*"));t.nodeType===1&&f.push(t);for(let a=0;a<f.length;a++){const l=f[a],o=l.attributes;for(let c=0;c<o.length;c++){const r=o[c];r.value.indexOf("{{")!==-1&&l.setAttribute(r.name,r.value.replace(/\{\{\s*(\w+)\s*\}\}/g,i))}}return t}function W(t,e,n,i,f,a){const l={};for(let c=0;c<t.children.length;c++){const r=t.children[c],u=r.getAttribute("data-ln-key");u&&(l[u]=r)}const o=document.createDocumentFragment();for(let c=0;c<e.length;c++){const r=e[c],u=String(i(r));let s=l[u];if(s)f(s,r,c);else{const d=B(n,a);if(!d||(E(d,r),s=d.firstElementChild,!s))continue;s.setAttribute("data-ln-key",u),f(s,r,c)}o.appendChild(s)}t.textContent="",t.appendChild(o)}function T(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){T(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function _(t,e,n,i){if(t.nodeType!==1)return;const a=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",l=Array.from(t.querySelectorAll(a));t.matches&&t.matches(a)&&l.push(t);for(const o of l)o[n]||(o[n]=new i(o))}const x={mk:"mk-MK",de:"de-DE",fr:"fr-FR",es:"es-ES",it:"it-IT",nl:"nl-NL",pt:"pt-PT",ru:"ru-RU",bg:"bg-BG",hr:"hr-HR",sr:"sr-RS",sq:"sq-AL",el:"el-GR",en:"en-US"};function U(t){const e=t?t.closest("[lang]"):null,n=(e?e.getAttribute("lang")||e.lang:null)||(document.documentElement?document.documentElement.getAttribute("lang")||document.documentElement.lang:null)||navigator.language;if(!n)return"en-US";const i=n.trim().toLowerCase();return i.indexOf("-")===-1&&x[i]?x[i]:n}function k(t){return t.hasAttribute("data-ln-value")?t.getAttribute("data-ln-value"):t.textContent.trim()}function G(t){let e=!1;for(let n=0;n<t.length;n++){const i=t[n];if(!(i===""||i==null)&&(e=!0,!Number.isFinite(Number(i))))return"string"}return e?"number":"string"}function V(t,e,n,i){if(n==="number"){const l=parseFloat(t),o=parseFloat(e);return(isNaN(l)?0:l)-(isNaN(o)?0:o)}const f=t!=null?String(t):"",a=e!=null?String(e):"";return i?i.compare(f,a):f<a?-1:f>a?1:0}function P(t,e,n,i,f={}){const a=f.extraAttributes||[],l=f.onAttributeChange||null,o=f.onInit||null;function c(r){const u=r||document.body;_(u,t,e,n),o&&o(u)}return T(function(){const r=new MutationObserver(function(s){for(let d=0;d<s.length;d++){const h=s[d];if(h.type==="childList"){for(let p=0;p<h.addedNodes.length;p++){const g=h.addedNodes[p];g.nodeType===1&&(_(g,t,e,n),o&&o(g))}for(let p=0;p<h.removedNodes.length;p++){const g=h.removedNodes[p];if(g.nodeType===1){const A=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",b=Array.from(g.querySelectorAll(A));g.matches&&g.matches(A)&&b.push(g);for(let w=0;w<b.length;w++){const y=b[w];if(!document.contains(y)){const C=y[e];C&&typeof C.destroy=="function"&&C.destroy()}}}}}else h.type==="attributes"&&(l&&h.target[e]?l(h.target,h.attributeName):(_(h.target,t,e,n),o&&o(h.target)))}});let u=[];if(t.indexOf("[")!==-1){const s=/\[([\w-]+)/g;let d;for(;(d=s.exec(t))!==null;)u.push(d[1])}else u.push(t);r.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:u.concat(a)})},i),window[e]=c,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){c(document.body)}):c(document.body),c}const I={};function H(t,e){I[t]=e}function J(t){return I[t]||{ingress:e=>e,egress:e=>e}}const O={};function q(t,e){if(!t||typeof e!="object")return;const n=t.toLowerCase().split("-")[0];O[n]=e}function X(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return O[e]||null}q("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=H,window.lnCore.getDataMapper=J,window.lnCore.registerLocaleFallback=q,window.lnCore.getLocaleFallback=X,window.lnCore.fillTemplate=E,window.lnCore.fill=L,window.lnCore.lnFill=K,window.lnCore.renderList=W);const $="ln:";let m=null;function N(){if(m!==null)return m;try{if(typeof localStorage>"u")return m=!1,!1;const t="__ln_test__";localStorage.setItem(t,t),localStorage.removeItem(t),m=!0}catch{m=!1}return m}function z(){return location.pathname.replace(/\/+$/,"").toLowerCase()||"/"}function D(t,e){const n=e.getAttribute("data-ln-persist"),i=n!==null&&n!==""?n:e.id;return i?$+t+":"+z()+":"+i:(console.warn('[ln-persist] Element requires id or data-ln-persist="key"',e),null)}function Q(t,e){if(!N())return null;const n=D(t,e);if(!n)return null;try{const i=localStorage.getItem(n);return i!==null?JSON.parse(i):null}catch{return null}}function R(t,e,n){if(!N())return;const i=D(t,e);if(i)try{n==null?localStorage.removeItem(i):localStorage.setItem(i,JSON.stringify(n))}catch{}}function F(t){return(t||"").replace(/^#/,"")}function v(t){const e=t===void 0?location.hash:t,n={},i=F(e);if(!i)return n;const f=i.split("&");for(let a=0;a<f.length;a++){const l=f[a];if(!l)continue;const o=l.indexOf(":"),c=o>-1?l.slice(0,o):l,r=o>-1?l.slice(o+1):"";if(c)try{n[c]=decodeURIComponent(r)}catch{n[c]=r}}return n}function Y(t){if(!t)return null;const e=v();return t in e?e[t]:null}function Z(t,e){if(!t)return;const n=v();e==null?delete n[t]:n[t]=String(e);const f=Object.keys(n).map(function(a){const l=n[a];return l===""?a:a+":"+encodeURIComponent(l)}).join("&");F(location.hash)!==f&&(location.hash=f)}function tt(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=v,window.lnCore.hashGet=Y,window.lnCore.hashSet=Z,window.lnCore.hashLinkClick=tt),(function(){const t="data-ln-sort",e="lnSort",n="data-ln-sort-field",i="data-ln-sort-state",f="data-ln-sort-dir",a="data-ln-sort-items";if(window[e]!==void 0)return;function l(r,u){if(u){const s=r.querySelector('[data-ln-field="'+u+'"]');if(s)return k(s)}return k(r)}function o(r){this.dom=r,this.targetId=r.getAttribute(t),this.field=r.getAttribute(n)||null;const u=r.closest("th");this.column=!this.field&&u?u.cellIndex:null,this.itemsSelector=r.getAttribute(a)||null,this._initialOrder=null;const s=document.getElementById(this.targetId);s&&(this._initialOrder=this.itemsSelector?Array.from(s.querySelectorAll(this.itemsSelector)):Array.from(s.children)),this._target=s;const d=this;if(this._onClick=function(h){const p=h.target.closest("["+f+"]");p&&d._apply(p.getAttribute(f))},r.addEventListener("click",this._onClick),this._onTargetChange=function(h){(d.field?h.detail.field===d.field:h.detail.column===d.column)||(r.setAttribute(i,"none"),r.hasAttribute("data-ln-persist")&&R("sort",r,null))},s&&s.addEventListener("ln-sort:change",this._onTargetChange),r.hasAttribute("data-ln-persist")){const h=Q("sort",r);h&&h.direction&&queueMicrotask(function(){d._apply(h.direction,!0)})}return this}o.prototype._apply=function(r,u){this.dom.setAttribute(i,r);const s=this._target||document.getElementById(this.targetId);if(!s)return;const d={field:this.field,column:this.column,direction:r,targetId:this.targetId};!u&&this.dom.hasAttribute("data-ln-persist")&&R("sort",this.dom,r==="none"?null:d),!j(s,"ln-sort:change",d).defaultPrevented&&this._defaultSort(s,r)},o.prototype._defaultSort=function(r,u){const s=this.itemsSelector?Array.from(r.querySelectorAll(this.itemsSelector)):Array.from(r.children);if(!s.length)return;const d=s[0].parentNode;let h;if(u==="none")h=(this._initialOrder||s).filter(function(g){return g.parentNode===d});else{const g=this.field,M=s.map(function(y){return l(y,g)}),A=G(M),b=typeof Intl<"u"?new Intl.Collator(U(this.dom),{sensitivity:"base"}):null,w=u==="desc"?-1:1;h=s.slice().sort(function(y,C){return V(l(y,g),l(C,g),A,b)*w})}const p=document.createDocumentFragment();for(let g=0;g<h.length;g++)p.appendChild(h[g]);d.appendChild(p)},o.prototype.destroy=function(){this.dom[e]&&(this.dom.removeEventListener("click",this._onClick),this._target&&this._target.removeEventListener("ln-sort:change",this._onTargetChange),delete this.dom[e])};function c(r,u){const s=r[e];if(s)if(u===n){s.field=r.getAttribute(n)||null;const d=r.closest("th");s.column=!s.field&&d?d.cellIndex:null}else u===a&&(s.itemsSelector=r.getAttribute(a)||null)}P(t,e,o,"ln-sort",{extraAttributes:[n,a],onAttributeChange:c})})()})();
@@ -0,0 +1,178 @@
1
+ import { dispatchCancelable, readValue, getLocale, detectValueType, compareValues, registerComponent, persistGet, persistSet } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-sort';
5
+ const DOM_ATTRIBUTE = 'lnSort';
6
+ const FIELD_ATTR = 'data-ln-sort-field';
7
+ const STATE_ATTR = 'data-ln-sort-state';
8
+ const DIR_ATTR = 'data-ln-sort-dir';
9
+ const ITEMS_ATTR = 'data-ln-sort-items';
10
+
11
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
12
+
13
+ // Read the value a default-DOM sort compares by: field-matched descendant
14
+ // first, else the item itself. Never the column-index fallback — that
15
+ // path is SSR/DOM-payload only (see README "No index/field bridge").
16
+ function _readItemValue(item, field) {
17
+ if (field) {
18
+ const el = item.querySelector('[data-ln-field="' + field + '"]');
19
+ if (el) return readValue(el);
20
+ }
21
+ return readValue(item);
22
+ }
23
+
24
+ // ─── Component ─────────────────────────────────────────────
25
+
26
+ function _component(dom) {
27
+ this.dom = dom;
28
+ this.targetId = dom.getAttribute(DOM_SELECTOR);
29
+ this.field = dom.getAttribute(FIELD_ATTR) || null;
30
+
31
+ // Index fallback — resolved once, only when no field is authored.
32
+ // SSR/DOM-payload path only; never bridged back to `field`.
33
+ const th = dom.closest('th');
34
+ this.column = (!this.field && th) ? th.cellIndex : null;
35
+
36
+ this.itemsSelector = dom.getAttribute(ITEMS_ATTR) || null;
37
+
38
+ // Snapshot the pre-sort DOM order once — the only state this
39
+ // component holds, needed to restore direction "none".
40
+ this._initialOrder = null;
41
+ const target = document.getElementById(this.targetId);
42
+ if (target) {
43
+ this._initialOrder = this.itemsSelector
44
+ ? Array.from(target.querySelectorAll(this.itemsSelector))
45
+ : Array.from(target.children);
46
+ }
47
+ this._target = target;
48
+
49
+ const self = this;
50
+
51
+ this._onClick = function (e) {
52
+ const btn = e.target.closest('[' + DIR_ATTR + ']');
53
+ if (!btn) return;
54
+ self._apply(btn.getAttribute(DIR_ATTR));
55
+ };
56
+ dom.addEventListener('click', this._onClick);
57
+
58
+ // Mutual exclusion — listen on the TARGET (not self). A sibling
59
+ // ln-sort instance targeting the same target wins; this instance
60
+ // resets to "none". Self-originated events naturally compare equal
61
+ // (own field/column always matches itself) and no-op here.
62
+ this._onTargetChange = function (e) {
63
+ const same = self.field
64
+ ? e.detail.field === self.field
65
+ : e.detail.column === self.column;
66
+ if (same) return;
67
+ dom.setAttribute(STATE_ATTR, 'none');
68
+ // Single-sort invariant holds in storage too — a losing instance
69
+ // must drop its key, else two non-null keys race on next load.
70
+ if (dom.hasAttribute('data-ln-persist')) persistSet('sort', dom, null);
71
+ };
72
+ if (target) target.addEventListener('ln-sort:change', this._onTargetChange);
73
+
74
+ // ─── Restore persisted sort ─────────────────────────────
75
+ // Deferred — same hazard as ln-search's form-restore dispatch
76
+ // (js/ln-search/src/ln-search.js): _apply() dispatches synchronously,
77
+ // and a consumer (ln-table/ln-list) targeting the same id may not
78
+ // have bound its ln-sort:change listener yet if it constructs later
79
+ // in this same init sweep. queueMicrotask waits for that sweep to
80
+ // finish first.
81
+ if (dom.hasAttribute('data-ln-persist')) {
82
+ const saved = persistGet('sort', dom);
83
+ if (saved && saved.direction) {
84
+ queueMicrotask(function () {
85
+ self._apply(saved.direction, true);
86
+ });
87
+ }
88
+ }
89
+
90
+ return this;
91
+ }
92
+
93
+ _component.prototype._apply = function (direction, skipPersist) {
94
+ this.dom.setAttribute(STATE_ATTR, direction);
95
+
96
+ const target = this._target || document.getElementById(this.targetId);
97
+ if (!target) return;
98
+
99
+ const detail = {
100
+ field: this.field,
101
+ column: this.column,
102
+ direction: direction,
103
+ targetId: this.targetId
104
+ };
105
+
106
+ if (!skipPersist && this.dom.hasAttribute('data-ln-persist')) {
107
+ persistSet('sort', this.dom, direction === 'none' ? null : detail);
108
+ }
109
+
110
+ const evt = dispatchCancelable(target, 'ln-sort:change', detail);
111
+ if (evt.defaultPrevented) return;
112
+
113
+ this._defaultSort(target, direction);
114
+ };
115
+
116
+ // ─── Default DOM behaviour (no consumer claimed the event) ────
117
+
118
+ _component.prototype._defaultSort = function (target, direction) {
119
+ const items = this.itemsSelector
120
+ ? Array.from(target.querySelectorAll(this.itemsSelector))
121
+ : Array.from(target.children);
122
+ if (!items.length) return;
123
+ const parent = items[0].parentNode;
124
+
125
+ let ordered;
126
+ if (direction === 'none') {
127
+ ordered = (this._initialOrder || items).filter(function (el) {
128
+ return el.parentNode === parent;
129
+ });
130
+ } else {
131
+ const field = this.field;
132
+ const values = items.map(function (el) { return _readItemValue(el, field); });
133
+ const type = detectValueType(values);
134
+ const collator = typeof Intl !== 'undefined'
135
+ ? new Intl.Collator(getLocale(this.dom), { sensitivity: 'base' })
136
+ : null;
137
+ const multiplier = direction === 'desc' ? -1 : 1;
138
+
139
+ ordered = items.slice().sort(function (a, b) {
140
+ return compareValues(_readItemValue(a, field), _readItemValue(b, field), type, collator) * multiplier;
141
+ });
142
+ }
143
+
144
+ const frag = document.createDocumentFragment();
145
+ for (let i = 0; i < ordered.length; i++) frag.appendChild(ordered[i]);
146
+ parent.appendChild(frag);
147
+ };
148
+
149
+ // ─── Destroy ───────────────────────────────────────────────
150
+
151
+ _component.prototype.destroy = function () {
152
+ if (!this.dom[DOM_ATTRIBUTE]) return;
153
+ this.dom.removeEventListener('click', this._onClick);
154
+ if (this._target) this._target.removeEventListener('ln-sort:change', this._onTargetChange);
155
+ delete this.dom[DOM_ATTRIBUTE];
156
+ };
157
+
158
+ // ─── Attribute Sync ────────────────────────────────────────
159
+
160
+ function _syncAttribute(el, attrName) {
161
+ const instance = el[DOM_ATTRIBUTE];
162
+ if (!instance) return;
163
+ if (attrName === FIELD_ATTR) {
164
+ instance.field = el.getAttribute(FIELD_ATTR) || null;
165
+ const th = el.closest('th');
166
+ instance.column = (!instance.field && th) ? th.cellIndex : null;
167
+ } else if (attrName === ITEMS_ATTR) {
168
+ instance.itemsSelector = el.getAttribute(ITEMS_ATTR) || null;
169
+ }
170
+ }
171
+
172
+ // ─── Init ──────────────────────────────────────────────────
173
+
174
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-sort', {
175
+ extraAttributes: [FIELD_ATTR, ITEMS_ATTR],
176
+ onAttributeChange: _syncAttribute
177
+ });
178
+ })();
@@ -1,95 +1,133 @@
1
- # ln-sortable
2
-
3
- A zero-dependency, high-performance **Drag & Drop Reordering Primitive** driven by browser Pointer Events APIs, designed for seamless mouse and touch interactions.
4
-
5
- It focuses strictly on visual DOM restructuring, delegating server-side synchronization, database persistence, and list state saves completely to the parent coordinator via telemetry events.
6
-
7
- ---
8
-
9
- ## 🧭 Philosophy & Architecture
10
-
11
- 1. **Pointer Concurrency:** Built natively on Pointer Events APIs (`pointerdown`, `pointermove`, `pointerup`). It replaces heavy HTML5 Drag & Drop frameworks, offering high-performance dragging and layout shifts across desktop, mobile, and hybrid touch displays.
12
- 2. **Visual Isolation:** The primitive does not import or inject inline styles or layout rules. Instead, it exposes CSS state hooks (e.g. `.ln-sortable--dragging`) on active elements, leaving transitions and placeholders fully to the stylesheet.
13
- 3. **HTML Attribute as Single Source of Truth:** Component states are governed entirely by the `data-ln-sortable` attribute. Standard JS API calls (`enable()`, `disable()`) write directly to the attribute, which is observed via `MutationObserver` to coordinate internals.
14
-
15
- ---
16
-
17
- ## 📦 Minimal Blueprint
18
-
19
- ### Drag Anywhere List
20
- Mark the container list directly with `data-ln-sortable`. All direct children become instantly sortable.
21
- ```html
22
- <ul data-ln-sortable>
23
- <li>First Item</li>
24
- <li>Second Item</li>
25
- <li>Third Item</li>
26
- </ul>
27
- ```
28
-
29
- ### Handles-Only List (Premium Touch-Safe)
30
- For touch screens, limit drag triggers to a specific nested grab handle via `data-ln-sortable-handle`.
31
- ```html
32
- <ol data-ln-sortable>
33
- <li>
34
- <span data-ln-sortable-handle>
35
- <svg class="ln-icon" aria-hidden="true"><use href="#ln-menu"></use></svg>
36
- </span>
37
- <span>Dashboard</span>
38
- </li>
39
- <li>
40
- <span data-ln-sortable-handle>
41
- <svg class="ln-icon" aria-hidden="true"><use href="#ln-menu"></use></svg>
42
- </span>
43
- <span>Users</span>
44
- </li>
45
- </ol>
46
- ```
47
-
48
- ---
49
-
50
- ## 🛠️ Declarative API Contract
51
-
52
- ### HTML Attributes
53
-
54
- | Attribute | Elements | Description |
55
- | :--- | :--- | :--- |
56
- | `data-ln-sortable` | Container (`<ul>`, `<ol>`, etc.) | Component root. Can be empty (enabled) or `"disabled"`. |
57
- | `data-ln-sortable-handle` | Descendant of list item | Identifies drag trigger. When present, pointer clicks outside fail to drag. |
58
-
59
- ### CSS Class Hooks
60
-
61
- The component toggles these visual state classes at runtime:
62
-
63
- | Class | Element | Description |
64
- | :--- | :--- | :--- |
65
- | `.ln-sortable--active` | Container | Drag session in progress. Pointer events on text selection are locked. |
66
- | `.ln-sortable--dragging` | Dragged item | The item currently being dragged. Typically styled with reduced opacity. |
67
- | `.ln-sortable--drop-before` | Neighbor item | Target item top-half placeholder. Highlight top border. |
68
- | `.ln-sortable--drop-after` | Neighbor item | Target item bottom-half placeholder. Highlight bottom border. |
69
-
70
- ---
71
-
72
- ## ⚡ DOM Events
73
-
74
- All events bubble from the container element.
75
-
76
- ### `ln-sortable:before-drag`
77
- Fired when pointer down triggers drag.
78
- - **Cancelable**: Yes. Call `e.preventDefault()` to cancel the drag action (e.g. list is locked).
79
- - **Payload (`detail`)**: `{ item: HTMLElement, index: number }`
80
-
81
- ### `ln-sortable:reordered`
82
- Fired when an item drop successfully changes the DOM index order.
83
- - **Payload (`detail`)**: `{ item: HTMLElement, oldIndex: number, newIndex: number }`
84
-
85
- ---
86
-
87
- ## ⚠️ Common Pitfalls
88
-
89
- - **Missing `touch-action: none`:** Drag handles must carry `touch-action: none` in CSS. Failing to add this prevents mobile browsers from scrolling, causing the browser to capture pointer tracks and breaking touch drags.
90
- - **Auto-Sync Assumptions:** The primitive does not call servers automatically. You must wire an `ln-sortable:reordered` listener and dispatch your sync request (e.g. via `ln-http`):
91
- ```javascript
92
- document.addEventListener('ln-sortable:reordered', function(e) {
93
- saveNewListOrder(e.target.id, e.detail.oldIndex, e.detail.newIndex);
94
- });
95
- ```
1
+ # ln-sortable
2
+
3
+ A zero-dependency, high-performance **Drag & Drop Reordering Primitive** driven by browser Pointer Events APIs, designed for seamless mouse and touch interactions.
4
+
5
+ It focuses strictly on visual DOM restructuring, delegating server-side synchronization, database persistence, and list state saves completely to the parent coordinator via telemetry events.
6
+
7
+ ---
8
+
9
+ ## 🧭 Philosophy & Architecture
10
+
11
+ 1. **Pointer Concurrency:** Built natively on Pointer Events APIs (`pointerdown`, `pointermove`, `pointerup`). It replaces heavy HTML5 Drag & Drop frameworks, offering high-performance dragging and layout shifts across desktop, mobile, and hybrid touch displays.
12
+ 2. **Visual Isolation:** The primitive does not import or inject inline styles or layout rules. Instead, it exposes CSS state hooks (e.g. `.ln-sortable--dragging`) on active elements, leaving transitions and placeholders fully to the stylesheet.
13
+ 3. **HTML Attribute as Single Source of Truth:** Component states are governed entirely by the `data-ln-sortable` attribute. Standard JS API calls (`enable()`, `disable()`) write directly to the attribute, which is observed via `MutationObserver` to coordinate internals.
14
+
15
+ ---
16
+
17
+ ## 📦 Minimal Blueprint
18
+
19
+ ### Drag Anywhere List
20
+ Mark the container list directly with `data-ln-sortable`. All direct children become instantly sortable.
21
+ ```html
22
+ <ul data-ln-sortable>
23
+ <li>First Item</li>
24
+ <li>Second Item</li>
25
+ <li>Third Item</li>
26
+ </ul>
27
+ ```
28
+
29
+ ### Handles-Only List (Premium Touch-Safe)
30
+ For touch screens, limit drag triggers to a specific nested grab handle via `data-ln-sortable-handle`.
31
+ ```html
32
+ <ol data-ln-sortable>
33
+ <li>
34
+ <span data-ln-sortable-handle>
35
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-menu"></use></svg>
36
+ </span>
37
+ <span>Dashboard</span>
38
+ </li>
39
+ <li>
40
+ <span data-ln-sortable-handle>
41
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-menu"></use></svg>
42
+ </span>
43
+ <span>Users</span>
44
+ </li>
45
+ </ol>
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 🛠️ Declarative API Contract
51
+
52
+ ### HTML Attributes
53
+
54
+ | Attribute | Elements | Description |
55
+ | :--- | :--- | :--- |
56
+ | `data-ln-sortable` | Container (`<ul>`, `<ol>`, etc.) | Component root. Can be empty (enabled) or `"disabled"`. |
57
+ | `data-ln-sortable-handle` | Descendant of list item | Identifies drag trigger. When present, pointer clicks outside fail to drag. |
58
+
59
+ ### CSS Class Hooks
60
+
61
+ The component toggles these visual state classes at runtime:
62
+
63
+ | Class | Element | Description |
64
+ | :--- | :--- | :--- |
65
+ | `.ln-sortable--active` | Container | Drag session in progress. Pointer events on text selection are locked. |
66
+ | `.ln-sortable--dragging` | Dragged item | The item currently being dragged. Typically styled with reduced opacity. |
67
+ | `.ln-sortable--drop-before` | Neighbor item | Target item top-half placeholder. Highlight top border. |
68
+ | `.ln-sortable--drop-after` | Neighbor item | Target item bottom-half placeholder. Highlight bottom border. |
69
+
70
+ ---
71
+
72
+ ## ⚡ DOM Events
73
+
74
+ All events bubble from the container element.
75
+
76
+ ### `ln-sortable:before-drag`
77
+ Fired when pointer down triggers drag.
78
+ - **Cancelable**: Yes. Call `e.preventDefault()` to cancel the drag action (e.g. list is locked).
79
+ - **Payload (`detail`)**: `{ item: HTMLElement, index: number }`
80
+
81
+ ### `ln-sortable:drag-start`
82
+ Fired right after a drag is committed (after `before-drag` passes, capture is set, and drag classes are applied).
83
+ - **Payload (`detail`)**: `{ item: HTMLElement, index: number }`
84
+
85
+ ### `ln-sortable:reordered`
86
+ Fired when an item drop successfully changes the DOM index order.
87
+ - **Payload (`detail`)**: `{ item: HTMLElement, oldIndex: number, newIndex: number }`
88
+
89
+ ### `ln-sortable:enabled` / `ln-sortable:disabled`
90
+ Fired when `data-ln-sortable` toggles between enabled and `"disabled"` (via `enable()`/`disable()` or a direct attribute write, picked up by the shared `MutationObserver`).
91
+ - **Payload (`detail`)**: `{ target: HTMLElement }`
92
+
93
+ ### `ln-sortable:destroyed`
94
+ Fired inside `destroy()`, after the `pointerdown` listener is removed.
95
+ - **Payload (`detail`)**: `{ target: HTMLElement }`
96
+
97
+ ---
98
+
99
+ ## ⚠️ Common Pitfalls
100
+
101
+ - **Missing `touch-action: none`:** Drag handles must carry `touch-action: none` in CSS. Failing to add this prevents mobile browsers from scrolling, causing the browser to capture pointer tracks and breaking touch drags.
102
+ - **Auto-Sync Assumptions:** The primitive does not call servers automatically. You must wire an `ln-sortable:reordered` listener and dispatch your sync request (e.g. via `ln-http`):
103
+ ```javascript
104
+ document.addEventListener('ln-sortable:reordered', function(e) {
105
+ saveNewListOrder(e.target.id, e.detail.oldIndex, e.detail.newIndex);
106
+ });
107
+ ```
108
+
109
+ ---
110
+
111
+ ## 🔧 Internals
112
+
113
+ Source: `js/ln-sortable/ln-sortable.js`. Each `[data-ln-sortable]` container gets a `_component` instance at `element.lnSortable` (`dom`, `isEnabled`, `_dragging` — the child currently being dragged, or `null` — and the bound `pointerdown` handler).
114
+
115
+ ### Handle resolution
116
+
117
+ On `pointerdown`: if the target (or an ancestor) carries `data-ln-sortable-handle`, walk up from the handle to the direct container child — that's the drag item. If no handle is under the pointer but handles exist elsewhere in the container, abort (click was outside a handle). If no handles exist anywhere in the container, the clicked child itself is the handle. Either way the resolved item is validated as a direct child of the container before drag starts.
118
+
119
+ ### Pointer lifecycle
120
+
121
+ `pointerdown` → resolve item → dispatch cancelable `ln-sortable:before-drag` (abort on `preventDefault()`, before any class or capture is applied) → `setPointerCapture` on the handle → set `_dragging`, apply `.ln-sortable--active`/`--dragging`, `aria-grabbed` → attach `pointermove`/`pointerup`/`pointercancel` on the handle.
122
+
123
+ `pointermove` (repeated): clear all `--drop-before`/`--drop-after` classes, then for each sibling (skipping the dragged element) compare `clientY` to the sibling's vertical midpoint — upper half gets `--drop-before`, lower half gets `--drop-after`. Loop breaks after the first match, so only one sibling ever carries a drop indicator.
124
+
125
+ `pointerup`/`pointercancel`: find the sibling still carrying a drop class, strip all classes + `aria-grabbed`, and if a valid drop target exists, reorder with a single `insertBefore` (`container.insertBefore(item, dropTarget)` for `--drop-before`, or before `dropTarget.nextElementSibling` for `--drop-after`) then dispatch `ln-sortable:reordered`. This is pure DOM manipulation — no data model is touched; the event is the only signal for consumers to persist order.
126
+
127
+ ### Attribute-driven enable/disable
128
+
129
+ `data-ln-sortable` (absent value) = enabled, `data-ln-sortable="disabled"` = disabled. `enable()`/`disable()` write the attribute; the shared `MutationObserver` picks up the change, updates `isEnabled` inline, and dispatches `ln-sortable:enabled`/`ln-sortable:disabled`. No cancelable before-event for enable/disable — the state change is immediate.
130
+
131
+ ### MutationObserver
132
+
133
+ One global observer on `document.body`: `childList` (subtree) auto-initializes new `[data-ln-sortable]` containers; `attributes` on `data-ln-sortable` re-syncs `isEnabled` on existing instances.