@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
|
@@ -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
|
+
}
|
|
@@ -30,11 +30,30 @@ import { topModalElement } from "../components/modalDepth"
|
|
|
30
30
|
*
|
|
31
31
|
* Modifier note: Ctrl and Meta are one modifier here, since macOS binds ⌘ where
|
|
32
32
|
* the rest bind Ctrl. Label the difference in the UI, not in the binding.
|
|
33
|
+
*
|
|
34
|
+
* Sequences ("g i", as on GitHub and Gmail) are bindings with `sequence`. A key
|
|
35
|
+
* that starts a sequence is held while the next one is awaited: if it
|
|
36
|
+
* continues a sequence, the sequence runs; if not, or after the timeout, the
|
|
37
|
+
* single-key binding on the held key (if any) runs, and the new key is handled
|
|
38
|
+
* as a fresh press. A key that starts no sequence runs at once, so sequences
|
|
39
|
+
* cost nothing to the bindings they do not collide with.
|
|
33
40
|
*/
|
|
34
41
|
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
/** One key of a sequence: a bare key ("g") or a full chord. */
|
|
43
|
+
export interface HotkeyStep {
|
|
37
44
|
key: string
|
|
45
|
+
code?: string
|
|
46
|
+
ctrl?: boolean
|
|
47
|
+
shift?: boolean
|
|
48
|
+
alt?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HotkeyBinding {
|
|
52
|
+
/**
|
|
53
|
+
* Layout-produced key, compared case-insensitively ("l", "f2", "escape").
|
|
54
|
+
* Required unless the binding is a `sequence`.
|
|
55
|
+
*/
|
|
56
|
+
key?: string
|
|
38
57
|
/**
|
|
39
58
|
* Physical key position ("KeyL", "F2", "Digit1"). Matches on its own, which
|
|
40
59
|
* is what makes non-Latin layouts work.
|
|
@@ -44,6 +63,16 @@ export interface HotkeyBinding {
|
|
|
44
63
|
ctrl?: boolean
|
|
45
64
|
shift?: boolean
|
|
46
65
|
alt?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Keys pressed one after another: `"g i"`, or steps for chords and physical
|
|
68
|
+
* positions (`[{ key: "g", code: "KeyG" }, { key: "i", code: "KeyI" }]`).
|
|
69
|
+
* The string form splits on spaces, so a Space step needs the array form.
|
|
70
|
+
*/
|
|
71
|
+
sequence?: string | (string | HotkeyStep)[]
|
|
72
|
+
/** How long a sequence waits for its next key, in ms. Default 1000. */
|
|
73
|
+
timeout?: number
|
|
74
|
+
/** Extra condition checked on every press; `false` leaves the key alone. */
|
|
75
|
+
when?: (event: KeyboardEvent) => boolean
|
|
47
76
|
/**
|
|
48
77
|
* Runs even while a text field has focus. Only for shortcuts that mean
|
|
49
78
|
* something there — Escape, and the palette the user reaches from anywhere.
|
|
@@ -82,15 +111,20 @@ export interface HotkeyHelp {
|
|
|
82
111
|
/**
|
|
83
112
|
* Written the way it reads, "+"-separated. Defaults to one built from the
|
|
84
113
|
* binding; set it where the binding is not the whole story, such as the
|
|
85
|
-
*
|
|
114
|
+
* key that needs Shift to type ("?"). Sequences read with spaces: "G I".
|
|
86
115
|
*/
|
|
87
116
|
combo?: string
|
|
88
117
|
}
|
|
89
118
|
|
|
90
119
|
export interface ShortcutEntry {
|
|
91
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Rendered one <kbd> per "+"-separated key, so write it the way it reads.
|
|
122
|
+
* Spaces separate the steps of a sequence ("G I", "Ctrl+K Ctrl+S").
|
|
123
|
+
*/
|
|
92
124
|
combo: string
|
|
93
125
|
label: string
|
|
126
|
+
/** Another mounted binding answers the same keys at the same priority. */
|
|
127
|
+
conflict?: boolean
|
|
94
128
|
}
|
|
95
129
|
|
|
96
130
|
export interface ShortcutGroup {
|
|
@@ -155,7 +189,10 @@ function isTextEntry(target: EventTarget | null): boolean {
|
|
|
155
189
|
|
|
156
190
|
const MODIFIER_KEYS = new Set(["control", "meta", "shift", "alt"])
|
|
157
191
|
|
|
158
|
-
|
|
192
|
+
type KeyLike = Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">
|
|
193
|
+
|
|
194
|
+
export function matchesHotkey(event: KeyLike, binding: HotkeyBinding | HotkeyStep): boolean {
|
|
195
|
+
if (binding.key === undefined) return false
|
|
159
196
|
const key = binding.key.toLowerCase()
|
|
160
197
|
const ctrlHeld = event.ctrlKey || event.metaKey
|
|
161
198
|
const wantsCtrl = Boolean(binding.ctrl) || key === "control" || key === "meta"
|
|
@@ -173,6 +210,31 @@ export function matchesHotkey(event: KeyboardEvent, binding: HotkeyBinding): boo
|
|
|
173
210
|
)
|
|
174
211
|
}
|
|
175
212
|
|
|
213
|
+
/** The keys a binding waits for, in order: one step for a plain binding. */
|
|
214
|
+
export function hotkeySteps(binding: HotkeyBinding): HotkeyStep[] {
|
|
215
|
+
const raw = binding.sequence
|
|
216
|
+
if (raw === undefined) {
|
|
217
|
+
if (binding.key === undefined) return []
|
|
218
|
+
const { key, code, ctrl, shift, alt } = binding
|
|
219
|
+
return [{ key, code, ctrl, shift, alt }]
|
|
220
|
+
}
|
|
221
|
+
const list = typeof raw === "string" ? raw.trim().split(/\s+/) : raw
|
|
222
|
+
return list.map((step) => (typeof step === "string" ? { key: step } : step))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Where a run of presses stands against a binding: "full" when they spell it
|
|
227
|
+
* out, "prefix" when more keys would, otherwise null.
|
|
228
|
+
*/
|
|
229
|
+
export function sequenceProgress(presses: KeyLike[], binding: HotkeyBinding): "full" | "prefix" | null {
|
|
230
|
+
const steps = hotkeySteps(binding)
|
|
231
|
+
if (!steps.length || presses.length > steps.length) return null
|
|
232
|
+
for (let i = 0; i < presses.length; i++) {
|
|
233
|
+
if (!matchesHotkey(presses[i]!, steps[i]!)) return null
|
|
234
|
+
}
|
|
235
|
+
return presses.length === steps.length ? "full" : "prefix"
|
|
236
|
+
}
|
|
237
|
+
|
|
176
238
|
/**
|
|
177
239
|
* Bind a list of hotkeys for as long as the calling component is mounted.
|
|
178
240
|
*
|
|
@@ -240,13 +302,35 @@ function insideOf(entry: MountedHotkeys, layer: Element): boolean {
|
|
|
240
302
|
return !!node && layer.contains(node)
|
|
241
303
|
}
|
|
242
304
|
|
|
243
|
-
|
|
244
|
-
|
|
305
|
+
interface Candidate {
|
|
306
|
+
binding: HotkeyBinding
|
|
307
|
+
event: KeyboardEvent
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const DEFAULT_TIMEOUT = 1000
|
|
311
|
+
const pressed: KeyLike[] = []
|
|
312
|
+
let held: Candidate[] = []
|
|
313
|
+
let holdTimer: ReturnType<typeof setTimeout> | undefined
|
|
314
|
+
const pendingKeys = ref<string[]>([])
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The keys of a sequence typed so far ("G" after the first key of "g i"),
|
|
318
|
+
* empty when nothing is pending. For a "G…" hint while the next key is awaited.
|
|
319
|
+
*/
|
|
320
|
+
export const pendingHotkey = computed(() => pendingKeys.value)
|
|
321
|
+
|
|
322
|
+
function snapshot(event: KeyboardEvent): KeyLike {
|
|
323
|
+
const { key, code, ctrlKey, metaKey, shiftKey, altKey } = event
|
|
324
|
+
return { key, code, ctrlKey, metaKey, shiftKey, altKey }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function eligible(event: KeyboardEvent): HotkeyBinding[] {
|
|
245
328
|
const inTextEntry = isTextEntry(event.target)
|
|
246
329
|
const blocker = event.key === "Escape" ? topLayer() : topModalElement()
|
|
247
330
|
const entries = [...mounted].sort(
|
|
248
331
|
(a, b) => Number(toValue(b.priority) || 0) - Number(toValue(a.priority) || 0) || b.order - a.order,
|
|
249
332
|
)
|
|
333
|
+
const out: HotkeyBinding[] = []
|
|
250
334
|
for (const entry of entries) {
|
|
251
335
|
if (toValue(entry.enabled) === false || !inScope(event.target, entry.scope)) continue
|
|
252
336
|
const covered = !!blocker && !insideOf(entry, blocker)
|
|
@@ -254,12 +338,78 @@ function onKeydown(event: KeyboardEvent) {
|
|
|
254
338
|
if (inTextEntry && !binding.allowInInput) continue
|
|
255
339
|
if (event.repeat && binding.repeat === false) continue
|
|
256
340
|
if (covered && !binding.overLayers) continue
|
|
257
|
-
if (!
|
|
258
|
-
|
|
341
|
+
if (binding.when && !binding.when(event)) continue
|
|
342
|
+
out.push(binding)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function run(candidates: Candidate[]): boolean {
|
|
349
|
+
for (const { binding, event } of candidates) {
|
|
350
|
+
if (binding.handler(event) !== false) return true
|
|
351
|
+
}
|
|
352
|
+
return false
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function resetSequence() {
|
|
356
|
+
clearTimeout(holdTimer)
|
|
357
|
+
holdTimer = undefined
|
|
358
|
+
pressed.length = 0
|
|
359
|
+
held = []
|
|
360
|
+
pendingKeys.value = []
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function flushHeld() {
|
|
364
|
+
const waiting = held
|
|
365
|
+
resetSequence()
|
|
366
|
+
run(waiting)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function onKeydown(event: KeyboardEvent) {
|
|
370
|
+
if (event.isComposing || event.defaultPrevented) return
|
|
371
|
+
if (pressed.length && MODIFIER_KEYS.has(event.key.toLowerCase())) return
|
|
372
|
+
if (pressed.length && event.key === "Escape") {
|
|
373
|
+
resetSequence()
|
|
374
|
+
}
|
|
375
|
+
const presses = [...pressed, snapshot(event)]
|
|
376
|
+
const bindings = eligible(event)
|
|
377
|
+
const full: Candidate[] = []
|
|
378
|
+
let waitFor = 0
|
|
379
|
+
for (const binding of bindings) {
|
|
380
|
+
const progress = sequenceProgress(presses, binding)
|
|
381
|
+
if (progress === "full") full.push({ binding, event })
|
|
382
|
+
else if (progress === "prefix") waitFor = Math.max(waitFor, binding.timeout ?? DEFAULT_TIMEOUT)
|
|
383
|
+
}
|
|
384
|
+
if (waitFor) {
|
|
385
|
+
clearTimeout(holdTimer)
|
|
386
|
+
pressed.push(snapshot(event))
|
|
387
|
+
held = full
|
|
388
|
+
pendingKeys.value = pressed.map(pressLabel)
|
|
389
|
+
holdTimer = setTimeout(flushHeld, waitFor)
|
|
390
|
+
event.preventDefault()
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
if (full.length) {
|
|
394
|
+
resetSequence()
|
|
395
|
+
if (run(full)) {
|
|
259
396
|
event.preventDefault()
|
|
260
397
|
return
|
|
261
398
|
}
|
|
262
399
|
}
|
|
400
|
+
if (pressed.length) {
|
|
401
|
+
flushHeld()
|
|
402
|
+
onKeydown(event)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function pressLabel(press: KeyLike): string {
|
|
407
|
+
return chordOf({
|
|
408
|
+
key: press.key,
|
|
409
|
+
ctrl: press.ctrlKey || press.metaKey,
|
|
410
|
+
alt: press.altKey,
|
|
411
|
+
shift: press.shiftKey && press.key.length !== 1,
|
|
412
|
+
})
|
|
263
413
|
}
|
|
264
414
|
|
|
265
415
|
export function useHotkeys(
|
|
@@ -280,8 +430,10 @@ export function useHotkeys(
|
|
|
280
430
|
}
|
|
281
431
|
onMounted(() => {
|
|
282
432
|
mounted.add(entry)
|
|
433
|
+
warnConflicts()
|
|
283
434
|
if (!state.listening) {
|
|
284
435
|
window.addEventListener("keydown", onKeydown)
|
|
436
|
+
window.addEventListener("blur", resetSequence)
|
|
285
437
|
state.listening = true
|
|
286
438
|
}
|
|
287
439
|
})
|
|
@@ -289,7 +441,9 @@ export function useHotkeys(
|
|
|
289
441
|
mounted.delete(entry)
|
|
290
442
|
if (!mounted.size && state.listening) {
|
|
291
443
|
window.removeEventListener("keydown", onKeydown)
|
|
444
|
+
window.removeEventListener("blur", resetSequence)
|
|
292
445
|
state.listening = false
|
|
446
|
+
resetSequence()
|
|
293
447
|
}
|
|
294
448
|
})
|
|
295
449
|
}
|
|
@@ -307,10 +461,110 @@ const KEY_NAMES: Record<string, string> = {
|
|
|
307
461
|
arrowright: "→",
|
|
308
462
|
}
|
|
309
463
|
|
|
464
|
+
function chordOf(step: HotkeyStep): string {
|
|
465
|
+
const key = step.key.toLowerCase()
|
|
466
|
+
const name = KEY_NAMES[key] ?? (key.length === 1 || /^f\d+$/.test(key) ? key.toUpperCase() : step.key)
|
|
467
|
+
return [step.ctrl && "Ctrl", step.alt && "Alt", step.shift && "Shift", name].filter(Boolean).join("+")
|
|
468
|
+
}
|
|
469
|
+
|
|
310
470
|
function comboOf(binding: HotkeyBinding): string {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
471
|
+
return hotkeySteps(binding).map(chordOf).join(" ")
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Identity of the keys a binding answers, for telling two of them apart. */
|
|
475
|
+
function signatureOf(binding: HotkeyBinding): string {
|
|
476
|
+
return hotkeySteps(binding)
|
|
477
|
+
.map((step) => {
|
|
478
|
+
const key = step.key.toLowerCase()
|
|
479
|
+
const symbol = key.length === 1 && key === key.toUpperCase()
|
|
480
|
+
const shift = step.shift === undefined && symbol ? "*" : String(Boolean(step.shift))
|
|
481
|
+
return `${Boolean(step.ctrl)}:${Boolean(step.alt)}:${shift}:${key}`
|
|
482
|
+
})
|
|
483
|
+
.join(" ")
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface HotkeyConflict {
|
|
487
|
+
/** The keys both answer, written as in the shortcut reference. */
|
|
488
|
+
combo: string
|
|
489
|
+
/** `help` labels of the clashing bindings; unlabelled ones are omitted. */
|
|
490
|
+
labels: string[]
|
|
491
|
+
priority: number
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Pure part of `hotkeyConflicts`: bindings from different `useHotkeys` calls
|
|
496
|
+
* answering the same keys at the same priority within the same scope. Within
|
|
497
|
+
* one call a repeat is deliberate (the first may decline), and a binding with
|
|
498
|
+
* `when` is conditional by design, so neither counts.
|
|
499
|
+
*/
|
|
500
|
+
export function findHotkeyConflicts(
|
|
501
|
+
groups: { bindings: HotkeyBinding[]; priority: number; scope: unknown }[],
|
|
502
|
+
): HotkeyConflict[] {
|
|
503
|
+
const scopeIds = new Map<unknown, number>()
|
|
504
|
+
const seen = new Map<string, { combo: string; labels: string[]; owners: Set<number>; priority: number }>()
|
|
505
|
+
groups.forEach((group, owner) => {
|
|
506
|
+
if (!scopeIds.has(group.scope)) scopeIds.set(group.scope, scopeIds.size)
|
|
507
|
+
for (const binding of group.bindings) {
|
|
508
|
+
if (binding.when || !hotkeySteps(binding).length) continue
|
|
509
|
+
const id = `${group.priority}|${scopeIds.get(group.scope)}|${signatureOf(binding)}`
|
|
510
|
+
const slot = seen.get(id) ?? {
|
|
511
|
+
combo: binding.help?.combo ?? comboOf(binding),
|
|
512
|
+
labels: [],
|
|
513
|
+
owners: new Set<number>(),
|
|
514
|
+
priority: group.priority,
|
|
515
|
+
}
|
|
516
|
+
slot.owners.add(owner)
|
|
517
|
+
if (binding.help && !slot.labels.includes(binding.help.label)) slot.labels.push(binding.help.label)
|
|
518
|
+
seen.set(id, slot)
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
return [...seen.values()]
|
|
522
|
+
.filter((slot) => slot.owners.size > 1)
|
|
523
|
+
.map(({ combo, labels, priority }) => ({ combo, labels, priority }))
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Bindings among the mounted ones that answer the same keys. */
|
|
527
|
+
export const hotkeyConflicts = computed<HotkeyConflict[]>(() =>
|
|
528
|
+
findHotkeyConflicts(
|
|
529
|
+
[...mounted]
|
|
530
|
+
.filter((entry) => toValue(entry.enabled) !== false)
|
|
531
|
+
.map((entry) => ({
|
|
532
|
+
bindings: entry.read(),
|
|
533
|
+
priority: Number(toValue(entry.priority) || 0),
|
|
534
|
+
scope: toValue(entry.scope) ?? null,
|
|
535
|
+
})),
|
|
536
|
+
),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
const warned = new Set<string>()
|
|
540
|
+
|
|
541
|
+
declare const process: { env: { NODE_ENV?: string } }
|
|
542
|
+
|
|
543
|
+
/*
|
|
544
|
+
* Written as the literal `process.env.NODE_ENV` so the consumer's bundler
|
|
545
|
+
* replaces it (Vite and webpack both do for dependencies); where nothing
|
|
546
|
+
* replaces it and no `process` exists, the warning simply stays off.
|
|
547
|
+
*/
|
|
548
|
+
function isDev(): boolean {
|
|
549
|
+
try {
|
|
550
|
+
return process.env.NODE_ENV !== "production"
|
|
551
|
+
} catch {
|
|
552
|
+
return false
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function warnConflicts() {
|
|
557
|
+
if (!isDev()) return
|
|
558
|
+
for (const conflict of hotkeyConflicts.value) {
|
|
559
|
+
const id = `${conflict.priority}|${conflict.combo}`
|
|
560
|
+
if (warned.has(id)) continue
|
|
561
|
+
warned.add(id)
|
|
562
|
+
console.warn(
|
|
563
|
+
`[@leavepulse/ui] ${conflict.combo} is bound more than once at priority ${conflict.priority}`
|
|
564
|
+
+ (conflict.labels.length ? ` (${conflict.labels.join(", ")})` : "")
|
|
565
|
+
+ "; only the most recently mounted one runs unless it declines.",
|
|
566
|
+
)
|
|
567
|
+
}
|
|
314
568
|
}
|
|
315
569
|
|
|
316
570
|
/**
|
|
@@ -321,13 +575,15 @@ function comboOf(binding: HotkeyBinding): string {
|
|
|
321
575
|
*/
|
|
322
576
|
export const activeShortcuts = computed<ShortcutGroup[]>(() => {
|
|
323
577
|
const groups = new Map<string, ShortcutEntry[]>()
|
|
578
|
+
const clashing = new Set(hotkeyConflicts.value.map((c) => c.combo))
|
|
324
579
|
for (const entry of mounted) {
|
|
325
580
|
if (toValue(entry.enabled) === false) continue
|
|
326
581
|
for (const binding of entry.read()) {
|
|
327
582
|
if (!binding.help) continue
|
|
328
583
|
const combo = binding.help.combo ?? comboOf(binding)
|
|
329
584
|
const entries = groups.get(binding.help.group) ?? []
|
|
330
|
-
|
|
585
|
+
const conflict = clashing.has(combo) || undefined
|
|
586
|
+
if (!entries.some((e) => e.combo === combo)) entries.push({ combo, label: binding.help.label, conflict })
|
|
331
587
|
groups.set(binding.help.group, entries)
|
|
332
588
|
}
|
|
333
589
|
}
|