@leavepulse/ui 0.44.0 → 0.45.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 (265) hide show
  1. package/README.md +49 -3
  2. package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-nGNnzXqQ.css} +21 -21
  3. package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
  4. package/dist/assets/{LpShortcutsDialog-ChUyU7oV.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
  5. package/dist/assets/{LpTable-DDQNdf86.css → LpTable-CPwWxTJS.css} +49 -9
  6. package/dist/chunks/{ContextMenuItems-CrfeNPWT.js → ContextMenuItems-BtKU7074.js} +2 -1
  7. package/dist/chunks/{LayoutCanvas-D-jPOAvg.js → LayoutCanvas-BkaGXTjx.js} +1 -1
  8. package/dist/chunks/{LayoutNode-D8Xy9N-o.js → LayoutNode-B3_9E1Xa.js} +9 -5
  9. package/dist/chunks/{LpActionBar-BbwCF6l5.js → LpActionBar-CnfFVIwC.js} +3 -3
  10. package/dist/chunks/{LpAddressInput-BrOqkZqg.js → LpAddressInput-BwX8uo4_.js} +7 -7
  11. package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
  12. package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-C53_RaGF.js} +4 -4
  13. package/dist/chunks/{LpAutocomplete-DxV3Y9h4.js → LpAutocomplete-C8IgSMsi.js} +2 -1
  14. package/dist/chunks/{LpAvatar-ClBbEYZN.js → LpAvatar-C1XStdOo.js} +3 -3
  15. package/dist/chunks/{LpBreadcrumbs-B8IJgFIc.js → LpBreadcrumbs-CQJd73Gm.js} +1 -1
  16. package/dist/chunks/{LpButton-kqJBHIIb.js → LpButton-CKSwC6Mu.js} +1 -1
  17. package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
  18. package/dist/chunks/{LpCard-CpRByR0q.js → LpCard-C2IvucmX.js} +1 -1
  19. package/dist/chunks/{LpCheckbox-DpqchzH4.js → LpCheckbox-Bkj13CbW.js} +1 -1
  20. package/dist/chunks/{LpCodeBlock-DeHJPq7p.js → LpCodeBlock-ca7ANFyW.js} +1 -1
  21. package/dist/chunks/{LpColorPicker-Bhf1qxhB.js → LpColorPicker-zz4VGkG4.js} +46 -20
  22. package/dist/chunks/{LpConfirmDialog-CIUOSnWo.js → LpConfirmDialog-DCw5jR1t.js} +3 -3
  23. package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-CbywMpMP.js} +1 -1
  24. package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
  25. package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-DuusgT69.js} +13 -9
  26. package/dist/chunks/LpDateRangePicker-DHeuznfC.js +208 -0
  27. package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
  28. package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-Dt4aB4Fr.js} +2 -2
  29. package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
  30. package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
  31. package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
  32. package/dist/chunks/{LpFileTree-CSI_5SK2.js → LpFileTree-CNurOvkV.js} +3 -3
  33. package/dist/chunks/{LpFileTreeNode-B4MiSQX2.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
  34. package/dist/chunks/{LpFormField-CIk5qosh.js → LpFormField-CtVYj6tO.js} +29 -15
  35. package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
  36. package/dist/chunks/{LpInput-C0e8y8_d.js → LpInput-D-YGQbEv.js} +39 -16
  37. package/dist/chunks/{LpLightbox-Mr0W_6HK.js → LpLightbox-DSDahF23.js} +1 -1
  38. package/dist/chunks/{LpLogViewer-YBOaFnc_.js → LpLogViewer-KypbgOyo.js} +252 -58
  39. package/dist/chunks/{LpMapPicker-NUzS9e37.js → LpMapPicker-Cc84LaM3.js} +1 -1
  40. package/dist/chunks/{LpMediaGallery-DfJmMW0I.js → LpMediaGallery-YKr-cQPN.js} +2 -2
  41. package/dist/chunks/{LpModal-DPwGKBPv.js → LpModal-DMMDQHAu.js} +3 -3
  42. package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-BVPDyIup.js} +4 -4
  43. package/dist/chunks/{LpNotificationBell-t5yn-siX.js → LpNotificationBell-CCwyJP5C.js} +26 -14
  44. package/dist/chunks/{LpNumberField-DWnQKktS.js → LpNumberField-B77O-z28.js} +1 -1
  45. package/dist/chunks/{LpPagination-DFngX7Xo.js → LpPagination-BcDlo3r_.js} +1 -1
  46. package/dist/chunks/{LpPasswordInput-CWVDuBJ5.js → LpPasswordInput-eKzQhbQP.js} +2 -2
  47. package/dist/chunks/{LpPhoneInput-CD-ZTeSd.js → LpPhoneInput-BCbrrhgE.js} +126 -23
  48. package/dist/chunks/{LpPie-BLco31Nx.js → LpPie-BEJ2SfI6.js} +9 -10
  49. package/dist/chunks/{LpPopover-p_e_shN3.js → LpPopover-DCKeqiVn.js} +1 -0
  50. package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
  51. package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
  52. package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
  53. package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
  54. package/dist/chunks/{LpSegmented-Dejd7cIx.js → LpSegmented-C09EEam2.js} +1 -1
  55. package/dist/chunks/{LpSelect-DrRD9I7L.js → LpSelect-DNIL6MB9.js} +20 -23
  56. package/dist/chunks/{LpSidebar-BgwUNWpJ.js → LpSidebar-CQNB0-yU.js} +3 -3
  57. package/dist/chunks/{LpSidebarNav-Bqxz8aWT.js → LpSidebarNav-kx-LdqNU.js} +3 -3
  58. package/dist/chunks/{LpStat-dXtyrdX9.js → LpStat-B-7udBRP.js} +1 -1
  59. package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
  60. package/dist/chunks/{LpSwitch-B1aLKulj.js → LpSwitch-OrcOJX9m.js} +1 -1
  61. package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
  62. package/dist/chunks/{LpTabs-_qMVaZJ6.js → LpTabs-29iW1XOx.js} +1 -1
  63. package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
  64. package/dist/chunks/{LpTextarea-yP8hGedR.js → LpTextarea-uuScWo6-.js} +1 -1
  65. package/dist/chunks/{LpThemeProvider-Wjm7Tuit.js → LpThemeProvider-Be4r2TQX.js} +1 -1
  66. package/dist/chunks/{LpThemeSwitcher-CKLc10KZ.js → LpThemeSwitcher-B4aVPbNC.js} +19 -22
  67. package/dist/chunks/{LpTimeField-C5HRgS5H.js → LpTimeField-Cqe7fXnh.js} +1 -1
  68. package/dist/chunks/{LpToaster--oT6lfhL.js → LpToaster-DpJvoCOh.js} +37 -3
  69. package/dist/chunks/{LpTooltip-CBdzeKNf.js → LpTooltip-CXVKcOXT.js} +3 -2
  70. package/dist/chunks/{LpTopologyCanvas-DocsLal3.js → LpTopologyCanvas-6ozNrQKh.js} +2 -1
  71. package/dist/chunks/{LpUptimeBar-DLtxOMRy.js → LpUptimeBar-ugcv25xj.js} +2 -2
  72. package/dist/chunks/icons-CkA0njzm.js +43 -0
  73. package/dist/chunks/{useTheme-C8JHUlPM.js → useTheme-DJm1JSsY.js} +47 -50
  74. package/dist/component-names.d.ts +1 -1
  75. package/dist/components/ContextMenuItems.vue.js +1 -1
  76. package/dist/components/LayoutCanvas.vue.js +1 -1
  77. package/dist/components/LayoutNode.vue.js +1 -1
  78. package/dist/components/LpActionBar.vue.js +1 -1
  79. package/dist/components/LpAddressInput.vue.js +1 -1
  80. package/dist/components/LpAlert.vue.js +1 -1
  81. package/dist/components/LpAppShell.vue.js +1 -1
  82. package/dist/components/LpAutocomplete.vue.js +1 -1
  83. package/dist/components/LpAvatar.vue.js +1 -1
  84. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  85. package/dist/components/LpButton.vue.d.ts +2 -2
  86. package/dist/components/LpButton.vue.js +1 -1
  87. package/dist/components/LpCalendar.vue.js +1 -1
  88. package/dist/components/LpCard.vue.js +1 -1
  89. package/dist/components/LpChart.vue.d.ts +16 -1
  90. package/dist/components/LpChart.vue.js +63 -5
  91. package/dist/components/LpCheckbox.vue.js +1 -1
  92. package/dist/components/LpCodeBlock.vue.js +1 -1
  93. package/dist/components/LpColorPicker.vue.d.ts +2 -2
  94. package/dist/components/LpColorPicker.vue.js +1 -1
  95. package/dist/components/LpCommandPalette.vue.d.ts +6 -0
  96. package/dist/components/LpCommandPalette.vue.js +61 -10
  97. package/dist/components/LpConfirmDialog.vue.js +1 -1
  98. package/dist/components/LpConfirmHost.vue.js +1 -1
  99. package/dist/components/LpContextMenu.vue.js +1 -1
  100. package/dist/components/LpDatePicker.vue.js +1 -1
  101. package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
  102. package/dist/components/LpDateRangePicker.vue.js +4 -0
  103. package/dist/components/LpDisclosure.vue.js +1 -1
  104. package/dist/components/LpDrawer.vue.js +1 -1
  105. package/dist/components/LpDropdownMenu.vue.js +1 -1
  106. package/dist/components/LpDropzone.vue.js +1 -1
  107. package/dist/components/LpEmptyState.vue.js +1 -1
  108. package/dist/components/LpFieldNotice.vue.d.ts +8 -0
  109. package/dist/components/LpFieldNotice.vue.js +4 -0
  110. package/dist/components/LpFileTree.vue.js +1 -1
  111. package/dist/components/LpFileTreeNode.vue.js +1 -1
  112. package/dist/components/LpFormField.vue.d.ts +4 -1
  113. package/dist/components/LpFormField.vue.js +1 -1
  114. package/dist/components/LpIcon.vue.d.ts +0 -1
  115. package/dist/components/LpIcon.vue.js +1 -1
  116. package/dist/components/LpImageEditor.vue.js +2 -2
  117. package/dist/components/LpInput.vue.d.ts +19 -0
  118. package/dist/components/LpInput.vue.js +1 -1
  119. package/dist/components/LpLightbox.vue.js +1 -1
  120. package/dist/components/LpLogViewer.vue.d.ts +52 -1
  121. package/dist/components/LpLogViewer.vue.js +1 -1
  122. package/dist/components/LpMapPicker.vue.js +1 -1
  123. package/dist/components/LpMediaGallery.vue.js +1 -1
  124. package/dist/components/LpModal.vue.js +1 -1
  125. package/dist/components/LpModalNav.vue.js +1 -1
  126. package/dist/components/LpNotificationBell.vue.js +1 -1
  127. package/dist/components/LpNumberField.vue.js +1 -1
  128. package/dist/components/LpPagination.vue.js +1 -1
  129. package/dist/components/LpPasswordInput.vue.js +1 -1
  130. package/dist/components/LpPhoneInput.vue.d.ts +13 -0
  131. package/dist/components/LpPhoneInput.vue.js +1 -1
  132. package/dist/components/LpPie.vue.js +1 -1
  133. package/dist/components/LpPopover.vue.js +1 -1
  134. package/dist/components/LpProgress.vue.d.ts +1 -1
  135. package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
  136. package/dist/components/LpRangeCalendar.vue.js +4 -0
  137. package/dist/components/LpRollingText.vue.d.ts +1 -1
  138. package/dist/components/LpRollingText.vue.js +1 -1
  139. package/dist/components/LpScrollArea.vue.d.ts +2 -2
  140. package/dist/components/LpScrollArea.vue.js +1 -1
  141. package/dist/components/LpSection.vue.js +1 -1
  142. package/dist/components/LpSegmented.vue.js +1 -1
  143. package/dist/components/LpSelect.vue.js +1 -1
  144. package/dist/components/LpShortcutsDialog.vue.d.ts +6 -0
  145. package/dist/components/LpShortcutsDialog.vue.js +56 -14
  146. package/dist/components/LpSidebar.vue.js +1 -1
  147. package/dist/components/LpSidebarNav.vue.js +1 -1
  148. package/dist/components/LpStat.vue.js +1 -1
  149. package/dist/components/LpStepper.vue.js +1 -1
  150. package/dist/components/LpSwitch.vue.js +1 -1
  151. package/dist/components/LpTable.vue.d.ts +84 -2
  152. package/dist/components/LpTable.vue.js +481 -107
  153. package/dist/components/LpTableFilter.vue.d.ts +31 -0
  154. package/dist/components/LpTableFilter.vue.js +4 -0
  155. package/dist/components/LpTabs.vue.js +1 -1
  156. package/dist/components/LpTagsInput.vue.d.ts +77 -0
  157. package/dist/components/LpTagsInput.vue.js +4 -0
  158. package/dist/components/LpTextarea.vue.js +1 -1
  159. package/dist/components/LpThemeProvider.vue.js +1 -1
  160. package/dist/components/LpThemeSwitcher.vue.d.ts +2 -2
  161. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  162. package/dist/components/LpTimeField.vue.js +1 -1
  163. package/dist/components/LpToaster.vue.js +1 -1
  164. package/dist/components/LpTooltip.vue.js +1 -1
  165. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  166. package/dist/components/LpUptimeBar.vue.js +1 -1
  167. package/dist/components/chart.js +3 -2
  168. package/dist/components/commandSearch.js +3 -0
  169. package/dist/components/dateRange.d.ts +43 -0
  170. package/dist/components/dateRange.js +76 -0
  171. package/dist/components/exportData.d.ts +58 -0
  172. package/dist/components/exportData.js +165 -0
  173. package/dist/components/logSearch.d.ts +18 -0
  174. package/dist/components/logSearch.js +36 -0
  175. package/dist/components/phone.d.ts +41 -0
  176. package/dist/components/phone.js +80 -0
  177. package/dist/components/tableFilter.d.ts +36 -0
  178. package/dist/components/tableFilter.js +84 -0
  179. package/dist/components/tags.d.ts +20 -0
  180. package/dist/components/tags.js +37 -0
  181. package/dist/composables/useField.d.ts +4 -0
  182. package/dist/composables/useFieldNotice.d.ts +17 -0
  183. package/dist/composables/useFieldNotice.js +28 -0
  184. package/dist/composables/useForm.d.ts +53 -0
  185. package/dist/composables/useForm.js +207 -0
  186. package/dist/composables/useHotkeys.d.ts +65 -5
  187. package/dist/composables/useHotkeys.js +170 -9
  188. package/dist/composables/useInputMask.d.ts +59 -0
  189. package/dist/composables/useInputMask.js +170 -0
  190. package/dist/composables/usePaintSelect.d.ts +22 -0
  191. package/dist/composables/usePaintSelect.js +169 -0
  192. package/dist/composables/useShift.js +7 -2
  193. package/dist/icons-all.d.ts +1 -0
  194. package/dist/icons-all.js +3 -0
  195. package/dist/icons.d.ts +14 -1
  196. package/dist/index.d.ts +29 -3
  197. package/dist/index.js +195 -138
  198. package/dist/kit-icons.d.ts +2 -0
  199. package/dist/theme/backdrop.d.ts +12 -0
  200. package/dist/theme/presets.d.ts +0 -2
  201. package/package.json +13 -4
  202. package/src/component-names.ts +4 -0
  203. package/src/components/ContextMenuItems.vue +1 -1
  204. package/src/components/LayoutNode.vue +11 -2
  205. package/src/components/LpActionBar.vue +1 -1
  206. package/src/components/LpAutocomplete.vue +1 -0
  207. package/src/components/LpAvatar.vue +2 -2
  208. package/src/components/LpButton.vue +1 -1
  209. package/src/components/LpChart.vue +80 -1
  210. package/src/components/LpColorPicker.vue +34 -1
  211. package/src/components/LpCommandPalette.vue +81 -7
  212. package/src/components/LpContextMenu.vue +1 -1
  213. package/src/components/LpDatePicker.vue +8 -3
  214. package/src/components/LpDateRangePicker.vue +224 -0
  215. package/src/components/LpDropdownMenu.vue +1 -0
  216. package/src/components/LpFieldNotice.vue +65 -0
  217. package/src/components/LpFormField.vue +33 -4
  218. package/src/components/LpIcon.vue +22 -4
  219. package/src/components/LpInput.vue +46 -6
  220. package/src/components/LpLogViewer.vue +225 -22
  221. package/src/components/LpModal.vue +1 -1
  222. package/src/components/LpNotificationBell.vue +15 -7
  223. package/src/components/LpPhoneInput.vue +179 -20
  224. package/src/components/LpPie.vue +8 -5
  225. package/src/components/LpPopover.vue +1 -0
  226. package/src/components/LpRangeCalendar.vue +365 -0
  227. package/src/components/LpRollingText.vue +3 -0
  228. package/src/components/LpScrollArea.vue +22 -2
  229. package/src/components/LpSelect.vue +0 -0
  230. package/src/components/LpShortcutsDialog.vue +77 -8
  231. package/src/components/LpStepper.vue +23 -10
  232. package/src/components/LpSwitch.vue +1 -1
  233. package/src/components/LpTable.vue +584 -65
  234. package/src/components/LpTableFilter.vue +175 -0
  235. package/src/components/LpTagsInput.vue +364 -0
  236. package/src/components/LpThemeSwitcher.vue +19 -23
  237. package/src/components/LpToaster.vue +49 -1
  238. package/src/components/LpTooltip.vue +9 -2
  239. package/src/components/LpTopologyCanvas.vue +7 -1
  240. package/src/components/chart.ts +5 -3
  241. package/src/components/commandSearch.ts +6 -0
  242. package/src/components/dateRange.ts +134 -0
  243. package/src/components/exportData.ts +228 -0
  244. package/src/components/logSearch.ts +46 -0
  245. package/src/components/phone.ts +140 -0
  246. package/src/components/tableFilter.ts +122 -0
  247. package/src/components/tags.ts +62 -0
  248. package/src/composables/useField.ts +4 -0
  249. package/src/composables/useFieldNotice.ts +59 -0
  250. package/src/composables/useForm.ts +314 -0
  251. package/src/composables/useHotkeys.ts +269 -13
  252. package/src/composables/useInputMask.ts +254 -0
  253. package/src/composables/usePaintSelect.ts +243 -0
  254. package/src/composables/useShift.ts +13 -2
  255. package/src/iconSubset.d.mts +10 -0
  256. package/src/iconSubset.mjs +51 -0
  257. package/src/icons-all.ts +10 -0
  258. package/src/icons.ts +59 -8
  259. package/src/index.ts +37 -3
  260. package/src/kit-icons.ts +5 -0
  261. package/src/nuxt.ts +30 -2
  262. package/src/runtime/theme.plugin.ts +2 -2
  263. package/src/theme/backdrop.ts +69 -0
  264. package/src/theme/presets.ts +6 -64
  265. package/src/tokens/tokens.css +46 -0
@@ -0,0 +1,122 @@
1
+ /*
2
+ * The rules behind LpTable's value filters and remembered state, kept pure so
3
+ * they can be tested. A column's filter is the set of HIDDEN values (see
4
+ * LpTable): a value nobody has seen before shows up rather than staying out.
5
+ */
6
+
7
+ /** Hide everything except `keep`: "show only this" in one step. */
8
+ export function onlyValues(values: readonly unknown[], keep: readonly unknown[]): Set<unknown> {
9
+ const kept = new Set(keep)
10
+ return new Set(values.filter((v) => !kept.has(v)))
11
+ }
12
+
13
+ /** Hide every value: the "None" step before ticking the few that matter. */
14
+ export function hideAllValues(values: readonly unknown[]): Set<unknown> {
15
+ return new Set(values)
16
+ }
17
+
18
+ /** Swap shown and hidden among the values the column holds. */
19
+ export function invertHidden(values: readonly unknown[], hidden: ReadonlySet<unknown>): Set<unknown> {
20
+ return new Set(values.filter((v) => !hidden.has(v)))
21
+ }
22
+
23
+ /** Show these values again, leaving the rest of the filter as it was. */
24
+ export function unhideValues(hidden: ReadonlySet<unknown>, values: readonly unknown[]): Set<unknown> {
25
+ const next = new Set(hidden)
26
+ for (const v of values) next.delete(v)
27
+ return next
28
+ }
29
+
30
+ /** Lower-cased and stripped of accents, so "cafe" finds "Café". */
31
+ export function foldText(text: string): string {
32
+ return text.normalize("NFKD").replace(/\p{M}/gu, "").toLocaleLowerCase()
33
+ }
34
+
35
+ /** Every word of the query appears somewhere in the label, in any order. */
36
+ export function matchesQuery(label: string, query: string): boolean {
37
+ const words = foldText(query).split(/\s+/).filter(Boolean)
38
+ if (!words.length) return true
39
+ const hay = foldText(label)
40
+ return words.every((w) => hay.includes(w))
41
+ }
42
+
43
+ /** How many rows hold each value of a column. */
44
+ export function countValues<Row extends Record<string, unknown>>(
45
+ rows: readonly Row[],
46
+ key: string,
47
+ ): Map<unknown, number> {
48
+ const counts = new Map<unknown, number>()
49
+ for (const row of rows) counts.set(row[key], (counts.get(row[key]) ?? 0) + 1)
50
+ return counts
51
+ }
52
+
53
+ /**
54
+ * A chip's text for one column's filter: the values still shown, the first
55
+ * `listed` by name and the rest as "+N", which says more than "3 of 12".
56
+ */
57
+ export function summarizeShown(shownLabels: readonly string[], listed = 2): string {
58
+ if (shownLabels.length <= listed) return shownLabels.join(", ")
59
+ return `${shownLabels.slice(0, listed).join(", ")} +${shownLabels.length - listed}`
60
+ }
61
+
62
+ // ── remembered state ───────────────────────────────────────────
63
+
64
+ export interface PersistedTableState {
65
+ sort?: { key: string; dir: "asc" | "desc" } | null
66
+ hidden?: Record<string, unknown[]>
67
+ columns?: string[]
68
+ }
69
+
70
+ type Plain = string | number | boolean | null
71
+
72
+ function isPlain(value: unknown): value is Plain {
73
+ return value === null || ["string", "number", "boolean"].includes(typeof value)
74
+ }
75
+
76
+ /**
77
+ * The state worth keeping across visits, as JSON. Only plain values survive:
78
+ * a Date or an object hidden in a filter cannot come back as the same value
79
+ * (the filter compares by identity), so it is left out rather than restored
80
+ * as something that matches nothing.
81
+ */
82
+ export function serializeTableState(state: PersistedTableState): string {
83
+ const hidden: Record<string, Plain[]> = {}
84
+ for (const [key, values] of Object.entries(state.hidden ?? {})) {
85
+ const plain = values.filter(isPlain)
86
+ if (plain.length) hidden[key] = plain
87
+ }
88
+ return JSON.stringify({ sort: state.sort ?? null, hidden, columns: state.columns ?? [] })
89
+ }
90
+
91
+ /** Read it back, dropping anything about columns the table no longer has. */
92
+ export function parseTableState(raw: string | null, columnKeys: readonly string[]): PersistedTableState | null {
93
+ if (!raw) return null
94
+ let data: unknown
95
+ try {
96
+ data = JSON.parse(raw)
97
+ } catch {
98
+ return null
99
+ }
100
+ if (!data || typeof data !== "object") return null
101
+ const known = new Set(columnKeys)
102
+ const record = data as Record<string, unknown>
103
+ const out: PersistedTableState = {}
104
+
105
+ const sort = record.sort as { key?: unknown; dir?: unknown } | null | undefined
106
+ if (sort === null) out.sort = null
107
+ else if (sort && typeof sort.key === "string" && known.has(sort.key) && (sort.dir === "asc" || sort.dir === "desc")) {
108
+ out.sort = { key: sort.key, dir: sort.dir }
109
+ }
110
+
111
+ if (record.hidden && typeof record.hidden === "object") {
112
+ out.hidden = {}
113
+ for (const [key, values] of Object.entries(record.hidden as Record<string, unknown>)) {
114
+ if (known.has(key) && Array.isArray(values)) out.hidden[key] = values.filter(isPlain)
115
+ }
116
+ }
117
+
118
+ if (Array.isArray(record.columns)) {
119
+ out.columns = record.columns.filter((c): c is string => typeof c === "string" && known.has(c))
120
+ }
121
+ return out
122
+ }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * The add pipeline behind LpTagsInput, kept pure so its rules are testable:
3
+ * split what was typed or pasted, normalise each part, then accept or reject
4
+ * it against the limit, the duplicate policy and the consumer's validator.
5
+ */
6
+
7
+ export type TagDuplicates = "reject" | "ignore" | "allow"
8
+
9
+ /** An error message for an invalid tag; anything else accepts it. */
10
+ export type TagValidator = (tag: string, tags: readonly string[]) => string | null | undefined | void
11
+
12
+ export type TagRejection = { value: string; reason: "max" | "duplicate" | "invalid"; message?: string }
13
+
14
+ export interface AddTagsOptions {
15
+ max?: number
16
+ duplicates?: TagDuplicates
17
+ validate?: TagValidator
18
+ normalize?: (tag: string) => string
19
+ }
20
+
21
+ export function splitTags(text: string, delimiter: string | RegExp | readonly string[]): string[] {
22
+ const parts =
23
+ delimiter instanceof RegExp
24
+ ? text.split(new RegExp(delimiter.source, delimiter.flags.includes("g") ? delimiter.flags : `${delimiter.flags}g`))
25
+ : (typeof delimiter === "string" ? [delimiter] : delimiter).reduce<string[]>(
26
+ (acc, d) => (d ? acc.flatMap((p) => p.split(d)) : acc),
27
+ [text],
28
+ )
29
+ return parts.map((p) => p.trim()).filter(Boolean)
30
+ }
31
+
32
+ export function addTags(
33
+ current: readonly string[],
34
+ candidates: readonly string[],
35
+ options: AddTagsOptions = {},
36
+ ): { tags: string[]; rejected: TagRejection[]; ignored: string[] } {
37
+ const tags = [...current]
38
+ const rejected: TagRejection[] = []
39
+ /** Repeats dropped under the "ignore" policy, as stored, so the UI can point at the original. */
40
+ const ignored: string[] = []
41
+ const duplicates = options.duplicates ?? "reject"
42
+ for (const candidate of candidates) {
43
+ const value = options.normalize ? options.normalize(candidate) : candidate
44
+ if (!value) continue
45
+ if (tags.includes(value) && duplicates !== "allow") {
46
+ if (duplicates === "reject") rejected.push({ value: candidate, reason: "duplicate" })
47
+ else ignored.push(value)
48
+ continue
49
+ }
50
+ if (options.max && tags.length >= options.max) {
51
+ rejected.push({ value: candidate, reason: "max" })
52
+ continue
53
+ }
54
+ const message = options.validate?.(value, tags)
55
+ if (typeof message === "string" && message) {
56
+ rejected.push({ value: candidate, reason: "invalid", message })
57
+ continue
58
+ }
59
+ tags.push(value)
60
+ }
61
+ return { tags, rejected, ignored }
62
+ }
@@ -1,4 +1,5 @@
1
1
  import { computed, inject, provide, useId, type ComputedRef, type InjectionKey } from "vue"
2
+ import type { FieldNoticeOptions } from "./useFieldNotice"
2
3
 
3
4
  /*
4
5
  * What a form field tells the control inside it: the id its label points at,
@@ -14,6 +15,9 @@ export interface FieldContext {
14
15
  invalid: boolean
15
16
  required: boolean
16
17
  disabled: boolean
18
+ /** Float a short note under the control without moving the layout (see useFieldNotice). */
19
+ notify?: (text: string, options?: FieldNoticeOptions) => void
20
+ clearNotice?: () => void
17
21
  }
18
22
 
19
23
  export const LP_FIELD: InjectionKey<ComputedRef<FieldContext>> = Symbol.for("lp-field")
@@ -0,0 +1,59 @@
1
+ import { getCurrentScope, onScopeDispose, ref, type Ref } from "vue"
2
+
3
+ /*
4
+ * A short note about what just happened to a field ("already added", "up to 5
5
+ * items") that floats under it instead of taking a line in the layout. A
6
+ * validation message belongs in the field's message line and may push the
7
+ * form down; a passing remark about one keystroke should not, or every refused
8
+ * paste would jolt the fields below.
9
+ *
10
+ * Render it with LpFieldNotice inside a `relative` box around the control.
11
+ * LpFormField does that for its control and hands `notify` down the field
12
+ * context, so a control inside a field (or the app) can raise one without
13
+ * owning any markup.
14
+ */
15
+ export type FieldNoticeTone = "info" | "danger"
16
+
17
+ export interface FieldNoticeOptions {
18
+ tone?: FieldNoticeTone
19
+ /** How long it stays, in ms. 0 keeps it until `clear()`. */
20
+ duration?: number
21
+ }
22
+
23
+ export interface FieldNotice {
24
+ message: Ref<string>
25
+ tone: Ref<FieldNoticeTone>
26
+ /** Bumped on every `show`, so a repeat of the same note can nudge rather than sit still. */
27
+ seq: Ref<number>
28
+ show: (text: string, options?: FieldNoticeOptions) => void
29
+ clear: () => void
30
+ }
31
+
32
+ export const FIELD_NOTICE_MS = 2600
33
+
34
+ export function useFieldNotice(defaultDuration = FIELD_NOTICE_MS): FieldNotice {
35
+ const message = ref("")
36
+ const tone = ref<FieldNoticeTone>("info")
37
+ const seq = ref(0)
38
+ let timer: ReturnType<typeof setTimeout> | undefined
39
+
40
+ function clear() {
41
+ if (timer) clearTimeout(timer)
42
+ timer = undefined
43
+ message.value = ""
44
+ }
45
+
46
+ function show(text: string, options: FieldNoticeOptions = {}) {
47
+ if (timer) clearTimeout(timer)
48
+ timer = undefined
49
+ message.value = text
50
+ tone.value = options.tone ?? "info"
51
+ seq.value++
52
+ const duration = options.duration ?? defaultDuration
53
+ if (duration > 0 && text) timer = setTimeout(clear, duration)
54
+ }
55
+
56
+ if (getCurrentScope()) onScopeDispose(clear)
57
+
58
+ return { message, tone, seq, show, clear }
59
+ }
@@ -0,0 +1,314 @@
1
+ import {
2
+ computed,
3
+ reactive,
4
+ ref,
5
+ toRaw,
6
+ toValue,
7
+ watch,
8
+ type ComputedRef,
9
+ type MaybeRefOrGetter,
10
+ type Ref,
11
+ type UnwrapRef,
12
+ } from "vue"
13
+
14
+ /*
15
+ * Form state that every form ends up writing by hand: the values, which
16
+ * fields were touched, whether anything differs from what was loaded, the
17
+ * errors, and a submit that cannot run twice.
18
+ *
19
+ * Errors are computed for every field but shown only once a field was touched
20
+ * (blurred, or edited after a blur) or a submit was attempted, so nobody is
21
+ * told off for a field they have not reached yet.
22
+ *
23
+ * `dirty` is the same signal useFormDraft asks for, so the two pair directly:
24
+ *
25
+ * ```ts
26
+ * const form = useForm({ initial: () => record.value, rules: { email: required } })
27
+ * const draft = useFormDraft({ key: () => `draft:${id}`, value: () => form.values, dirty: form.dirty })
28
+ * function restore() { form.setValues(draft.read() ?? {}); draft.discard() }
29
+ * const save = form.handleSubmit(async (values) => { await api.save(values); draft.discard() })
30
+ * ```
31
+ *
32
+ * A reactive `initial` (a record that loads after mount) is adopted while the
33
+ * form is untouched, and becomes the new baseline for `dirty`; once the user
34
+ * has changed something, a reload of the record no longer overwrites it.
35
+ */
36
+
37
+ export type FormErrorMessage = string | null | undefined | void | false
38
+ export type FieldRule<V, T> = (value: V, values: T) => FormErrorMessage | Promise<FormErrorMessage>
39
+ export type FormRules<T> = { [K in keyof T]?: FieldRule<T[K], T> | FieldRule<T[K], T>[] }
40
+ export type FormErrors<T> = Partial<Record<keyof T | "form", string>>
41
+
42
+ export interface UseFormOptions<T extends object> {
43
+ initial: MaybeRefOrGetter<T>
44
+ rules?: FormRules<T>
45
+ /** Cross-field checks; return messages by field, or under `form` for the whole form. */
46
+ validate?: (values: T) => FormErrors<T> | void | Promise<FormErrors<T> | void>
47
+ }
48
+
49
+ export interface FieldBinding<V> {
50
+ modelValue: V
51
+ "onUpdate:modelValue": (value: V) => void
52
+ onBlur: () => void
53
+ error: string | undefined
54
+ invalid: boolean
55
+ }
56
+
57
+ export interface UseForm<T extends object> {
58
+ values: UnwrapRef<T>
59
+ /** Errors as currently computed, shown or not. */
60
+ errors: ComputedRef<FormErrors<T>>
61
+ /** Errors the user should see now. */
62
+ visibleErrors: ComputedRef<FormErrors<T>>
63
+ touched: Partial<Record<keyof T, boolean>>
64
+ dirty: ComputedRef<boolean>
65
+ valid: ComputedRef<boolean>
66
+ validating: Ref<boolean>
67
+ submitting: Ref<boolean>
68
+ submitCount: Ref<number>
69
+ isDirty: (name: keyof T) => boolean
70
+ /** Props for a kit control: `<LpInput v-bind="form.field('email')" />`. */
71
+ field: <K extends keyof T>(name: K) => FieldBinding<T[K]>
72
+ set: <K extends keyof T>(name: K, value: T[K]) => void
73
+ setValues: (values: Partial<T>) => void
74
+ touch: (name: keyof T) => void
75
+ /** Run the checks for one field, or all of them; resolves to whether it passed. */
76
+ validate: (name?: keyof T) => Promise<boolean>
77
+ /** Errors from elsewhere, a server reply most often. Cleared field by field on edit. */
78
+ setErrors: (errors: FormErrors<T>) => void
79
+ /** Back to `values` (or the current initial), which also become the new baseline. */
80
+ reset: (values?: T) => void
81
+ /**
82
+ * Wrap the submit handler: touches everything, validates, and calls `fn`
83
+ * only when valid and not already submitting. A successful `fn` makes the
84
+ * submitted values the new baseline.
85
+ */
86
+ handleSubmit: (fn: (values: T) => unknown) => (event?: Event) => Promise<void>
87
+ }
88
+
89
+ function clone<T>(value: T): T {
90
+ try {
91
+ return structuredClone(toRaw(value))
92
+ } catch {
93
+ return JSON.parse(JSON.stringify(value)) as T
94
+ }
95
+ }
96
+
97
+ export function deepEqual(a: unknown, b: unknown): boolean {
98
+ if (Object.is(a, b)) return true
99
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
100
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false
101
+ if (Array.isArray(a) !== Array.isArray(b)) return false
102
+ const ka = Object.keys(a)
103
+ const kb = Object.keys(b)
104
+ if (ka.length !== kb.length) return false
105
+ return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
106
+ }
107
+
108
+ function message(result: FormErrorMessage): string | undefined {
109
+ return typeof result === "string" && result ? result : undefined
110
+ }
111
+
112
+ export function useForm<T extends object>(options: UseFormOptions<T>): UseForm<T> {
113
+ let baseline = clone(toValue(options.initial))
114
+ const values = reactive(clone(baseline)) as UnwrapRef<T>
115
+ const plain = () => values as unknown as T
116
+ const errors = reactive<FormErrors<T>>({}) as FormErrors<T>
117
+ const external = reactive<FormErrors<T>>({}) as FormErrors<T>
118
+ const touched = reactive<Partial<Record<keyof T, boolean>>>({}) as Partial<Record<keyof T, boolean>>
119
+ const validating = ref(false)
120
+ const submitting = ref(false)
121
+ const submitCount = ref(0)
122
+ const runs = new Map<keyof T | "form", number>()
123
+ const baseVersion = ref(0)
124
+
125
+ const keys = (): (keyof T)[] => Object.keys(plain()) as (keyof T)[]
126
+
127
+ const dirty = computed(() => {
128
+ void baseVersion.value
129
+ return !deepEqual(plain(), baseline)
130
+ })
131
+
132
+ function isDirty(name: keyof T): boolean {
133
+ void baseVersion.value
134
+ return !deepEqual(plain()[name], baseline[name])
135
+ }
136
+
137
+ async function checkField(name: keyof T): Promise<string | undefined> {
138
+ const rule = options.rules?.[name]
139
+ const list = (Array.isArray(rule) ? rule : rule ? [rule] : []) as FieldRule<unknown, T>[]
140
+ for (const r of list) {
141
+ const found = message(await r(plain()[name], plain()))
142
+ if (found) return found
143
+ }
144
+ return undefined
145
+ }
146
+
147
+ async function runField(name: keyof T): Promise<boolean> {
148
+ const run = (runs.get(name) ?? 0) + 1
149
+ runs.set(name, run)
150
+ const found = await checkField(name)
151
+ if (runs.get(name) !== run) return !errors[name]
152
+ if (found) errors[name] = found
153
+ else delete errors[name]
154
+ return !found && !external[name]
155
+ }
156
+
157
+ async function runForm(): Promise<boolean> {
158
+ if (!options.validate) return true
159
+ const run = (runs.get("form") ?? 0) + 1
160
+ runs.set("form", run)
161
+ const found = (await options.validate(plain())) ?? {}
162
+ if (runs.get("form") !== run) return true
163
+ let ok = true
164
+ for (const [name, text] of Object.entries(found) as [keyof T | "form", string | undefined][]) {
165
+ if (!text) continue
166
+ ok = false
167
+ if (!errors[name]) errors[name] = text
168
+ }
169
+ return ok
170
+ }
171
+
172
+ async function validate(name?: keyof T): Promise<boolean> {
173
+ validating.value = true
174
+ try {
175
+ if (name !== undefined) return await runField(name)
176
+ if (errors.form) delete errors.form
177
+ const fields = await Promise.all(keys().map(runField))
178
+ const form = await runForm()
179
+ return fields.every(Boolean) && form && !Object.values(external).some(Boolean)
180
+ } finally {
181
+ validating.value = false
182
+ }
183
+ }
184
+
185
+ function touch(name: keyof T): void {
186
+ touched[name] = true
187
+ void validate(name)
188
+ }
189
+
190
+ function set<K extends keyof T>(name: K, value: T[K]): void {
191
+ ;(values as unknown as T)[name] = value
192
+ }
193
+
194
+ function setValues(next: Partial<T>): void {
195
+ for (const [k, v] of Object.entries(next) as [keyof T, T[keyof T]][]) set(k, v)
196
+ }
197
+
198
+ function clearState(): void {
199
+ for (const k of Object.keys(errors)) delete errors[k as keyof T]
200
+ for (const k of Object.keys(external)) delete external[k as keyof T]
201
+ for (const k of Object.keys(touched)) delete touched[k as keyof T]
202
+ runs.clear()
203
+ submitCount.value = 0
204
+ }
205
+
206
+ function adopt(next: T): void {
207
+ baseline = clone(next)
208
+ const target = values as unknown as Record<string, unknown>
209
+ for (const k of Object.keys(target)) if (!(k in (baseline as object))) delete target[k]
210
+ Object.assign(target, clone(baseline))
211
+ baseVersion.value++
212
+ }
213
+
214
+ function reset(next?: T): void {
215
+ adopt(next ?? toValue(options.initial))
216
+ clearState()
217
+ }
218
+
219
+ function setErrors(next: FormErrors<T>): void {
220
+ for (const [k, v] of Object.entries(next) as [keyof T | "form", string | undefined][]) {
221
+ if (v) external[k] = v
222
+ else delete external[k]
223
+ }
224
+ }
225
+
226
+ watch(
227
+ () => toValue(options.initial),
228
+ (next) => {
229
+ if (!dirty.value && !deepEqual(next, baseline)) adopt(next)
230
+ },
231
+ { deep: true },
232
+ )
233
+
234
+ // An edit re-checks a field already touched, and drops an external error on it.
235
+ let snapshot = clone(plain())
236
+ watch(
237
+ plain,
238
+ () => {
239
+ const now = clone(plain())
240
+ for (const name of Object.keys(now) as (keyof T)[]) {
241
+ if (deepEqual(now[name], snapshot[name])) continue
242
+ if (external[name]) delete external[name]
243
+ if (touched[name] || submitCount.value) void validate(name)
244
+ }
245
+ snapshot = now
246
+ },
247
+ { deep: true },
248
+ )
249
+
250
+ const merged = computed<FormErrors<T>>(() => ({ ...errors, ...external }))
251
+ const visibleErrors = computed<FormErrors<T>>(() => {
252
+ const all = merged.value
253
+ if (submitCount.value) return all
254
+ const shown: FormErrors<T> = {}
255
+ for (const [k, v] of Object.entries(all) as [keyof T, string][]) {
256
+ if (touched[k] || external[k]) shown[k] = v
257
+ }
258
+ return shown
259
+ })
260
+ const valid = computed(() => !Object.values(merged.value).some(Boolean))
261
+
262
+ function field<K extends keyof T>(name: K): FieldBinding<T[K]> {
263
+ const error = visibleErrors.value[name]
264
+ return {
265
+ modelValue: (values as unknown as T)[name],
266
+ "onUpdate:modelValue": (value: T[K]) => set(name, value),
267
+ onBlur: () => touch(name),
268
+ error,
269
+ invalid: !!error,
270
+ }
271
+ }
272
+
273
+ function handleSubmit(fn: (values: T) => unknown) {
274
+ return async (event?: Event) => {
275
+ event?.preventDefault()
276
+ if (submitting.value) return
277
+ submitting.value = true
278
+ try {
279
+ submitCount.value++
280
+ for (const name of keys()) touched[name] = true
281
+ if (!(await validate())) return
282
+ const submitted = clone(plain())
283
+ await fn(submitted)
284
+ if (deepEqual(plain(), submitted)) {
285
+ baseline = submitted
286
+ baseVersion.value++
287
+ }
288
+ } finally {
289
+ submitting.value = false
290
+ }
291
+ }
292
+ }
293
+
294
+ return {
295
+ values,
296
+ errors: merged,
297
+ visibleErrors,
298
+ touched,
299
+ dirty,
300
+ valid,
301
+ validating,
302
+ submitting,
303
+ submitCount,
304
+ isDirty,
305
+ field,
306
+ set,
307
+ setValues,
308
+ touch,
309
+ validate,
310
+ setErrors,
311
+ reset,
312
+ handleSubmit,
313
+ }
314
+ }