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