@leavepulse/ui 0.44.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -3
- package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-HzAA4gri.css} +21 -21
- package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
- package/dist/assets/{LpShortcutsDialog-ChUyU7oV.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
- package/dist/assets/{LpTable-DDQNdf86.css → LpTable-CPwWxTJS.css} +49 -9
- package/dist/chunks/{ContextMenuItems-CrfeNPWT.js → ContextMenuItems-BtKU7074.js} +2 -1
- package/dist/chunks/{LayoutCanvas-D-jPOAvg.js → LayoutCanvas-BkaGXTjx.js} +1 -1
- package/dist/chunks/{LayoutNode-D8Xy9N-o.js → LayoutNode-B3_9E1Xa.js} +9 -5
- package/dist/chunks/{LpActionBar-BbwCF6l5.js → LpActionBar-CnfFVIwC.js} +3 -3
- package/dist/chunks/{LpAddressInput-BrOqkZqg.js → LpAddressInput-CJOYGZlI.js} +7 -7
- package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
- package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-B07Lf48e.js} +4 -4
- package/dist/chunks/{LpAutocomplete-DxV3Y9h4.js → LpAutocomplete-C8IgSMsi.js} +2 -1
- package/dist/chunks/{LpAvatar-ClBbEYZN.js → LpAvatar-C1XStdOo.js} +3 -3
- package/dist/chunks/{LpBreadcrumbs-B8IJgFIc.js → LpBreadcrumbs-CQJd73Gm.js} +1 -1
- package/dist/chunks/{LpButton-kqJBHIIb.js → LpButton-CKSwC6Mu.js} +1 -1
- package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
- package/dist/chunks/{LpCard-CpRByR0q.js → LpCard-C2IvucmX.js} +1 -1
- package/dist/chunks/{LpCheckbox-DpqchzH4.js → LpCheckbox-Bkj13CbW.js} +1 -1
- package/dist/chunks/{LpCodeBlock-DeHJPq7p.js → LpCodeBlock-ca7ANFyW.js} +1 -1
- package/dist/chunks/{LpColorPicker-Bhf1qxhB.js → LpColorPicker-zz4VGkG4.js} +46 -20
- package/dist/chunks/{LpConfirmDialog-CIUOSnWo.js → LpConfirmDialog-BTVAR4tF.js} +3 -3
- package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-Ci_uCHgQ.js} +1 -1
- package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
- package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-BrdFtSTy.js} +16 -10
- package/dist/chunks/LpDateRangePicker-Drd7EbIz.js +210 -0
- package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
- package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-CfRhHwmE.js} +3 -3
- package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
- package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
- package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
- package/dist/chunks/{LpFileTree-CSI_5SK2.js → LpFileTree-CNurOvkV.js} +3 -3
- package/dist/chunks/{LpFileTreeNode-B4MiSQX2.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
- package/dist/chunks/{LpFormField-CIk5qosh.js → LpFormField-CtVYj6tO.js} +29 -15
- package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
- package/dist/chunks/{LpInput-C0e8y8_d.js → LpInput-D-YGQbEv.js} +39 -16
- package/dist/chunks/{LpLightbox-Mr0W_6HK.js → LpLightbox-DSDahF23.js} +1 -1
- package/dist/chunks/{LpLogViewer-YBOaFnc_.js → LpLogViewer-KypbgOyo.js} +252 -58
- package/dist/chunks/{LpMapPicker-NUzS9e37.js → LpMapPicker-Cc84LaM3.js} +1 -1
- package/dist/chunks/{LpMediaGallery-DfJmMW0I.js → LpMediaGallery-YKr-cQPN.js} +2 -2
- package/dist/chunks/{LpModal-DPwGKBPv.js → LpModal-BzTVeNqe.js} +3 -3
- package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-CjABSGCu.js} +14 -8
- package/dist/chunks/{LpNotificationBell-t5yn-siX.js → LpNotificationBell-CCwyJP5C.js} +26 -14
- package/dist/chunks/{LpNumberField-DWnQKktS.js → LpNumberField-B77O-z28.js} +1 -1
- package/dist/chunks/{LpPagination-DFngX7Xo.js → LpPagination-BcDlo3r_.js} +1 -1
- package/dist/chunks/{LpPasswordInput-CWVDuBJ5.js → LpPasswordInput-eKzQhbQP.js} +2 -2
- package/dist/chunks/{LpPhoneInput-CD-ZTeSd.js → LpPhoneInput-BCbrrhgE.js} +126 -23
- package/dist/chunks/{LpPie-BLco31Nx.js → LpPie-BEJ2SfI6.js} +9 -10
- package/dist/chunks/{LpPopover-p_e_shN3.js → LpPopover-DCKeqiVn.js} +1 -0
- package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
- package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
- package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
- package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
- package/dist/chunks/{LpSegmented-Dejd7cIx.js → LpSegmented-C09EEam2.js} +1 -1
- package/dist/chunks/{LpSelect-DrRD9I7L.js → LpSelect-DNIL6MB9.js} +20 -23
- package/dist/chunks/{LpSidebar-BgwUNWpJ.js → LpSidebar-Bwws_X29.js} +3 -3
- package/dist/chunks/{LpSidebarNav-Bqxz8aWT.js → LpSidebarNav-kx-LdqNU.js} +3 -3
- package/dist/chunks/{LpStat-dXtyrdX9.js → LpStat-B-7udBRP.js} +1 -1
- package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
- package/dist/chunks/{LpSwitch-B1aLKulj.js → LpSwitch-OrcOJX9m.js} +1 -1
- package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
- package/dist/chunks/{LpTabs-_qMVaZJ6.js → LpTabs-29iW1XOx.js} +1 -1
- package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
- package/dist/chunks/{LpTextarea-yP8hGedR.js → LpTextarea-uuScWo6-.js} +1 -1
- package/dist/chunks/{LpThemeProvider-Wjm7Tuit.js → LpThemeProvider-Be4r2TQX.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-CKLc10KZ.js → LpThemeSwitcher-B4aVPbNC.js} +19 -22
- package/dist/chunks/{LpTimeField-C5HRgS5H.js → LpTimeField-Cqe7fXnh.js} +1 -1
- package/dist/chunks/{LpToaster--oT6lfhL.js → LpToaster-DTtWuKvq.js} +76 -9
- package/dist/chunks/{LpTooltip-CBdzeKNf.js → LpTooltip-CXVKcOXT.js} +3 -2
- package/dist/chunks/{LpTopologyCanvas-DocsLal3.js → LpTopologyCanvas-6ozNrQKh.js} +2 -1
- package/dist/chunks/{LpUptimeBar-DLtxOMRy.js → LpUptimeBar-ugcv25xj.js} +2 -2
- package/dist/chunks/icons-CkA0njzm.js +43 -0
- package/dist/chunks/{useTheme-C8JHUlPM.js → useTheme-DJm1JSsY.js} +47 -50
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpButton.vue.d.ts +2 -2
- package/dist/components/LpButton.vue.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.d.ts +16 -1
- package/dist/components/LpChart.vue.js +63 -5
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.d.ts +2 -2
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +6 -0
- package/dist/components/LpCommandPalette.vue.js +62 -11
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
- package/dist/components/LpDateRangePicker.vue.js +4 -0
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.d.ts +8 -0
- package/dist/components/LpFieldNotice.vue.js +4 -0
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.d.ts +4 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpIcon.vue.d.ts +0 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +2 -2
- package/dist/components/LpInput.vue.d.ts +19 -0
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.d.ts +52 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.d.ts +13 -0
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpPopover.vue.js +1 -1
- package/dist/components/LpProgress.vue.d.ts +1 -1
- package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
- package/dist/components/LpRangeCalendar.vue.js +4 -0
- package/dist/components/LpRollingText.vue.d.ts +1 -1
- package/dist/components/LpRollingText.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.d.ts +2 -2
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +6 -0
- package/dist/components/LpShortcutsDialog.vue.js +56 -14
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTable.vue.d.ts +84 -2
- package/dist/components/LpTable.vue.js +481 -107
- package/dist/components/LpTableFilter.vue.d.ts +31 -0
- package/dist/components/LpTableFilter.vue.js +4 -0
- package/dist/components/LpTabs.vue.js +1 -1
- package/dist/components/LpTagsInput.vue.d.ts +77 -0
- package/dist/components/LpTagsInput.vue.js +4 -0
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeProvider.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +2 -2
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.d.ts +2 -0
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/chart.js +3 -2
- package/dist/components/commandSearch.js +3 -0
- package/dist/components/dateRange.d.ts +43 -0
- package/dist/components/dateRange.js +76 -0
- package/dist/components/exportData.d.ts +58 -0
- package/dist/components/exportData.js +165 -0
- package/dist/components/logSearch.d.ts +18 -0
- package/dist/components/logSearch.js +36 -0
- package/dist/components/phone.d.ts +41 -0
- package/dist/components/phone.js +80 -0
- package/dist/components/tableFilter.d.ts +36 -0
- package/dist/components/tableFilter.js +84 -0
- package/dist/components/tags.d.ts +20 -0
- package/dist/components/tags.js +37 -0
- package/dist/composables/useField.d.ts +4 -0
- package/dist/composables/useFieldNotice.d.ts +17 -0
- package/dist/composables/useFieldNotice.js +28 -0
- package/dist/composables/useForm.d.ts +53 -0
- package/dist/composables/useForm.js +207 -0
- package/dist/composables/useHotkeys.d.ts +65 -5
- package/dist/composables/useHotkeys.js +170 -9
- package/dist/composables/useInputMask.d.ts +59 -0
- package/dist/composables/useInputMask.js +170 -0
- package/dist/composables/useLocale.d.ts +2 -0
- package/dist/composables/useLocale.js +9 -0
- package/dist/composables/usePaintSelect.d.ts +22 -0
- package/dist/composables/usePaintSelect.js +169 -0
- package/dist/composables/useShift.js +7 -2
- package/dist/icons-all.d.ts +1 -0
- package/dist/icons-all.js +3 -0
- package/dist/icons.d.ts +14 -1
- package/dist/index.d.ts +29 -3
- package/dist/index.js +195 -138
- package/dist/kit-icons.d.ts +2 -0
- package/dist/theme/backdrop.d.ts +12 -0
- package/dist/theme/presets.d.ts +0 -2
- package/package.json +13 -4
- package/src/component-names.ts +4 -0
- package/src/components/ContextMenuItems.vue +1 -1
- package/src/components/LayoutNode.vue +11 -2
- package/src/components/LpActionBar.vue +1 -1
- package/src/components/LpAutocomplete.vue +1 -0
- package/src/components/LpAvatar.vue +2 -2
- package/src/components/LpButton.vue +1 -1
- package/src/components/LpChart.vue +80 -1
- package/src/components/LpColorPicker.vue +34 -1
- package/src/components/LpCommandPalette.vue +82 -8
- package/src/components/LpContextMenu.vue +1 -1
- package/src/components/LpDatePicker.vue +11 -4
- package/src/components/LpDateRangePicker.vue +226 -0
- package/src/components/LpDrawer.vue +1 -1
- package/src/components/LpDropdownMenu.vue +1 -0
- package/src/components/LpFieldNotice.vue +65 -0
- package/src/components/LpFormField.vue +33 -4
- package/src/components/LpIcon.vue +22 -4
- package/src/components/LpInput.vue +46 -6
- package/src/components/LpLogViewer.vue +225 -22
- package/src/components/LpModal.vue +2 -2
- package/src/components/LpModalNav.vue +13 -2
- package/src/components/LpNotificationBell.vue +15 -7
- package/src/components/LpPhoneInput.vue +179 -20
- package/src/components/LpPie.vue +8 -5
- package/src/components/LpPopover.vue +1 -0
- package/src/components/LpRangeCalendar.vue +365 -0
- package/src/components/LpRollingText.vue +3 -0
- package/src/components/LpScrollArea.vue +22 -2
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpShortcutsDialog.vue +77 -8
- package/src/components/LpStepper.vue +23 -10
- package/src/components/LpSwitch.vue +1 -1
- package/src/components/LpTable.vue +584 -65
- package/src/components/LpTableFilter.vue +175 -0
- package/src/components/LpTagsInput.vue +364 -0
- package/src/components/LpThemeSwitcher.vue +19 -23
- package/src/components/LpToaster.vue +91 -5
- package/src/components/LpTooltip.vue +9 -2
- package/src/components/LpTopologyCanvas.vue +7 -1
- package/src/components/chart.ts +5 -3
- package/src/components/commandSearch.ts +6 -0
- package/src/components/dateRange.ts +134 -0
- package/src/components/exportData.ts +228 -0
- package/src/components/logSearch.ts +46 -0
- package/src/components/phone.ts +140 -0
- package/src/components/tableFilter.ts +122 -0
- package/src/components/tags.ts +62 -0
- package/src/composables/useField.ts +4 -0
- package/src/composables/useFieldNotice.ts +59 -0
- package/src/composables/useForm.ts +314 -0
- package/src/composables/useHotkeys.ts +269 -13
- package/src/composables/useInputMask.ts +254 -0
- package/src/composables/useLocale.ts +14 -0
- package/src/composables/usePaintSelect.ts +243 -0
- package/src/composables/useShift.ts +13 -2
- package/src/iconSubset.d.mts +10 -0
- package/src/iconSubset.mjs +51 -0
- package/src/icons-all.ts +10 -0
- package/src/icons.ts +59 -8
- package/src/index.ts +37 -3
- package/src/kit-icons.ts +5 -0
- package/src/nuxt.ts +30 -2
- package/src/runtime/theme.plugin.ts +2 -2
- package/src/theme/backdrop.ts +69 -0
- package/src/theme/presets.ts +6 -64
- package/src/tokens/tokens.css +53 -4
|
@@ -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
|
-
/**
|
|
118
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
539
|
+
function sortRows(rows: T[]): T[] {
|
|
369
540
|
const s = activeSort.value
|
|
370
|
-
if (!s || props.load) return
|
|
541
|
+
if (!s || props.load) return rows
|
|
371
542
|
const col = props.columns.find((c) => c.key === s.key)
|
|
372
|
-
if (!col?.sortable) return
|
|
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 [...
|
|
377
|
-
|
|
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
|
-
//
|
|
447
|
-
//
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
677
|
-
<
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
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
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
|
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
|
}
|