@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
@@ -5,22 +5,37 @@ 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"
16
22
  import { useLabels } from "../composables/useLpConfig"
17
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"
18
27
 
19
28
  export interface TableColumn<Row> {
20
29
  key: string
21
30
  label: string
22
31
  align?: "left" | "right" | "center"
23
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
24
39
  /** Allow clicking the header to sort by this column. */
25
40
  sortable?: boolean
26
41
  /** The value this column sorts by, when the cell holds something else. */
@@ -39,6 +54,8 @@ export interface TableColumn<Row> {
39
54
  * vocabulary; the menu should say what the column itself says.
40
55
  */
41
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
42
59
  /** Keep this column out of the show/hide list — an actions column has no
43
60
  * name to offer and nothing to gain from being hidden. */
44
61
  alwaysVisible?: boolean
@@ -55,6 +72,42 @@ export interface TableLabels {
55
72
  filter: string
56
73
  showAll: string
57
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
58
111
  }
59
112
 
60
113
  export interface SortState {
@@ -114,8 +167,15 @@ const props = withDefaults(
114
167
  rowKey?: keyof T | ((row: T) => RowKey)
115
168
  emptyLabel?: string
116
169
  emptyIcon?: string
117
- /** Render a leading checkbox column; selection binds via v-model:selected. */
118
- 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"
119
179
  /** Selected row keys (v-model:selected). */
120
180
  selected?: RowKey[]
121
181
  /** Active sort (v-model:sort). When set, the parent owns ordering (server
@@ -135,8 +195,30 @@ const props = withDefaults(
135
195
  filterLabel?: string
136
196
  showAllLabel?: string
137
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
138
202
  /** All of the wording above in one object, and the config-level scope "table". */
139
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
140
222
  }>(),
141
223
  {
142
224
  rows: () => [],
@@ -145,6 +227,9 @@ const props = withDefaults(
145
227
  emptyIcon: "lucide:inbox",
146
228
  selectable: false,
147
229
  selected: () => [],
230
+ density: "comfortable",
231
+ summary: false,
232
+ allMatching: false,
148
233
  // undefined, not null: null is a parent saying "unsorted", undefined is no
149
234
  // parent at all, and only the latter lets the table keep its own sort.
150
235
  sort: undefined,
@@ -163,6 +248,30 @@ const labelSet = useLabels<TableLabels>(
163
248
  filter: "Show only",
164
249
  showAll: "Show all",
165
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.",
166
275
  },
167
276
  () => props.labels,
168
277
  )
@@ -197,6 +306,7 @@ const scrollAttrs = computed(() => {
197
306
  const emit = defineEmits<{
198
307
  (e: "update:selected", value: RowKey[]): void
199
308
  (e: "update:sort", value: SortState | null): void
309
+ (e: "update:allMatching", value: boolean): void
200
310
  (e: "rowClick", row: T): void
201
311
  }>()
202
312
 
@@ -241,6 +351,8 @@ function onRowContextMenu(row: T) {
241
351
  // Row items first — they are what the pointer is actually on — then the
242
352
  // table-wide ones under a rule, when the row has any of its own.
243
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 []
244
356
  const table = tableMenu()
245
357
  const row = menuRow.value ? (props.rowMenu?.(menuRow.value) ?? []) : []
246
358
  if (!row.length) return table
@@ -272,11 +384,16 @@ const sourceIndex = computed(() => new Map(sourceRows.value.map((row, i) => [row
272
384
 
273
385
  // The tallest page shown so far, held as a floor: the last page is usually
274
386
  // short, and without it the pager below jumps up the moment you reach it.
275
- 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)
276
393
  const floor = ref(0)
277
394
  async function holdHeight() {
278
395
  await nextTick()
279
- const height = tableEl.value?.offsetHeight ?? 0
396
+ const height = frameEl.value?.querySelector("table")?.offsetHeight ?? 0
280
397
  if (height > floor.value) floor.value = height
281
398
  }
282
399
 
@@ -347,6 +464,60 @@ function showAllValues(key: string) {
347
464
  withMotion(() => (hiddenValues.value = { ...hiddenValues.value, [key]: new Set() }))
348
465
  }
349
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
+
350
521
  function toggleColumn(key: string) {
351
522
  const next = new Set(hiddenColumns.value)
352
523
  if (next.has(key)) next.delete(key)
@@ -365,18 +536,18 @@ const filteredRows = computed<T[]>(() => {
365
536
  })
366
537
 
367
538
  // We compare by raw cell value with a stable-ish coerce.
368
- const displayRows = computed<T[]>(() => {
539
+ function sortRows(rows: T[]): T[] {
369
540
  const s = activeSort.value
370
- if (!s || props.load) return filteredRows.value
541
+ if (!s || props.load) return rows
371
542
  const col = props.columns.find((c) => c.key === s.key)
372
- if (!col?.sortable) return filteredRows.value
543
+ if (!col?.sortable) return rows
373
544
  const factor = s.dir === "asc" ? 1 : -1
374
545
  const own = col.compare
375
546
  const value = col.sortValue ?? ((row: T) => row[s.key])
376
- return [...filteredRows.value].sort(
377
- (a, b) => (own ? own(a, b) : compareValues(value(a), value(b))) * factor,
378
- )
379
- })
547
+ return [...rows].sort((a, b) => (own ? own(a, b) : compareValues(value(a), value(b))) * factor)
548
+ }
549
+
550
+ const displayRows = computed<T[]>(() => sortRows(filteredRows.value))
380
551
 
381
552
  function toggleSort(col: TableColumn<T>) {
382
553
  if (!col.sortable) return
@@ -410,6 +581,20 @@ function sortIcon(col: TableColumn<T>): string {
410
581
  * Empty for a column that does not sort, and LpContextMenu passes an empty
411
582
  * list straight through to the browser's own menu.
412
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
+
413
598
  function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
414
599
  const s = activeSort.value
415
600
  const set = (next: SortState | null) => () =>
@@ -443,29 +628,26 @@ function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
443
628
  )
444
629
  }
445
630
 
446
- // "Show only the mains leads" asked of the column that says which is which,
447
- // 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.
448
635
  if (col.filterable) {
449
636
  const hidden = hiddenValues.value[col.key]
450
- items.push({
451
- label: text.value.filter,
452
- icon: "lucide:filter",
453
- separatorBefore: items.length > 0,
454
- children: [
455
- ...valuesOf(col).map((value) => ({
456
- label: col.valueLabel?.(value) ?? String(value ?? "—"),
457
- checked: !isValueHidden(col.key, value),
458
- onSelect: () => toggleValue(col.key, value),
459
- })),
460
- {
461
- label: text.value.showAll,
462
- icon: "lucide:list",
463
- separatorBefore: true,
464
- disabled: !hidden?.size,
465
- onSelect: () => showAllValues(col.key),
466
- },
467
- ],
468
- })
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
+ )
469
651
  }
470
652
 
471
653
  return items
@@ -478,8 +660,12 @@ function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
478
660
  */
479
661
  function tableMenu(): ContextMenuItemDef[] {
480
662
  const offered = props.columns.filter((c) => !c.alwaysVisible && c.label)
481
- 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
482
667
  return [
668
+ ...exportItem,
483
669
  {
484
670
  label: text.value.columns,
485
671
  icon: "lucide:columns-3",
@@ -535,9 +721,71 @@ watch([activeSort, hiddenValues], () => {
535
721
  else fetchPage(props.debounce)
536
722
  })
537
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
+ })
538
762
  watch(() => props.load, () => fetchPage(), { immediate: true })
539
763
 
540
- 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 })
541
789
 
542
790
  // ── selection ────────────────────────────────────────────────
543
791
  const selectedSet = computed(() => new Set(props.selected))
@@ -566,6 +814,110 @@ function toggleRow(key: RowKey, checked: boolean) {
566
814
  emit("update:selected", [...next])
567
815
  }
568
816
 
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
+
569
921
  const colSpan = computed(() => visibleColumns.value.length + (props.selectable ? 1 : 0))
570
922
 
571
923
  /*
@@ -651,7 +1003,98 @@ const barInsetTop = computed(() =>
651
1003
  <template>
652
1004
  <!-- No top fade: a sticky header sits in the masked band and would be dimmed
653
1005
  by it. The bottom edge is left crisp with it for symmetry. -->
654
- <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>
655
1098
  <LpScrollArea
656
1099
  v-bind="scrollAttrs"
657
1100
  :fade="false"
@@ -663,7 +1106,7 @@ const barInsetTop = computed(() =>
663
1106
  reaches: rows and headers carry their own and take the event first, so
664
1107
  this is the fallback rather than a competitor. -->
665
1108
  <LpContextMenu :items="contextMenu" always>
666
- <table ref="tableEl" class="w-full border-collapse text-sm">
1109
+ <table class="w-full border-collapse text-sm">
667
1110
  <!-- The background belongs on `thead` itself, not only on its row:
668
1111
  `border-collapse` collapses the borders, and with a sticky header the
669
1112
  rows show through that seam — a band of body text rides across the
@@ -673,19 +1116,21 @@ const barInsetTop = computed(() =>
673
1116
  :class="stickyHeader ? 'sticky top-0 z-10 bg-surface-soft' : ''"
674
1117
  >
675
1118
  <tr class="border-b border-line bg-surface-soft">
676
- <th v-if="selectable" class="w-px px-4 py-3">
677
- <LpCheckbox
678
- :model-value="allChecked"
679
- :indeterminate="someChecked"
680
- @update:model-value="toggleAll"
681
- />
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>
682
1127
  </th>
683
1128
  <th
684
1129
  v-for="col in visibleColumns"
685
1130
  :key="col.key"
686
1131
  :style="col.width ? { width: col.width } : undefined"
687
- class="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
688
- :class="alignClass(col.align)"
1132
+ class="px-4 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
1133
+ :class="[alignClass(col.align), cellY]"
689
1134
  :aria-sort="
690
1135
  activeSort?.key === col.key
691
1136
  ? activeSort.dir === 'asc'
@@ -713,14 +1158,7 @@ const barInsetTop = computed(() =>
713
1158
  >
714
1159
  {{ col.label }}
715
1160
  <LpIcon
716
- v-if="!col.sortable"
717
- name="lucide:filter"
718
- :size="12"
719
- class="shrink-0"
720
- :class="hiddenValues[col.key]?.size ? 'text-brand opacity-100' : 'opacity-40 group-hover/sort:opacity-70'"
721
- />
722
- <LpIcon
723
- v-else
1161
+ v-if="col.sortable"
724
1162
  :name="sortIcon(col)"
725
1163
  :size="13"
726
1164
  class="shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]"
@@ -733,6 +1171,30 @@ const barInsetTop = computed(() =>
733
1171
  </button>
734
1172
  </LpContextMenu>
735
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>
736
1198
  </th>
737
1199
  </tr>
738
1200
  </thead>
@@ -757,12 +1219,15 @@ const barInsetTop = computed(() =>
757
1219
  <tr v-if="displayRows.length === 0" key="lp-table-empty" class="lp-row-empty">
758
1220
  <td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
759
1221
  <LpIcon
760
- :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"
761
1223
  :size="22"
762
1224
  class="mx-auto mb-2 opacity-60"
763
1225
  :class="loading ? 'animate-spin' : ''"
764
1226
  />
765
- <div>{{ loading ? text.loading : failed ? text.error : text.empty }}</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>
766
1231
  </td>
767
1232
  </tr>
768
1233
  <!-- A plain <tr>, because TransitionGroup animates element children and
@@ -775,20 +1240,30 @@ const barInsetTop = computed(() =>
775
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"
776
1241
  :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
777
1242
  v-bind="roving.itemProps(String(keyFor(row, index)))"
778
- @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))"
779
1246
  @keydown="onRowKeydown"
780
1247
  >
781
- <td v-if="selectable" class="w-px px-4 py-3" @click.stop>
782
- <LpCheckbox
783
- :model-value="selectedSet.has(keyFor(row, index))"
784
- @update:model-value="(v) => toggleRow(keyFor(row, index), v)"
785
- />
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>
786
1261
  </td>
787
1262
  <td
788
- v-for="col in visibleColumns"
1263
+ v-for="(col, ci) in visibleColumns"
789
1264
  :key="col.key"
790
- class="px-4 py-3 text-ink"
791
- :class="alignClass(col.align)"
1265
+ class="px-4 text-ink"
1266
+ :class="[alignClass(col.align), cellY, col.nowrap ? 'whitespace-nowrap' : '']"
792
1267
  >
793
1268
  <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
794
1269
  {{ row[col.key] }}
@@ -860,12 +1335,56 @@ const barInsetTop = computed(() =>
860
1335
  * which is a moment slower and stays a table the whole time.
861
1336
  */
862
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
+
863
1374
  /* A filter that redraws the list is information, not decoration: with motion
864
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
+
865
1383
  @media (prefers-reduced-motion: reduce) {
866
1384
  .lp-row-enter-active,
867
1385
  .lp-row-leave-active,
868
- .lp-row-move {
1386
+ .lp-row-move,
1387
+ .lp-tick {
869
1388
  transition: none;
870
1389
  }
871
1390
  }