@leavepulse/ui 0.43.4 → 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 (437) hide show
  1. package/README.md +121 -7
  2. package/dist/assets/{LpCommandPalette-B-0Est4Z.css → LpCommandPalette-nGNnzXqQ.css} +21 -21
  3. package/dist/assets/{LpDropzone-BBoWvi-J.css → LpDropzone-i4BdMi1H.css} +12 -12
  4. package/dist/assets/{LpImageEditor-Ct7Y3aNu.css → LpImageEditor-CbMA82g8.css} +6 -6
  5. package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
  6. package/dist/assets/{LpShortcutsDialog-C6YoQ8_M.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
  7. package/dist/assets/{LpSkeleton-D7xU8IPV.css → LpSkeleton-H3Z5dEIk.css} +4 -1
  8. package/dist/assets/{LpTable-BgPLJOXa.css → LpTable-CPwWxTJS.css} +49 -9
  9. package/dist/chunks/ContextMenuItems-BtKU7074.js +124 -0
  10. package/dist/chunks/{LayoutCanvas-Dd20boUG.js → LayoutCanvas-BkaGXTjx.js} +26 -9
  11. package/dist/chunks/{LayoutNode-DGpZA9LX.js → LayoutNode-B3_9E1Xa.js} +28 -12
  12. package/dist/chunks/{LpActionBar-BlvtGB2P.js → LpActionBar-CnfFVIwC.js} +26 -12
  13. package/dist/chunks/{LpAddressInput-B-Zvxsn7.js → LpAddressInput-BwX8uo4_.js} +54 -49
  14. package/dist/chunks/{LpAlert-BAYHDSS1.js → LpAlert-B6ilrM7T.js} +16 -9
  15. package/dist/chunks/{LpAppShell-BGQVIY-3.js → LpAppShell-C53_RaGF.js} +53 -26
  16. package/dist/chunks/{LpAutocomplete-DExlIZe_.js → LpAutocomplete-C8IgSMsi.js} +52 -24
  17. package/dist/chunks/{LpAvatar-B3-xAOH3.js → LpAvatar-C1XStdOo.js} +19 -10
  18. package/dist/chunks/LpBreadcrumbs-CQJd73Gm.js +95 -0
  19. package/dist/chunks/{LpButton-5VXXCjwF.js → LpButton-CKSwC6Mu.js} +45 -15
  20. package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
  21. package/dist/chunks/{LpCard-BhI1N_vL.js → LpCard-C2IvucmX.js} +1 -1
  22. package/dist/chunks/{LpCheckbox-CVccKYaH.js → LpCheckbox-Bkj13CbW.js} +8 -4
  23. package/dist/chunks/{LpCodeBlock-Bo0B4TFR.js → LpCodeBlock-ca7ANFyW.js} +49 -17
  24. package/dist/chunks/{LpColorPicker-BlXea7LM.js → LpColorPicker-zz4VGkG4.js} +75 -26
  25. package/dist/chunks/{LpConfirmDialog-Q-bxff8z.js → LpConfirmDialog-DCw5jR1t.js} +59 -17
  26. package/dist/chunks/LpConfirmHost-CbywMpMP.js +49 -0
  27. package/dist/chunks/LpContextMenu-BqkAYa9X.js +69 -0
  28. package/dist/chunks/LpDatePicker-DuusgT69.js +195 -0
  29. package/dist/chunks/LpDateRangePicker-DHeuznfC.js +208 -0
  30. package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
  31. package/dist/chunks/{LpDrawer-BZ6k3hvo.js → LpDrawer-Dt4aB4Fr.js} +65 -28
  32. package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
  33. package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
  34. package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
  35. package/dist/chunks/{LpFileTree-_5vFBgaK.js → LpFileTree-CNurOvkV.js} +25 -10
  36. package/dist/chunks/{LpFileTreeNode-BdcIYKIE.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
  37. package/dist/chunks/LpFormField-CtVYj6tO.js +101 -0
  38. package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
  39. package/dist/chunks/{LpInput-9EXWUM9B.js → LpInput-D-YGQbEv.js} +58 -20
  40. package/dist/chunks/{LpLightbox-2sYVMY-S.js → LpLightbox-DSDahF23.js} +148 -61
  41. package/dist/chunks/{LpLink-DCND-zyu.js → LpLink-Krkjhe_y.js} +15 -6
  42. package/dist/chunks/{LpLogViewer-CaRy5MeS.js → LpLogViewer-KypbgOyo.js} +300 -72
  43. package/dist/chunks/{LpMapPicker-ul4oYno6.js → LpMapPicker-Cc84LaM3.js} +39 -14
  44. package/dist/chunks/{LpMediaGallery-FF-NquIo.js → LpMediaGallery-YKr-cQPN.js} +7 -4
  45. package/dist/chunks/{LpModal-Bt0pQCI_.js → LpModal-DMMDQHAu.js} +52 -29
  46. package/dist/chunks/{LpModalNav-YaifHrXC.js → LpModalNav-BVPDyIup.js} +10 -7
  47. package/dist/chunks/{LpNotificationBell-DyGGsMAM.js → LpNotificationBell-CCwyJP5C.js} +79 -33
  48. package/dist/chunks/{LpNumberField-CWuX08iX.js → LpNumberField-B77O-z28.js} +21 -8
  49. package/dist/chunks/{LpNumberFlow-amWYaeuA.js → LpNumberFlow-z4WPZcZu.js} +22 -12
  50. package/dist/chunks/{LpOtpInput-y2v-pmiN.js → LpOtpInput-CziPF3Oq.js} +37 -8
  51. package/dist/chunks/{LpPagination-5FxjmmB5.js → LpPagination-BcDlo3r_.js} +31 -20
  52. package/dist/chunks/{LpPasswordInput--sjYCkLS.js → LpPasswordInput-eKzQhbQP.js} +11 -8
  53. package/dist/chunks/LpPhoneInput-BCbrrhgE.js +336 -0
  54. package/dist/chunks/{LpPie-D6OnzwlJ.js → LpPie-BEJ2SfI6.js} +84 -28
  55. package/dist/chunks/{LpPopover-8268UmlC.js → LpPopover-DCKeqiVn.js} +5 -2
  56. package/dist/chunks/{LpProgress-Btq5W4Ut.js → LpProgress-CBU1iI69.js} +5 -4
  57. package/dist/chunks/{LpRadioGroup-xPIuVDO_.js → LpRadioGroup-DmEW0l78.js} +9 -2
  58. package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
  59. package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
  60. package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
  61. package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
  62. package/dist/chunks/{LpSegmented-fV0Sk92p.js → LpSegmented-C09EEam2.js} +8 -4
  63. package/dist/chunks/{LpSelect-ANIVtrpM.js → LpSelect-DNIL6MB9.js} +50 -31
  64. package/dist/chunks/{LpSidebar-ClBUwoEz.js → LpSidebar-CQNB0-yU.js} +30 -22
  65. package/dist/chunks/{LpSidebarNav-llOcs_Fn.js → LpSidebarNav-kx-LdqNU.js} +16 -11
  66. package/dist/chunks/{LpSkeleton-CTI-vBgW.js → LpSkeleton-BB6XIV-w.js} +1 -1
  67. package/dist/chunks/{LpSlider-BwNIBkAK.js → LpSlider-Cuzmrd7p.js} +7 -2
  68. package/dist/chunks/{LpSparkline-C6xQ2dwv.js → LpSparkline-CB80N8SJ.js} +11 -4
  69. package/dist/chunks/{LpStat-CQaW8R--.js → LpStat-B-7udBRP.js} +2 -2
  70. package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
  71. package/dist/chunks/{LpSwitch-Blizvqyt.js → LpSwitch-OrcOJX9m.js} +41 -27
  72. package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
  73. package/dist/chunks/{LpTableOfContents-CjvxPMpU.js → LpTableOfContents-BUcAfGPQ.js} +36 -41
  74. package/dist/chunks/{LpTabs-Cefe_lys.js → LpTabs-29iW1XOx.js} +20 -6
  75. package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
  76. package/dist/chunks/LpTextarea-uuScWo6-.js +77 -0
  77. package/dist/chunks/LpThemeProvider-Be4r2TQX.js +35 -0
  78. package/dist/chunks/LpThemeSwitcher-B4aVPbNC.js +166 -0
  79. package/dist/chunks/{LpTilt-DuyKbpkA.js → LpTilt-B7KHX1zH.js} +2 -2
  80. package/dist/chunks/{LpTimeField-K6nAGboX.js → LpTimeField-Cqe7fXnh.js} +14 -8
  81. package/dist/chunks/{LpToaster-gDB1EaU3.js → LpToaster-DpJvoCOh.js} +94 -31
  82. package/dist/chunks/{LpTooltip-hCLPAqMG.js → LpTooltip-CXVKcOXT.js} +14 -6
  83. package/dist/chunks/LpTooltipProvider-CftjV7Xc.js +25 -0
  84. package/dist/chunks/{LpTopologyCanvas-Cm1L39uL.js → LpTopologyCanvas-6ozNrQKh.js} +8 -2
  85. package/dist/chunks/{LpUptimeBar-DvAMno0S.js → LpUptimeBar-ugcv25xj.js} +44 -15
  86. package/dist/chunks/icons-CkA0njzm.js +43 -0
  87. package/dist/chunks/{LpThemeSwitcher-Cue-myST.js → useTheme-DJm1JSsY.js} +215 -260
  88. package/dist/component-names.d.ts +1 -1
  89. package/dist/components/ContextMenuItems.vue.d.ts +8 -0
  90. package/dist/components/ContextMenuItems.vue.js +4 -0
  91. package/dist/components/LayoutCanvas.vue.js +1 -1
  92. package/dist/components/LayoutNode.vue.d.ts +4 -0
  93. package/dist/components/LayoutNode.vue.js +1 -1
  94. package/dist/components/LpActionBar.vue.d.ts +3 -0
  95. package/dist/components/LpActionBar.vue.js +1 -1
  96. package/dist/components/LpAddressInput.vue.d.ts +1 -1
  97. package/dist/components/LpAddressInput.vue.js +1 -1
  98. package/dist/components/LpAlert.vue.d.ts +9 -0
  99. package/dist/components/LpAlert.vue.js +1 -1
  100. package/dist/components/LpAppShell.vue.d.ts +10 -0
  101. package/dist/components/LpAppShell.vue.js +1 -1
  102. package/dist/components/LpAutocomplete.vue.d.ts +31 -6
  103. package/dist/components/LpAutocomplete.vue.js +1 -1
  104. package/dist/components/LpAvatar.vue.d.ts +3 -3
  105. package/dist/components/LpAvatar.vue.js +1 -1
  106. package/dist/components/LpBadge.vue.d.ts +2 -2
  107. package/dist/components/LpBreadcrumbs.vue.d.ts +13 -9
  108. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  109. package/dist/components/LpButton.vue.d.ts +35 -98
  110. package/dist/components/LpButton.vue.js +1 -1
  111. package/dist/components/LpCalendar.vue.js +1 -1
  112. package/dist/components/LpCard.vue.js +1 -1
  113. package/dist/components/LpChart.vue.d.ts +47 -3
  114. package/dist/components/LpChart.vue.js +377 -97
  115. package/dist/components/LpCheckbox.vue.js +1 -1
  116. package/dist/components/LpCodeBlock.vue.d.ts +2 -0
  117. package/dist/components/LpCodeBlock.vue.js +1 -1
  118. package/dist/components/LpColorPicker.vue.d.ts +2 -2
  119. package/dist/components/LpColorPicker.vue.js +1 -1
  120. package/dist/components/LpCommandPalette.vue.d.ts +14 -3
  121. package/dist/components/LpCommandPalette.vue.js +142 -77
  122. package/dist/components/LpConfirmDialog.vue.d.ts +15 -3
  123. package/dist/components/LpConfirmDialog.vue.js +1 -1
  124. package/dist/components/LpConfirmHost.vue.d.ts +9 -0
  125. package/dist/components/LpConfirmHost.vue.js +4 -0
  126. package/dist/components/LpContextMenu.vue.d.ts +3 -5
  127. package/dist/components/LpContextMenu.vue.js +1 -1
  128. package/dist/components/LpDatePicker.vue.d.ts +24 -6
  129. package/dist/components/LpDatePicker.vue.js +1 -1
  130. package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
  131. package/dist/components/LpDateRangePicker.vue.js +4 -0
  132. package/dist/components/LpDisclosure.vue.js +1 -1
  133. package/dist/components/LpDrawer.vue.d.ts +10 -0
  134. package/dist/components/LpDrawer.vue.js +1 -1
  135. package/dist/components/LpDropdownMenu.vue.js +1 -1
  136. package/dist/components/LpDropzone.vue.js +23 -7
  137. package/dist/components/LpEmptyState.vue.js +1 -1
  138. package/dist/components/LpFieldNotice.vue.d.ts +8 -0
  139. package/dist/components/LpFieldNotice.vue.js +4 -0
  140. package/dist/components/LpFileTree.vue.d.ts +11 -2
  141. package/dist/components/LpFileTree.vue.js +1 -1
  142. package/dist/components/LpFileTreeNode.vue.js +1 -1
  143. package/dist/components/LpFormField.vue.d.ts +13 -1
  144. package/dist/components/LpFormField.vue.js +1 -1
  145. package/dist/components/LpIcon.vue.d.ts +0 -1
  146. package/dist/components/LpIcon.vue.js +1 -1
  147. package/dist/components/LpImageEditor.vue.js +93 -58
  148. package/dist/components/LpInput.vue.d.ts +22 -3
  149. package/dist/components/LpInput.vue.js +1 -1
  150. package/dist/components/LpLightbox.vue.d.ts +17 -0
  151. package/dist/components/LpLightbox.vue.js +1 -1
  152. package/dist/components/LpLink.vue.d.ts +11 -11
  153. package/dist/components/LpLink.vue.js +1 -1
  154. package/dist/components/LpLogViewer.vue.d.ts +72 -3
  155. package/dist/components/LpLogViewer.vue.js +1 -1
  156. package/dist/components/LpMapPicker.vue.d.ts +4 -4
  157. package/dist/components/LpMapPicker.vue.js +1 -1
  158. package/dist/components/LpMediaGallery.vue.js +1 -1
  159. package/dist/components/LpModal.vue.d.ts +16 -6
  160. package/dist/components/LpModal.vue.js +1 -1
  161. package/dist/components/LpModalNav.vue.d.ts +0 -1
  162. package/dist/components/LpModalNav.vue.js +1 -1
  163. package/dist/components/LpNotificationBell.vue.d.ts +11 -2
  164. package/dist/components/LpNotificationBell.vue.js +1 -1
  165. package/dist/components/LpNumberField.vue.d.ts +2 -0
  166. package/dist/components/LpNumberField.vue.js +1 -1
  167. package/dist/components/LpNumberFlow.vue.js +1 -1
  168. package/dist/components/LpOtpInput.vue.js +1 -1
  169. package/dist/components/LpPagination.vue.d.ts +2 -0
  170. package/dist/components/LpPagination.vue.js +1 -1
  171. package/dist/components/LpPasswordInput.vue.js +1 -1
  172. package/dist/components/LpPhoneInput.vue.d.ts +13 -0
  173. package/dist/components/LpPhoneInput.vue.js +1 -1
  174. package/dist/components/LpPie.vue.d.ts +21 -2
  175. package/dist/components/LpPie.vue.js +1 -1
  176. package/dist/components/LpPopover.vue.d.ts +2 -0
  177. package/dist/components/LpPopover.vue.js +1 -1
  178. package/dist/components/LpProgress.vue.d.ts +1 -1
  179. package/dist/components/LpProgress.vue.js +1 -1
  180. package/dist/components/LpRadioGroup.vue.js +1 -1
  181. package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
  182. package/dist/components/LpRangeCalendar.vue.js +4 -0
  183. package/dist/components/LpRollingText.vue.d.ts +1 -1
  184. package/dist/components/LpRollingText.vue.js +1 -1
  185. package/dist/components/LpScrollArea.vue.d.ts +2 -2
  186. package/dist/components/LpScrollArea.vue.js +1 -1
  187. package/dist/components/LpSection.vue.d.ts +1 -1
  188. package/dist/components/LpSection.vue.js +1 -1
  189. package/dist/components/LpSegmented.vue.d.ts +5 -0
  190. package/dist/components/LpSegmented.vue.js +1 -1
  191. package/dist/components/LpSelect.vue.d.ts +1 -0
  192. package/dist/components/LpSelect.vue.js +1 -1
  193. package/dist/components/LpShortcutsDialog.vue.d.ts +7 -1
  194. package/dist/components/LpShortcutsDialog.vue.js +60 -15
  195. package/dist/components/LpSidebar.vue.d.ts +6 -2
  196. package/dist/components/LpSidebar.vue.js +1 -1
  197. package/dist/components/LpSidebarNav.vue.js +1 -1
  198. package/dist/components/LpSkeleton.vue.js +1 -1
  199. package/dist/components/LpSlider.vue.js +1 -1
  200. package/dist/components/LpSparkline.vue.d.ts +1 -0
  201. package/dist/components/LpSparkline.vue.js +1 -1
  202. package/dist/components/LpStat.vue.js +1 -1
  203. package/dist/components/LpStepper.vue.js +1 -1
  204. package/dist/components/LpSwitch.vue.js +1 -1
  205. package/dist/components/LpTable.vue.d.ts +107 -3
  206. package/dist/components/LpTable.vue.js +540 -128
  207. package/dist/components/LpTableFilter.vue.d.ts +31 -0
  208. package/dist/components/LpTableFilter.vue.js +4 -0
  209. package/dist/components/LpTableOfContents.vue.d.ts +4 -2
  210. package/dist/components/LpTableOfContents.vue.js +1 -1
  211. package/dist/components/LpTabs.vue.d.ts +2 -0
  212. package/dist/components/LpTabs.vue.js +1 -1
  213. package/dist/components/LpTagsInput.vue.d.ts +77 -0
  214. package/dist/components/LpTagsInput.vue.js +4 -0
  215. package/dist/components/LpTextarea.vue.d.ts +19 -6
  216. package/dist/components/LpTextarea.vue.js +1 -1
  217. package/dist/components/LpThemeProvider.vue.d.ts +22 -0
  218. package/dist/components/LpThemeProvider.vue.js +4 -0
  219. package/dist/components/LpThemeSwitcher.vue.d.ts +15 -2
  220. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  221. package/dist/components/LpTilt.vue.js +1 -1
  222. package/dist/components/LpTimeField.vue.js +1 -1
  223. package/dist/components/LpToaster.vue.d.ts +6 -1
  224. package/dist/components/LpToaster.vue.js +1 -1
  225. package/dist/components/LpTooltip.vue.d.ts +1 -2
  226. package/dist/components/LpTooltip.vue.js +1 -1
  227. package/dist/components/LpTooltipProvider.vue.d.ts +22 -0
  228. package/dist/components/LpTooltipProvider.vue.js +4 -0
  229. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  230. package/dist/components/LpUptimeBar.vue.d.ts +4 -0
  231. package/dist/components/LpUptimeBar.vue.js +1 -1
  232. package/dist/components/chart.d.ts +32 -1
  233. package/dist/components/chart.js +42 -6
  234. package/dist/components/codeHighlight.d.ts +6 -0
  235. package/dist/components/codeHighlight.js +92 -20
  236. package/dist/components/color.d.ts +1 -1
  237. package/dist/components/color.js +20 -9
  238. package/dist/components/commandSearch.js +3 -0
  239. package/dist/components/confirm.d.ts +25 -0
  240. package/dist/components/confirm.js +26 -0
  241. package/dist/components/countries.d.ts +6 -0
  242. package/dist/components/countries.js +7 -0
  243. package/dist/components/dateRange.d.ts +43 -0
  244. package/dist/components/dateRange.js +76 -0
  245. package/dist/components/exportData.d.ts +58 -0
  246. package/dist/components/exportData.js +165 -0
  247. package/dist/components/fileTree.js +16 -7
  248. package/dist/components/imageCrop.d.ts +42 -0
  249. package/dist/components/imageCrop.js +47 -0
  250. package/dist/components/link.d.ts +12 -0
  251. package/dist/components/link.js +19 -0
  252. package/dist/components/logSearch.d.ts +18 -0
  253. package/dist/components/logSearch.js +36 -0
  254. package/dist/components/modalDepth.d.ts +18 -2
  255. package/dist/components/modalDepth.js +17 -5
  256. package/dist/components/otp.d.ts +5 -0
  257. package/dist/components/otp.js +10 -0
  258. package/dist/components/phone.d.ts +41 -0
  259. package/dist/components/phone.js +80 -0
  260. package/dist/components/sidebar.d.ts +7 -1
  261. package/dist/components/tableFilter.d.ts +36 -0
  262. package/dist/components/tableFilter.js +84 -0
  263. package/dist/components/tableSort.d.ts +8 -0
  264. package/dist/components/tableSort.js +31 -0
  265. package/dist/components/tags.d.ts +20 -0
  266. package/dist/components/tags.js +37 -0
  267. package/dist/composables/useField.d.ts +38 -0
  268. package/dist/composables/useField.js +45 -0
  269. package/dist/composables/useFieldNotice.d.ts +17 -0
  270. package/dist/composables/useFieldNotice.js +28 -0
  271. package/dist/composables/useForm.d.ts +53 -0
  272. package/dist/composables/useForm.js +207 -0
  273. package/dist/composables/useFormDraft.d.ts +10 -3
  274. package/dist/composables/useFormDraft.js +9 -14
  275. package/dist/composables/useHotkeys.d.ts +81 -5
  276. package/dist/composables/useHotkeys.js +223 -25
  277. package/dist/composables/useInputFilter.d.ts +17 -0
  278. package/dist/composables/useInputFilter.js +47 -10
  279. package/dist/composables/useInputMask.d.ts +59 -0
  280. package/dist/composables/useInputMask.js +170 -0
  281. package/dist/composables/useLpConfig.d.ts +24 -0
  282. package/dist/composables/useLpConfig.js +44 -0
  283. package/dist/composables/usePaintSelect.d.ts +22 -0
  284. package/dist/composables/usePaintSelect.js +169 -0
  285. package/dist/composables/useRovingFocus.js +7 -0
  286. package/dist/composables/useShift.js +7 -2
  287. package/dist/composables/useTilt.d.ts +2 -2
  288. package/dist/composables/useTilt.js +5 -6
  289. package/dist/composables/useToast.d.ts +20 -0
  290. package/dist/composables/useToast.js +28 -4
  291. package/dist/composables/useZoomPan.d.ts +3 -3
  292. package/dist/composables/useZoomPan.js +4 -4
  293. package/dist/icons-all.d.ts +1 -0
  294. package/dist/icons-all.js +3 -0
  295. package/dist/icons.d.ts +14 -1
  296. package/dist/index.d.ts +60 -4
  297. package/dist/index.js +245 -141
  298. package/dist/kit-icons.d.ts +2 -0
  299. package/dist/layout/tree.d.ts +2 -0
  300. package/dist/layout/tree.js +16 -1
  301. package/dist/layout/useLayout.d.ts +15 -0
  302. package/dist/layout/useLayout.js +41 -14
  303. package/dist/theme/backdrop.d.ts +12 -0
  304. package/dist/theme/presets.d.ts +0 -2
  305. package/dist/theme/useTheme.d.ts +32 -9
  306. package/package.json +18 -8
  307. package/src/component-names.ts +9 -7
  308. package/src/components/ContextMenuItems.vue +88 -0
  309. package/src/components/LayoutCanvas.vue +26 -6
  310. package/src/components/LayoutNode.vue +30 -4
  311. package/src/components/LpActionBar.vue +31 -8
  312. package/src/components/LpAddressInput.vue +57 -41
  313. package/src/components/LpAlert.vue +16 -3
  314. package/src/components/LpAppShell.vue +45 -12
  315. package/src/components/LpAutocomplete.vue +47 -10
  316. package/src/components/LpAvatar.vue +20 -6
  317. package/src/components/LpBreadcrumbs.vue +65 -41
  318. package/src/components/LpButton.vue +60 -14
  319. package/src/components/LpChart.vue +351 -28
  320. package/src/components/LpCheckbox.vue +7 -3
  321. package/src/components/LpCodeBlock.vue +50 -14
  322. package/src/components/LpColorPicker.vue +73 -10
  323. package/src/components/LpCommandPalette.vue +115 -20
  324. package/src/components/LpConfirmDialog.vue +68 -17
  325. package/src/components/LpConfirmHost.vue +59 -0
  326. package/src/components/LpContextMenu.vue +15 -116
  327. package/src/components/LpDatePicker.vue +104 -68
  328. package/src/components/LpDateRangePicker.vue +224 -0
  329. package/src/components/LpDrawer.vue +56 -19
  330. package/src/components/LpDropdownMenu.vue +1 -0
  331. package/src/components/LpDropzone.vue +21 -4
  332. package/src/components/LpFieldNotice.vue +65 -0
  333. package/src/components/LpFileTree.vue +33 -7
  334. package/src/components/LpFormField.vue +59 -7
  335. package/src/components/LpIcon.vue +22 -4
  336. package/src/components/LpImageEditor.vue +96 -57
  337. package/src/components/LpInput.vue +62 -11
  338. package/src/components/LpLightbox.vue +140 -29
  339. package/src/components/LpLink.vue +24 -13
  340. package/src/components/LpLogViewer.vue +311 -39
  341. package/src/components/LpMapPicker.vue +36 -13
  342. package/src/components/LpMediaGallery.vue +8 -2
  343. package/src/components/LpModal.vue +54 -28
  344. package/src/components/LpModalNav.vue +5 -2
  345. package/src/components/LpNotificationBell.vue +77 -23
  346. package/src/components/LpNumberField.vue +21 -6
  347. package/src/components/LpNumberFlow.vue +26 -10
  348. package/src/components/LpOtpInput.vue +43 -8
  349. package/src/components/LpPagination.vue +23 -7
  350. package/src/components/LpPasswordInput.vue +4 -1
  351. package/src/components/LpPhoneInput.vue +224 -39
  352. package/src/components/LpPie.vue +102 -20
  353. package/src/components/LpPopover.vue +4 -0
  354. package/src/components/LpProgress.vue +5 -3
  355. package/src/components/LpRadioGroup.vue +9 -2
  356. package/src/components/LpRangeCalendar.vue +365 -0
  357. package/src/components/LpRollingText.vue +3 -0
  358. package/src/components/LpScrollArea.vue +22 -2
  359. package/src/components/LpSegmented.vue +12 -2
  360. package/src/components/LpSelect.vue +0 -0
  361. package/src/components/LpShortcutsDialog.vue +83 -9
  362. package/src/components/LpSidebar.vue +14 -9
  363. package/src/components/LpSidebarNav.vue +17 -5
  364. package/src/components/LpSkeleton.vue +4 -1
  365. package/src/components/LpSlider.vue +7 -1
  366. package/src/components/LpSparkline.vue +11 -3
  367. package/src/components/LpStepper.vue +23 -10
  368. package/src/components/LpSwitch.vue +31 -8
  369. package/src/components/LpTable.vue +668 -90
  370. package/src/components/LpTableFilter.vue +175 -0
  371. package/src/components/LpTableOfContents.vue +50 -32
  372. package/src/components/LpTabs.vue +23 -6
  373. package/src/components/LpTagsInput.vue +364 -0
  374. package/src/components/LpTextarea.vue +44 -6
  375. package/src/components/LpThemeProvider.vue +41 -0
  376. package/src/components/LpThemeSwitcher.vue +76 -33
  377. package/src/components/LpTilt.vue +2 -2
  378. package/src/components/LpTimeField.vue +14 -3
  379. package/src/components/LpToaster.vue +115 -27
  380. package/src/components/LpTooltip.vue +29 -5
  381. package/src/components/LpTooltipProvider.vue +24 -0
  382. package/src/components/LpTopologyCanvas.vue +19 -2
  383. package/src/components/LpUptimeBar.vue +46 -8
  384. package/src/components/chart.ts +82 -8
  385. package/src/components/codeHighlight.ts +111 -15
  386. package/src/components/color.ts +30 -9
  387. package/src/components/commandSearch.ts +6 -0
  388. package/src/components/confirm.ts +58 -0
  389. package/src/components/countries.ts +12 -0
  390. package/src/components/dateRange.ts +134 -0
  391. package/src/components/exportData.ts +228 -0
  392. package/src/components/fileTree.ts +19 -7
  393. package/src/components/imageCrop.ts +107 -0
  394. package/src/components/link.ts +34 -0
  395. package/src/components/logSearch.ts +46 -0
  396. package/src/components/modalDepth.ts +48 -6
  397. package/src/components/otp.ts +16 -0
  398. package/src/components/phone.ts +140 -0
  399. package/src/components/sidebar.ts +7 -1
  400. package/src/components/tableFilter.ts +122 -0
  401. package/src/components/tableSort.ts +37 -0
  402. package/src/components/tags.ts +62 -0
  403. package/src/composables/useField.ts +83 -0
  404. package/src/composables/useFieldNotice.ts +59 -0
  405. package/src/composables/useForm.ts +314 -0
  406. package/src/composables/useFormDraft.ts +25 -20
  407. package/src/composables/useHotkeys.ts +355 -28
  408. package/src/composables/useInputFilter.ts +77 -14
  409. package/src/composables/useInputMask.ts +254 -0
  410. package/src/composables/useLpConfig.ts +87 -0
  411. package/src/composables/usePaintSelect.ts +243 -0
  412. package/src/composables/useRovingFocus.ts +11 -0
  413. package/src/composables/useShift.ts +13 -2
  414. package/src/composables/useTilt.ts +5 -6
  415. package/src/composables/useToast.ts +56 -4
  416. package/src/composables/useZoomPan.ts +6 -6
  417. package/src/iconSubset.d.mts +10 -0
  418. package/src/iconSubset.mjs +51 -0
  419. package/src/icons-all.ts +10 -0
  420. package/src/icons.ts +59 -8
  421. package/src/index.ts +73 -4
  422. package/src/kit-icons.ts +5 -0
  423. package/src/layout/tree.ts +30 -0
  424. package/src/layout/useLayout.ts +68 -18
  425. package/src/nuxt.ts +87 -17
  426. package/src/runtime/config.plugin.ts +14 -0
  427. package/src/runtime/theme.plugin.ts +43 -23
  428. package/src/theme/backdrop.ts +69 -0
  429. package/src/theme/presets.ts +6 -64
  430. package/src/theme/useTheme.ts +206 -100
  431. package/src/tokens/tokens.css +46 -0
  432. package/dist/chunks/LpBreadcrumbs-p4jHe_V-.js +0 -62
  433. package/dist/chunks/LpContextMenu-CwW1QXe_.js +0 -217
  434. package/dist/chunks/LpDatePicker-CwOToxGq.js +0 -164
  435. package/dist/chunks/LpFormField-CazBc2-a.js +0 -62
  436. package/dist/chunks/LpPhoneInput-BDSKVl_z.js +0 -209
  437. package/dist/chunks/LpTextarea-t7F6gv8E.js +0 -47
@@ -5,22 +5,43 @@ export default { inheritAttrs: false }
5
5
  </script>
6
6
 
7
7
  <script setup lang="ts" generic="T extends Record<string, unknown>">
8
- import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useAttrs, watch } from "vue"
8
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useAttrs, watch } from "vue"
9
9
  import LpCheckbox from "./LpCheckbox.vue"
10
10
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
11
11
  import LpIcon from "./LpIcon.vue"
12
12
  import LpPagination from "./LpPagination.vue"
13
+ import LpPopover from "./LpPopover.vue"
13
14
  import LpScrollArea from "./LpScrollArea.vue"
15
+ import LpRollingText from "./LpRollingText.vue"
16
+ import LpShift from "./LpShift.vue"
17
+ import LpTableFilter, { type TableFilterOption } from "./LpTableFilter.vue"
14
18
  import { useAsyncTask } from "../composables/useAsyncTask"
19
+ import { useHotkeys } from "../composables/useHotkeys"
20
+ import { usePaintSelect } from "../composables/usePaintSelect"
15
21
  import { useRovingFocus } from "../composables/useRovingFocus"
22
+ import { useLabels } from "../composables/useLpConfig"
23
+ import { compareValues } from "./tableSort"
24
+ import LpButton from "./LpButton.vue"
25
+ import { downloadFile, fileStem, toCsv, type CsvOptions } from "./exportData"
26
+ import { countValues, parseTableState, serializeTableState, summarizeShown } from "./tableFilter"
16
27
 
17
28
  export interface TableColumn<Row> {
18
29
  key: string
19
30
  label: string
20
31
  align?: "left" | "right" | "center"
21
32
  width?: string
33
+ /**
34
+ * Keep the cell on one line. For ids, codes, dates and amounts, which a
35
+ * narrow screen otherwise breaks at a hyphen or a space ("ORD-" / "1040");
36
+ * the table scrolls sideways instead.
37
+ */
38
+ nowrap?: boolean
22
39
  /** Allow clicking the header to sort by this column. */
23
40
  sortable?: boolean
41
+ /** The value this column sorts by, when the cell holds something else. */
42
+ sortValue?: (row: Row) => unknown
43
+ /** Full control over the order; wins over `sortValue`. Ascending order. */
44
+ compare?: (a: Row, b: Row) => number
24
45
  /**
25
46
  * Offer "show only these values" in the header's menu, built from the values
26
47
  * actually present in the rows. For a column that holds a small vocabulary —
@@ -33,11 +54,62 @@ export interface TableColumn<Row> {
33
54
  * vocabulary; the menu should say what the column itself says.
34
55
  */
35
56
  valueLabel?: (value: unknown) => string
57
+ /** The value a CSV export writes for this column; the raw cell by default. */
58
+ exportValue?: (row: Row) => unknown
36
59
  /** Keep this column out of the show/hide list — an actions column has no
37
60
  * name to offer and nothing to gain from being hidden. */
38
61
  alwaysVisible?: boolean
39
62
  }
40
63
 
64
+ export interface TableLabels {
65
+ [key: string]: string
66
+ loading: string
67
+ error: string
68
+ empty: string
69
+ sortAsc: string
70
+ sortDesc: string
71
+ sortClear: string
72
+ filter: string
73
+ showAll: string
74
+ columns: string
75
+ exportCsv: string
76
+ selectAll: string
77
+ shortcutsGroup: string
78
+ filterColumn: string
79
+ filterSearch: string
80
+ filterAll: string
81
+ filterNone: string
82
+ filterInvert: string
83
+ filterOnly: string
84
+ filterOnlyShown: string
85
+ filterAddShown: string
86
+ filterNoValues: string
87
+ filterNothingShown: string
88
+ clearFilter: string
89
+ clearFilters: string
90
+ noMatches: string
91
+ resetFilters: string
92
+ rowCount: string
93
+ rowsTotal: string
94
+ selectedCount: string
95
+ clearSelection: string
96
+ pageSelected: string
97
+ selectAllMatching: string
98
+ allMatchingSelected: string
99
+ }
100
+
101
+ export interface TableExportOptions extends CsvOptions {
102
+ /**
103
+ * Which rows: every row in the current order, the rows the header filters
104
+ * leave, or the selected ones. With `load` only the loaded page is known.
105
+ */
106
+ scope?: "all" | "filtered" | "selected"
107
+ /** File name without extension. */
108
+ filename?: string
109
+ /** Return the CSV without offering a download. */
110
+ download?: boolean
111
+ }
112
+
41
113
  export interface SortState {
42
114
  key: string
43
115
  dir: "asc" | "desc"
@@ -68,6 +140,7 @@ export interface TablePage<Row> {
68
140
 
69
141
  type RowKey = string | number
70
142
 
143
+
71
144
  const props = withDefaults(
72
145
  defineProps<{
73
146
  columns: TableColumn<T>[]
@@ -86,12 +159,23 @@ const props = withDefaults(
86
159
  debounce?: number
87
160
  loadingLabel?: string
88
161
  errorLabel?: string
89
- /** Stable id per row — required for selection; falls back to index otherwise. */
162
+ /**
163
+ * Stable id per row. Without it a row is keyed by its position in `rows`,
164
+ * which survives sorting and filtering but not a new array; set it whenever
165
+ * rows are replaced (`load`, polling) and selection must carry over.
166
+ */
90
167
  rowKey?: keyof T | ((row: T) => RowKey)
91
168
  emptyLabel?: string
92
169
  emptyIcon?: string
93
- /** Render a leading checkbox column; selection binds via v-model:selected. */
94
- selectable?: boolean
170
+ /**
171
+ * Rows can be selected (v-model:selected). `true` shows a tick column,
172
+ * where pressing a tick and sweeping ticks the run. `"hold"` keeps the
173
+ * column out of sight until it is needed: a long press on a row selects it
174
+ * (and the same press goes on selecting the rows it is dragged across), the
175
+ * column slides in, and while anything is selected a click on a row toggles
176
+ * it. Emptying the selection, or Escape, slides the column away again.
177
+ */
178
+ selectable?: boolean | "hold"
95
179
  /** Selected row keys (v-model:selected). */
96
180
  selected?: RowKey[]
97
181
  /** Active sort (v-model:sort). When set, the parent owns ordering (server
@@ -111,26 +195,98 @@ const props = withDefaults(
111
195
  filterLabel?: string
112
196
  showAllLabel?: string
113
197
  columnsLabel?: string
198
+ /** Offer a CSV download in a toolbar above the table and in its menu. */
199
+ exportable?: boolean
200
+ /** File name for that download, without extension. */
201
+ exportFilename?: string
202
+ /** All of the wording above in one object, and the config-level scope "table". */
203
+ labels?: Partial<TableLabels>
204
+ /** Row height: `compact` for dense data, `comfortable` (default) otherwise. */
205
+ density?: "comfortable" | "compact"
206
+ /**
207
+ * Always show the bar above the table with the row count. It shows on its
208
+ * own while filters are active or rows are selected.
209
+ */
210
+ summary?: boolean
211
+ /**
212
+ * Remember sort, value filters and hidden columns across visits under this
213
+ * key (localStorage). Only plain filter values (text, numbers) are kept.
214
+ */
215
+ persist?: string
216
+ /**
217
+ * With `load`: the user chose "select all N" beyond the loaded page
218
+ * (v-model:allMatching). The keys in `selected` are then only the loaded
219
+ * ones; acting on everything that matches is the consumer's to do.
220
+ */
221
+ allMatching?: boolean
114
222
  }>(),
115
223
  {
116
224
  rows: () => [],
117
225
  pageSize: 25,
118
226
  debounce: 150,
119
- loadingLabel: "Loading…",
120
- errorLabel: "Could not load",
121
- emptyLabel: "Nothing here yet",
122
227
  emptyIcon: "lucide:inbox",
123
228
  selectable: false,
124
229
  selected: () => [],
125
- sort: null,
126
- sortAscLabel: "Sort ascending",
127
- sortDescLabel: "Sort descending",
128
- sortClearLabel: "Clear sorting",
129
- filterLabel: "Show only",
130
- showAllLabel: "Show all",
131
- columnsLabel: "Columns",
230
+ density: "comfortable",
231
+ summary: false,
232
+ allMatching: false,
233
+ // undefined, not null: null is a parent saying "unsorted", undefined is no
234
+ // parent at all, and only the latter lets the table keep its own sort.
235
+ sort: undefined,
236
+ },
237
+ )
238
+
239
+ const labelSet = useLabels<TableLabels>(
240
+ "table",
241
+ {
242
+ loading: "Loading…",
243
+ error: "Could not load",
244
+ empty: "Nothing here yet",
245
+ sortAsc: "Sort ascending",
246
+ sortDesc: "Sort descending",
247
+ sortClear: "Clear sorting",
248
+ filter: "Show only",
249
+ showAll: "Show all",
250
+ columns: "Columns",
251
+ exportCsv: "Export CSV",
252
+ selectAll: "Select every row",
253
+ shortcutsGroup: "Table",
254
+ filterColumn: "Filter {column}",
255
+ filterSearch: "Search values",
256
+ filterAll: "All",
257
+ filterNone: "None",
258
+ filterInvert: "Invert",
259
+ filterOnly: "Only",
260
+ filterOnlyShown: "Only these {count}",
261
+ filterAddShown: "Add these {count}",
262
+ filterNoValues: "No values match",
263
+ filterNothingShown: "none",
264
+ clearFilter: "Clear the {column} filter",
265
+ clearFilters: "Clear all",
266
+ noMatches: "No rows match the filters",
267
+ resetFilters: "Reset filters",
268
+ rowCount: "{shown} of {total}",
269
+ rowsTotal: "{total} rows",
270
+ selectedCount: "{count} selected",
271
+ clearSelection: "Clear selection",
272
+ pageSelected: "All {count} on this page are selected.",
273
+ selectAllMatching: "Select all {total}",
274
+ allMatchingSelected: "All {total} are selected.",
132
275
  },
276
+ () => props.labels,
133
277
  )
278
+ const text = computed<TableLabels>(() => ({
279
+ ...labelSet.value,
280
+ loading: props.loadingLabel ?? labelSet.value.loading,
281
+ error: props.errorLabel ?? labelSet.value.error,
282
+ empty: props.emptyLabel ?? labelSet.value.empty,
283
+ sortAsc: props.sortAscLabel ?? labelSet.value.sortAsc,
284
+ sortDesc: props.sortDescLabel ?? labelSet.value.sortDesc,
285
+ sortClear: props.sortClearLabel ?? labelSet.value.sortClear,
286
+ filter: props.filterLabel ?? labelSet.value.filter,
287
+ showAll: props.showAllLabel ?? labelSet.value.showAll,
288
+ columns: props.columnsLabel ?? labelSet.value.columns,
289
+ }))
134
290
 
135
291
  /*
136
292
  * Layout classes belong on the root, not on the scroll area. Since the pager
@@ -150,13 +306,16 @@ const scrollAttrs = computed(() => {
150
306
  const emit = defineEmits<{
151
307
  (e: "update:selected", value: RowKey[]): void
152
308
  (e: "update:sort", value: SortState | null): void
309
+ (e: "update:allMatching", value: boolean): void
153
310
  (e: "rowClick", row: T): void
154
311
  }>()
155
312
 
156
313
  function keyFor(row: T, index: number): RowKey {
157
314
  if (typeof props.rowKey === "function") return props.rowKey(row)
158
315
  if (props.rowKey) return row[props.rowKey] as RowKey
159
- return index
316
+ // Position in the source, not in the sorted view: a sort must not move a
317
+ // tick from one row to another.
318
+ return sourceIndex.value.get(row) ?? index
160
319
  }
161
320
 
162
321
  function alignClass(align?: "left" | "right" | "center"): string {
@@ -192,6 +351,8 @@ function onRowContextMenu(row: T) {
192
351
  // Row items first — they are what the pointer is actually on — then the
193
352
  // table-wide ones under a rule, when the row has any of its own.
194
353
  const contextMenu = computed<ContextMenuItemDef[]>(() => {
354
+ // A finger's long press opens this menu too; while it is selecting rows, no.
355
+ if (paint.painting.value) return []
195
356
  const table = tableMenu()
196
357
  const row = menuRow.value ? (props.rowMenu?.(menuRow.value) ?? []) : []
197
358
  if (!row.length) return table
@@ -207,7 +368,7 @@ const contextMenu = computed<ContextMenuItemDef[]>(() => {
207
368
  // emitted an event nobody was listening for, and left the rows exactly as they
208
369
  // were: a control that answers a click by doing nothing.
209
370
  const ownSort = ref<SortState | null>(null)
210
- const activeSort = computed(() => props.sort ?? ownSort.value)
371
+ const activeSort = computed(() => (props.sort !== undefined ? props.sort : ownSort.value))
211
372
 
212
373
  // ── server-backed rows ───────────────────────────────────────
213
374
  const loaded = shallowRef<T[]>([])
@@ -219,14 +380,20 @@ const task = useAsyncTask<TablePage<T>>()
219
380
  const loading = task.pending
220
381
 
221
382
  const sourceRows = computed<T[]>(() => (props.load ? loaded.value : props.rows))
383
+ const sourceIndex = computed(() => new Map(sourceRows.value.map((row, i) => [row, i])))
222
384
 
223
385
  // The tallest page shown so far, held as a floor: the last page is usually
224
386
  // short, and without it the pager below jumps up the moment you reach it.
225
- const tableEl = ref<HTMLElement | null>(null)
387
+ /*
388
+ * The table sits in LpContextMenu's as-child trigger, which re-clones its child
389
+ * on every render and leaves a template ref on it undefined after the first
390
+ * update. The component's own root is stable, so the table is found from it.
391
+ */
392
+ const frameEl = ref<HTMLElement | null>(null)
226
393
  const floor = ref(0)
227
394
  async function holdHeight() {
228
395
  await nextTick()
229
- const height = tableEl.value?.offsetHeight ?? 0
396
+ const height = frameEl.value?.querySelector("table")?.offsetHeight ?? 0
230
397
  if (height > floor.value) floor.value = height
231
398
  }
232
399
 
@@ -297,6 +464,60 @@ function showAllValues(key: string) {
297
464
  withMotion(() => (hiddenValues.value = { ...hiddenValues.value, [key]: new Set() }))
298
465
  }
299
466
 
467
+ function setHidden(key: string, next: Set<unknown>) {
468
+ withMotion(() => (hiddenValues.value = { ...hiddenValues.value, [key]: next }))
469
+ }
470
+
471
+ function labelOf(col: TableColumn<T>, value: unknown): string {
472
+ return col.valueLabel?.(value) ?? String(value ?? "—")
473
+ }
474
+
475
+ /*
476
+ * What the filter of one column offers. Counts follow the OTHER filters, the
477
+ * way a faceted search does: with "Status: open" set, the "Owner" filter
478
+ * counts open rows, which is what the user is about to narrow further.
479
+ */
480
+ function filterOptions(col: TableColumn<T>): TableFilterOption[] {
481
+ let counts: Map<unknown, number> | undefined
482
+ if (!props.load) {
483
+ const others = Object.entries(hiddenValues.value).filter(([key, set]) => key !== col.key && set.size)
484
+ const rows = others.length
485
+ ? props.rows.filter((row) => others.every(([key, hidden]) => !hidden.has(row[key])))
486
+ : props.rows
487
+ counts = countValues(rows, col.key)
488
+ }
489
+ return valuesOf(col).map((value) => ({ value, label: labelOf(col, value), count: counts?.get(value) ?? (counts ? 0 : undefined) }))
490
+ }
491
+
492
+ /** Every column with something hidden, summarised for the chips above the table. */
493
+ const activeFilters = computed(() =>
494
+ props.columns
495
+ .filter((col) => col.filterable && hiddenValues.value[col.key]?.size)
496
+ .map((col) => {
497
+ const hidden = hiddenValues.value[col.key]!
498
+ const shown = valuesOf(col).filter((v) => !hidden.has(v)).map((v) => labelOf(col, v))
499
+ return { col, text: shown.length ? summarizeShown(shown) : text.value.filterNothingShown }
500
+ }),
501
+ )
502
+
503
+ function clearAllFilters() {
504
+ withMotion(() => (hiddenValues.value = {}))
505
+ }
506
+
507
+ function filterLabels() {
508
+ const t = text.value
509
+ return {
510
+ search: t.filterSearch,
511
+ all: t.filterAll,
512
+ none: t.filterNone,
513
+ invert: t.filterInvert,
514
+ only: t.filterOnly,
515
+ onlyShown: t.filterOnlyShown,
516
+ addShown: t.filterAddShown,
517
+ noValues: t.filterNoValues,
518
+ }
519
+ }
520
+
300
521
  function toggleColumn(key: string) {
301
522
  const next = new Set(hiddenColumns.value)
302
523
  if (next.has(key)) next.delete(key)
@@ -315,23 +536,19 @@ const filteredRows = computed<T[]>(() => {
315
536
  })
316
537
 
317
538
  // We compare by raw cell value with a stable-ish coerce.
318
- const displayRows = computed<T[]>(() => {
539
+ function sortRows(rows: T[]): T[] {
319
540
  const s = activeSort.value
320
- if (!s || props.load) return filteredRows.value
541
+ if (!s || props.load) return rows
321
542
  const col = props.columns.find((c) => c.key === s.key)
322
- if (!col?.sortable) return filteredRows.value
543
+ if (!col?.sortable) return rows
323
544
  const factor = s.dir === "asc" ? 1 : -1
324
- return [...filteredRows.value].sort((a, b) => compare(a[s.key], b[s.key]) * factor)
325
- })
326
-
327
- function compare(a: unknown, b: unknown): number {
328
- if (a == null && b == null) return 0
329
- if (a == null) return -1
330
- if (b == null) return 1
331
- if (typeof a === "number" && typeof b === "number") return a - b
332
- return String(a).localeCompare(String(b), undefined, { numeric: true })
545
+ const own = col.compare
546
+ const value = col.sortValue ?? ((row: T) => row[s.key])
547
+ return [...rows].sort((a, b) => (own ? own(a, b) : compareValues(value(a), value(b))) * factor)
333
548
  }
334
549
 
550
+ const displayRows = computed<T[]>(() => sortRows(filteredRows.value))
551
+
335
552
  function toggleSort(col: TableColumn<T>) {
336
553
  if (!col.sortable) return
337
554
  const s = activeSort.value
@@ -364,6 +581,20 @@ function sortIcon(col: TableColumn<T>): string {
364
581
  * Empty for a column that does not sort, and LpContextMenu passes an empty
365
582
  * list straight through to the browser's own menu.
366
583
  */
584
+ // Opened from the header menu: the menu has to finish closing and hand focus
585
+ // back before the popover can take it, or the popover reads that focus move as
586
+ // an outside interaction and dismisses itself. So the trigger is clicked once
587
+ // the menu is gone.
588
+ function openFilter(key: string) {
589
+ const open = () => frameEl.value?.querySelector<HTMLElement>(`[data-lp-filter="${CSS.escape(key)}"]`)?.click()
590
+ const started = performance.now()
591
+ const wait = () => {
592
+ if (!document.querySelector("[data-reka-menu-content]") || performance.now() - started > 600) setTimeout(open, 30)
593
+ else requestAnimationFrame(wait)
594
+ }
595
+ requestAnimationFrame(wait)
596
+ }
597
+
367
598
  function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
368
599
  const s = activeSort.value
369
600
  const set = (next: SortState | null) => () =>
@@ -377,19 +608,19 @@ function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
377
608
  if (col.sortable) {
378
609
  items.push(
379
610
  {
380
- label: props.sortAscLabel,
611
+ label: text.value.sortAsc,
381
612
  icon: "lucide:arrow-up",
382
613
  disabled: s?.key === col.key && s.dir === "asc",
383
614
  onSelect: set({ key: col.key, dir: "asc" }),
384
615
  },
385
616
  {
386
- label: props.sortDescLabel,
617
+ label: text.value.sortDesc,
387
618
  icon: "lucide:arrow-down",
388
619
  disabled: s?.key === col.key && s.dir === "desc",
389
620
  onSelect: set({ key: col.key, dir: "desc" }),
390
621
  },
391
622
  {
392
- label: props.sortClearLabel,
623
+ label: text.value.sortClear,
393
624
  icon: "lucide:x",
394
625
  disabled: !s,
395
626
  onSelect: set(null),
@@ -397,29 +628,26 @@ function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
397
628
  )
398
629
  }
399
630
 
400
- // "Show only the mains leads" asked of the column that says which is which,
401
- // rather than of a filter bar somewhere else on the page.
631
+ // The values themselves live in the funnel's popover, where they can be
632
+ // searched, painted and narrowed to one; the menu only leads there, and
633
+ // offers the one-step way back. A second, plainer list here meant two
634
+ // places to filter that behaved differently.
402
635
  if (col.filterable) {
403
636
  const hidden = hiddenValues.value[col.key]
404
- items.push({
405
- label: props.filterLabel,
406
- icon: "lucide:filter",
407
- separatorBefore: items.length > 0,
408
- children: [
409
- ...valuesOf(col).map((value) => ({
410
- label: col.valueLabel?.(value) ?? String(value ?? "—"),
411
- checked: !isValueHidden(col.key, value),
412
- onSelect: () => toggleValue(col.key, value),
413
- })),
414
- {
415
- label: props.showAllLabel,
416
- icon: "lucide:list",
417
- separatorBefore: true,
418
- disabled: !hidden?.size,
419
- onSelect: () => showAllValues(col.key),
420
- },
421
- ],
422
- })
637
+ items.push(
638
+ {
639
+ label: text.value.filterColumn.replace("{column}", col.label),
640
+ icon: "lucide:filter",
641
+ separatorBefore: items.length > 0,
642
+ onSelect: () => openFilter(col.key),
643
+ },
644
+ {
645
+ label: text.value.showAll,
646
+ icon: "lucide:list",
647
+ disabled: !hidden?.size,
648
+ onSelect: () => showAllValues(col.key),
649
+ },
650
+ )
423
651
  }
424
652
 
425
653
  return items
@@ -432,10 +660,14 @@ function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
432
660
  */
433
661
  function tableMenu(): ContextMenuItemDef[] {
434
662
  const offered = props.columns.filter((c) => !c.alwaysVisible && c.label)
435
- if (!offered.length) return []
663
+ const exportItem: ContextMenuItemDef[] = props.exportable
664
+ ? [{ label: text.value.exportCsv, icon: "lucide:download", onSelect: () => exportCsv() }]
665
+ : []
666
+ if (!offered.length) return exportItem
436
667
  return [
668
+ ...exportItem,
437
669
  {
438
- label: props.columnsLabel,
670
+ label: text.value.columns,
439
671
  icon: "lucide:columns-3",
440
672
  children: offered.map((col) => ({
441
673
  label: col.label,
@@ -462,6 +694,13 @@ function fetchPage(delay = 0) {
462
694
  task.run((signal) => load(query, signal), {
463
695
  delay,
464
696
  apply: (result) => {
697
+ // Rows deleted from the last page leave it past the end; step back to
698
+ // the page that now exists rather than strand the user on an empty one.
699
+ const last = Math.max(1, Math.ceil(result.total / props.pageSize))
700
+ if (page.value > last) {
701
+ page.value = last
702
+ return
703
+ }
465
704
  loaded.value = result.rows
466
705
  total.value = result.total
467
706
  facets.value = result.facets ?? {}
@@ -482,13 +721,76 @@ watch([activeSort, hiddenValues], () => {
482
721
  else fetchPage(props.debounce)
483
722
  })
484
723
  watch(page, () => fetchPage())
724
+
725
+ // ── remembered state ─────────────────────────────────────────
726
+ // Read after mount, not during setup: a server render knows nothing of this
727
+ // browser's storage, and restoring earlier would make the first client render
728
+ // disagree with it.
729
+ const storageKey = computed(() => (props.persist ? `lp-table:${props.persist}` : ""))
730
+ const restored = ref(false)
731
+ onMounted(() => {
732
+ if (storageKey.value) {
733
+ let raw: string | null = null
734
+ try {
735
+ raw = localStorage.getItem(storageKey.value)
736
+ } catch {}
737
+ const state = parseTableState(raw, props.columns.map((c) => c.key))
738
+ if (state) {
739
+ if (state.sort !== undefined) {
740
+ ownSort.value = state.sort
741
+ emit("update:sort", state.sort)
742
+ }
743
+ if (state.hidden) {
744
+ hiddenValues.value = Object.fromEntries(Object.entries(state.hidden).map(([k, v]) => [k, new Set(v)]))
745
+ }
746
+ if (state.columns) hiddenColumns.value = new Set(state.columns)
747
+ }
748
+ }
749
+ restored.value = true
750
+ })
751
+ watch([activeSort, hiddenValues, hiddenColumns], () => {
752
+ if (!storageKey.value || !restored.value) return
753
+ const state = serializeTableState({
754
+ sort: activeSort.value,
755
+ hidden: Object.fromEntries(Object.entries(hiddenValues.value).map(([k, v]) => [k, [...v]])),
756
+ columns: [...hiddenColumns.value],
757
+ })
758
+ try {
759
+ localStorage.setItem(storageKey.value, state)
760
+ } catch {}
761
+ })
485
762
  watch(() => props.load, () => fetchPage(), { immediate: true })
486
763
 
487
- defineExpose({ reload: () => fetchPage() })
764
+ // ── export ───────────────────────────────────────────────────
765
+ // The columns on screen, minus the unnamed ones (actions), in the order shown.
766
+ function exportCsv(options: TableExportOptions = {}): string {
767
+ const scope = options.scope ?? "filtered"
768
+ let rows: T[]
769
+ if (scope === "all") rows = sortRows(sourceRows.value)
770
+ else if (scope === "selected") {
771
+ const keys = new Set(props.selected)
772
+ rows = sortRows(sourceRows.value).filter((row, i) => keys.has(keyFor(row, i)))
773
+ } else rows = displayRows.value
774
+ const columns = visibleColumns.value
775
+ .filter((col) => col.label)
776
+ .map((col) => ({
777
+ key: col.key,
778
+ label: col.label,
779
+ value: col.exportValue ?? ((row: T) => row[col.key]),
780
+ }))
781
+ const csv = toCsv(rows, columns, options)
782
+ if (options.download !== false) {
783
+ downloadFile(`${fileStem(options.filename ?? props.exportFilename, "table")}.csv`, csv, "text/csv;charset=utf-8")
784
+ }
785
+ return csv
786
+ }
787
+
788
+ defineExpose({ reload: () => fetchPage(), exportCsv })
488
789
 
489
790
  // ── selection ────────────────────────────────────────────────
490
791
  const selectedSet = computed(() => new Set(props.selected))
491
- const allKeys = computed(() => sourceRows.value.map((r, i) => keyFor(r, i)))
792
+ // "All" is what the user can see: rows a header filter hides are left alone.
793
+ const allKeys = computed(() => filteredRows.value.map((r, i) => keyFor(r, i)))
492
794
  const allChecked = computed(
493
795
  () => allKeys.value.length > 0 && allKeys.value.every((k) => selectedSet.value.has(k)),
494
796
  )
@@ -497,7 +799,12 @@ const someChecked = computed(
497
799
  )
498
800
 
499
801
  function toggleAll(checked: boolean) {
500
- emit("update:selected", checked ? [...allKeys.value] : [])
802
+ const next = new Set(selectedSet.value)
803
+ for (const key of allKeys.value) {
804
+ if (checked) next.add(key)
805
+ else next.delete(key)
806
+ }
807
+ emit("update:selected", [...next])
501
808
  }
502
809
 
503
810
  function toggleRow(key: RowKey, checked: boolean) {
@@ -507,7 +814,111 @@ function toggleRow(key: RowKey, checked: boolean) {
507
814
  emit("update:selected", [...next])
508
815
  }
509
816
 
510
- const colSpan = computed(() => props.columns.length + (props.selectable ? 1 : 0))
817
+ // Press a row's tick and sweep down the column to tick (or clear) the run,
818
+ // as in a file manager; Ctrl+A inside the table ticks every row shown.
819
+ const keyByText = computed(
820
+ () => new Map(displayRows.value.map((row, index) => [String(keyFor(row, index)), keyFor(row, index)])),
821
+ )
822
+ const paint = usePaintSelect<RowKey>({
823
+ root: frameEl,
824
+ enabled: () => !!props.selectable,
825
+ selection: () => props.selected,
826
+ set: (keys) => emit("update:selected", keys),
827
+ resolve: (raw) => keyByText.value.get(raw),
828
+ selectAll: () => [...new Set([...props.selected, ...allKeys.value])],
829
+ help: () => ({ group: text.value.shortcutsGroup, label: text.value.selectAll }),
830
+ })
831
+
832
+ const holdMode = computed(() => props.selectable === "hold")
833
+ /*
834
+ * In hold mode the tick column is there all along, collapsed to nothing, and
835
+ * opens by easing its width: the other columns then slide over instead of
836
+ * jumping when it appears.
837
+ */
838
+ const ticksShown = computed(
839
+ () => props.selectable === true || props.selected.length > 0 || paint.painting.value,
840
+ )
841
+
842
+ function clearSelection() {
843
+ emit("update:selected", [])
844
+ if (props.allMatching) emit("update:allMatching", false)
845
+ }
846
+
847
+ /*
848
+ * A long press on a row starts selection in hold mode. Presses that begin on
849
+ * something the row holds that answers clicks of its own (a link, a button, a
850
+ * field) are left to it.
851
+ */
852
+ const OWN_CLICKS = "a,button,input,select,textarea,label,[role=button],[role=checkbox],[contenteditable=true]"
853
+ function onRowPointerDown(event: PointerEvent, key: RowKey) {
854
+ if (!holdMode.value) return
855
+ if ((event.target as Element | null)?.closest(OWN_CLICKS)) return
856
+ paint.onPointerDown(event, key, { hold: 420 })
857
+ }
858
+
859
+ // Once something is selected, a click on a row adds or removes it, as in a
860
+ // phone's selection mode; with nothing selected it is the row's click again.
861
+ function onRowClick(row: T, key: RowKey) {
862
+ if (holdMode.value && props.selected.length) toggleRow(key, !selectedSet.value.has(key))
863
+ else emit("rowClick", row)
864
+ }
865
+
866
+ useHotkeys(
867
+ () =>
868
+ props.selectable && props.selected.length
869
+ ? [
870
+ {
871
+ key: "escape",
872
+ help: { group: text.value.shortcutsGroup, label: text.value.clearSelection },
873
+ handler: () => clearSelection(),
874
+ },
875
+ ]
876
+ : [],
877
+ { scope: frameEl },
878
+ )
879
+
880
+ // ── the bar above the table ──────────────────────────────────
881
+ const pageKeys = computed(() => displayRows.value.map((row, i) => keyFor(row, i)))
882
+ const pageAllSelected = computed(
883
+ () => pageKeys.value.length > 0 && pageKeys.value.every((k) => selectedSet.value.has(k)),
884
+ )
885
+ // "Select all" beyond the page is a separate, explicit step: the header tick
886
+ // only ever means the rows the user can see.
887
+ watch(pageAllSelected, (all) => {
888
+ if (!all && props.allMatching) emit("update:allMatching", false)
889
+ })
890
+ /*
891
+ * One status line that rolls between its states ("60 rows", "3 selected",
892
+ * "All 40 are selected") instead of swapping text in a frame; the controls
893
+ * beside it fade in and out, and the group eases its width so they slide.
894
+ */
895
+ const statusText = computed(() => {
896
+ if (!(props.selectable && props.selected.length)) return countText.value
897
+ return props.allMatching
898
+ ? text.value.allMatchingSelected.replace("{total}", String(total.value))
899
+ : text.value.selectedCount.replace("{count}", String(props.selected.length))
900
+ })
901
+
902
+ const countText = computed(() => {
903
+ const t = text.value
904
+ if (props.load) return t.rowsTotal.replace("{total}", String(total.value))
905
+ if (displayRows.value.length !== props.rows.length) {
906
+ return t.rowCount.replace("{shown}", String(displayRows.value.length)).replace("{total}", String(props.rows.length))
907
+ }
908
+ return t.rowsTotal.replace("{total}", String(props.rows.length))
909
+ })
910
+ // A selectable table keeps the bar at all times (the row count until something
911
+ // is selected): ticking the first row must not push the table down.
912
+ const showBar = computed(() => props.summary || !!props.selectable || activeFilters.value.length > 0)
913
+ const filteredOut = computed(() => {
914
+ if (loading.value || failed.value || displayRows.value.length) return false
915
+ if (props.load) return Object.values(hiddenValues.value).some((set) => set.size)
916
+ return props.rows.length > 0
917
+ })
918
+
919
+ const cellY = computed(() => (props.density === "compact" ? "py-1.5" : "py-3"))
920
+
921
+ const colSpan = computed(() => visibleColumns.value.length + (props.selectable ? 1 : 0))
511
922
 
512
923
  /*
513
924
  * Keyboard navigation over the rows. A table is one Tab stop, not one per row:
@@ -592,7 +1003,98 @@ const barInsetTop = computed(() =>
592
1003
  <template>
593
1004
  <!-- No top fade: a sticky header sits in the masked band and would be dimmed
594
1005
  by it. The bottom edge is left crisp with it for symmetry. -->
595
- <div class="flex flex-col gap-3" :class="rootClass">
1006
+ <div ref="frameEl" class="flex flex-col gap-3" :class="rootClass">
1007
+ <div v-if="exportable" class="flex justify-end">
1008
+ <LpButton variant="ghost" size="sm" @click="exportCsv()">
1009
+ <LpIcon name="lucide:download" :size="14" />
1010
+ {{ text.exportCsv }}
1011
+ </LpButton>
1012
+ </div>
1013
+ <!-- What the table is showing right now: the filters in force, each one
1014
+ a chip that clears it, the row count, and what is selected with room
1015
+ for the consumer's bulk actions. Eased in and out (LpShift), so the
1016
+ table below glides rather than jumps when the first filter is set. -->
1017
+ <!-- The gap below the bar lives inside the eased box (pb-3), cancelled
1018
+ outside it (-mb-3): an empty box must not leave the root's gap behind. -->
1019
+ <LpShift class="-mb-3">
1020
+ <div v-if="showBar" class="flex min-h-8 items-center gap-x-3 pb-3 text-xs">
1021
+ <div v-if="activeFilters.length" class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
1022
+ <span
1023
+ v-for="f in activeFilters"
1024
+ :key="f.col.key"
1025
+ class="inline-flex max-w-72 animate-[chip-in_var(--duration-fast)_var(--ease-emphasized)] items-center gap-1 rounded-pill bg-brand-soft py-0.5 pr-1 pl-2.5 text-ink motion-reduce:animate-none"
1026
+ >
1027
+ <span class="truncate"><span class="text-muted">{{ f.col.label }}:</span> {{ f.text }}</span>
1028
+ <button
1029
+ type="button"
1030
+ class="grid size-4 shrink-0 place-items-center rounded-pill text-muted outline-none transition-colors duration-fast hover:bg-ink/10 hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1031
+ :aria-label="text.clearFilter.replace('{column}', f.col.label)"
1032
+ @click="showAllValues(f.col.key)"
1033
+ >
1034
+ <LpIcon name="lucide:x" :size="12" />
1035
+ </button>
1036
+ </span>
1037
+ <button
1038
+ v-if="activeFilters.length > 1"
1039
+ type="button"
1040
+ class="rounded-control px-1.5 py-0.5 font-medium text-muted outline-none transition-colors duration-fast hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1041
+ @click="clearAllFilters"
1042
+ >
1043
+ {{ text.clearFilters }}
1044
+ </button>
1045
+ </div>
1046
+ <!-- One line, always: the status and its controls never wrap, so the bar
1047
+ keeps its height and the table under it stays put; the group eases
1048
+ its width as controls come and go. -->
1049
+ <LpShift axis="width" class="ml-auto shrink-0">
1050
+ <div class="flex items-center gap-2 whitespace-nowrap text-muted">
1051
+ <LpRollingText
1052
+ :value="statusText"
1053
+ align="end"
1054
+ class="tabular-nums"
1055
+ :class="selectable && selected.length ? 'font-medium text-ink' : ''"
1056
+ aria-live="polite"
1057
+ />
1058
+ <Transition
1059
+ enter-active-class="transition-opacity duration-fast motion-reduce:transition-none"
1060
+ leave-active-class="transition-opacity duration-fast motion-reduce:transition-none"
1061
+ enter-from-class="opacity-0"
1062
+ leave-to-class="opacity-0"
1063
+ >
1064
+ <span v-if="selectable && selected.length" class="flex items-center gap-2">
1065
+ <Transition
1066
+ enter-active-class="transition-opacity duration-fast motion-reduce:transition-none"
1067
+ leave-active-class="transition-opacity duration-fast motion-reduce:transition-none"
1068
+ enter-from-class="opacity-0"
1069
+ leave-to-class="opacity-0"
1070
+ >
1071
+ <span v-if="load && pageAllSelected && !allMatching && total > pageKeys.length" class="flex items-center gap-2">
1072
+ <!-- The sentence is context, the button is the action: on a narrow
1073
+ screen only the action stays, so the line still fits. -->
1074
+ <span class="hidden sm:inline">{{ text.pageSelected.replace("{count}", String(pageKeys.length)) }}</span>
1075
+ <button
1076
+ type="button"
1077
+ class="rounded-control px-1.5 py-0.5 font-medium text-brand outline-none transition-colors duration-fast hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring"
1078
+ @click="emit('update:allMatching', true)"
1079
+ >
1080
+ {{ text.selectAllMatching.replace("{total}", String(total)) }}
1081
+ </button>
1082
+ </span>
1083
+ </Transition>
1084
+ <button
1085
+ type="button"
1086
+ class="rounded-control px-1.5 py-0.5 font-medium outline-none transition-colors duration-fast hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1087
+ @click="clearSelection"
1088
+ >
1089
+ {{ text.clearSelection }}
1090
+ </button>
1091
+ <slot name="bulk" :selected="selected" :all-matching="allMatching" :clear="clearSelection" />
1092
+ </span>
1093
+ </Transition>
1094
+ </div>
1095
+ </LpShift>
1096
+ </div>
1097
+ </LpShift>
596
1098
  <LpScrollArea
597
1099
  v-bind="scrollAttrs"
598
1100
  :fade="false"
@@ -604,7 +1106,7 @@ const barInsetTop = computed(() =>
604
1106
  reaches: rows and headers carry their own and take the event first, so
605
1107
  this is the fallback rather than a competitor. -->
606
1108
  <LpContextMenu :items="contextMenu" always>
607
- <table ref="tableEl" class="w-full border-collapse text-sm">
1109
+ <table class="w-full border-collapse text-sm">
608
1110
  <!-- The background belongs on `thead` itself, not only on its row:
609
1111
  `border-collapse` collapses the borders, and with a sticky header the
610
1112
  rows show through that seam — a band of body text rides across the
@@ -614,19 +1116,21 @@ const barInsetTop = computed(() =>
614
1116
  :class="stickyHeader ? 'sticky top-0 z-10 bg-surface-soft' : ''"
615
1117
  >
616
1118
  <tr class="border-b border-line bg-surface-soft">
617
- <th v-if="selectable" class="w-px px-4 py-3">
618
- <LpCheckbox
619
- :model-value="allChecked"
620
- :indeterminate="someChecked"
621
- @update:model-value="toggleAll"
622
- />
1119
+ <th v-if="selectable" class="w-px p-0" :class="cellY">
1120
+ <div class="lp-tick lp-tick-open" :class="ticksShown ? '' : 'lp-tick-hidden'" :inert="!ticksShown">
1121
+ <LpCheckbox
1122
+ :model-value="allChecked"
1123
+ :indeterminate="someChecked"
1124
+ @update:model-value="toggleAll"
1125
+ />
1126
+ </div>
623
1127
  </th>
624
1128
  <th
625
1129
  v-for="col in visibleColumns"
626
1130
  :key="col.key"
627
1131
  :style="col.width ? { width: col.width } : undefined"
628
- class="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
629
- :class="alignClass(col.align)"
1132
+ class="px-4 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
1133
+ :class="[alignClass(col.align), cellY]"
630
1134
  :aria-sort="
631
1135
  activeSort?.key === col.key
632
1136
  ? activeSort.dir === 'asc'
@@ -654,14 +1158,7 @@ const barInsetTop = computed(() =>
654
1158
  >
655
1159
  {{ col.label }}
656
1160
  <LpIcon
657
- v-if="!col.sortable"
658
- name="lucide:filter"
659
- :size="12"
660
- class="shrink-0"
661
- :class="hiddenValues[col.key]?.size ? 'text-brand opacity-100' : 'opacity-40 group-hover/sort:opacity-70'"
662
- />
663
- <LpIcon
664
- v-else
1161
+ v-if="col.sortable"
665
1162
  :name="sortIcon(col)"
666
1163
  :size="13"
667
1164
  class="shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]"
@@ -674,6 +1171,30 @@ const barInsetTop = computed(() =>
674
1171
  </button>
675
1172
  </LpContextMenu>
676
1173
  <template v-else>{{ col.label }}</template>
1174
+ <LpPopover
1175
+ v-if="col.filterable"
1176
+ align="start"
1177
+ panel-class="w-72"
1178
+ :aria-label="text.filterColumn.replace('{column}', col.label)"
1179
+ >
1180
+ <template #trigger>
1181
+ <button
1182
+ type="button"
1183
+ class="ml-0.5 inline-grid size-5 place-items-center rounded align-middle outline-none transition-colors duration-fast hover:bg-surface-raised hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1184
+ :class="hiddenValues[col.key]?.size ? 'text-brand' : 'opacity-60'"
1185
+ :aria-label="text.filterColumn.replace('{column}', col.label)"
1186
+ :data-lp-filter="col.key"
1187
+ >
1188
+ <LpIcon :name="hiddenValues[col.key]?.size ? 'lucide:filter-x' : 'lucide:filter'" :size="12" />
1189
+ </button>
1190
+ </template>
1191
+ <LpTableFilter
1192
+ :options="filterOptions(col)"
1193
+ :hidden="hiddenValues[col.key] ?? new Set()"
1194
+ :labels="filterLabels()"
1195
+ @update:hidden="(next) => setHidden(col.key, next)"
1196
+ />
1197
+ </LpPopover>
677
1198
  </th>
678
1199
  </tr>
679
1200
  </thead>
@@ -698,12 +1219,15 @@ const barInsetTop = computed(() =>
698
1219
  <tr v-if="displayRows.length === 0" key="lp-table-empty" class="lp-row-empty">
699
1220
  <td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
700
1221
  <LpIcon
701
- :name="loading ? 'lucide:loader-circle' : failed ? 'lucide:circle-alert' : emptyIcon"
1222
+ :name="loading ? 'lucide:loader-circle' : failed ? 'lucide:circle-alert' : filteredOut ? 'lucide:filter-x' : emptyIcon"
702
1223
  :size="22"
703
1224
  class="mx-auto mb-2 opacity-60"
704
1225
  :class="loading ? 'animate-spin' : ''"
705
1226
  />
706
- <div>{{ loading ? loadingLabel : failed ? errorLabel : emptyLabel }}</div>
1227
+ <div>{{ loading ? text.loading : failed ? text.error : filteredOut ? text.noMatches : text.empty }}</div>
1228
+ <LpButton v-if="filteredOut" variant="outline" size="sm" class="mt-3" @click="clearAllFilters">
1229
+ {{ text.resetFilters }}
1230
+ </LpButton>
707
1231
  </td>
708
1232
  </tr>
709
1233
  <!-- A plain <tr>, because TransitionGroup animates element children and
@@ -716,20 +1240,30 @@ const barInsetTop = computed(() =>
716
1240
  class="group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
717
1241
  :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
718
1242
  v-bind="roving.itemProps(String(keyFor(row, index)))"
719
- @click="emit('rowClick', row)"
1243
+ :data-paint-key="selectable ? String(keyFor(row, index)) : undefined"
1244
+ @click="onRowClick(row, keyFor(row, index))"
1245
+ @pointerdown="onRowPointerDown($event, keyFor(row, index))"
720
1246
  @keydown="onRowKeydown"
721
1247
  >
722
- <td v-if="selectable" class="w-px px-4 py-3" @click.stop>
723
- <LpCheckbox
724
- :model-value="selectedSet.has(keyFor(row, index))"
725
- @update:model-value="(v) => toggleRow(keyFor(row, index), v)"
726
- />
1248
+ <td
1249
+ v-if="selectable"
1250
+ class="w-px cursor-pointer p-0 select-none"
1251
+ :class="cellY"
1252
+ @click.stop
1253
+ @pointerdown.stop="paint.onPointerDown($event, keyFor(row, index))"
1254
+ >
1255
+ <div class="lp-tick lp-tick-open" :class="ticksShown ? '' : 'lp-tick-cue'">
1256
+ <LpCheckbox
1257
+ :model-value="selectedSet.has(keyFor(row, index))"
1258
+ @update:model-value="(v) => toggleRow(keyFor(row, index), v)"
1259
+ />
1260
+ </div>
727
1261
  </td>
728
1262
  <td
729
- v-for="col in visibleColumns"
1263
+ v-for="(col, ci) in visibleColumns"
730
1264
  :key="col.key"
731
- class="px-4 py-3 text-ink"
732
- :class="alignClass(col.align)"
1265
+ class="px-4 text-ink"
1266
+ :class="[alignClass(col.align), cellY, col.nowrap ? 'whitespace-nowrap' : '']"
733
1267
  >
734
1268
  <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
735
1269
  {{ row[col.key] }}
@@ -801,12 +1335,56 @@ const barInsetTop = computed(() =>
801
1335
  * which is a moment slower and stays a table the whole time.
802
1336
  */
803
1337
 
1338
+ /*
1339
+ * The tick column of a hold-to-select table keeps its width and only hides its
1340
+ * boxes until they mean something, which is the desktop pattern Material
1341
+ * describes: a row's box shows while the row is hovered, and every box shows
1342
+ * once anything is selected. An earlier version collapsed the column to nothing
1343
+ * and floated the hover box over the first cell, where it sat on the text.
1344
+ */
1345
+ .lp-tick {
1346
+ display: flex;
1347
+ align-items: center;
1348
+ max-width: 0;
1349
+ padding-inline: 0;
1350
+ overflow: hidden;
1351
+ opacity: 0;
1352
+ transition:
1353
+ max-width var(--duration-medium) var(--ease-emphasized),
1354
+ padding var(--duration-medium) var(--ease-emphasized),
1355
+ opacity var(--duration-fast) ease;
1356
+ }
1357
+
1358
+ .lp-tick-open {
1359
+ max-width: 3.25rem;
1360
+ padding-inline: 1rem;
1361
+ opacity: 1;
1362
+ }
1363
+
1364
+ .lp-tick-hidden,
1365
+ .lp-tick-cue {
1366
+ opacity: 0;
1367
+ }
1368
+
1369
+ tr:hover .lp-tick-cue,
1370
+ .lp-tick-cue:focus-within {
1371
+ opacity: 1;
1372
+ }
1373
+
804
1374
  /* A filter that redraws the list is information, not decoration: with motion
805
1375
  turned down the rows change without animating rather than not changing. */
1376
+ /* Only where there is a hover to reveal it; a finger long-presses instead. */
1377
+ @media (hover: none) {
1378
+ tr:hover .lp-tick-cue {
1379
+ opacity: 0;
1380
+ }
1381
+ }
1382
+
806
1383
  @media (prefers-reduced-motion: reduce) {
807
1384
  .lp-row-enter-active,
808
1385
  .lp-row-leave-active,
809
- .lp-row-move {
1386
+ .lp-row-move,
1387
+ .lp-tick {
810
1388
  transition: none;
811
1389
  }
812
1390
  }