@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
|
@@ -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
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter } from "vue"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Format-as-you-type for a native <input> without losing the caret.
|
|
5
|
+
*
|
|
6
|
+
* A mask is a string of tokens and literals: "+0 (000) 000-00-00",
|
|
7
|
+
* "0000 0000 0000 0000", "AA-0000". Each token accepts one character; anything
|
|
8
|
+
* else is a literal the mask inserts itself. A backslash makes the next mask
|
|
9
|
+
* character literal ("\\0" is a literal zero). A list of masks picks the first
|
|
10
|
+
* one the typed characters fit, and a function derives the mask from the raw
|
|
11
|
+
* value typed so far (card networks, phone plans).
|
|
12
|
+
*
|
|
13
|
+
* The caret is tracked by how many accepted characters sit before it, not by
|
|
14
|
+
* its offset, so inserting a literal ahead of it never shoves it backwards.
|
|
15
|
+
* Literals are only written once a character follows them, so Backspace walks
|
|
16
|
+
* back naturally instead of hitting an auto-inserted separator.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type MaskToken = RegExp | { pattern: RegExp; transform?: (ch: string) => string }
|
|
20
|
+
export type MaskTokens = Record<string, MaskToken>
|
|
21
|
+
export type MaskSpec = string | readonly string[] | ((raw: string) => string)
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_MASK_TOKENS: MaskTokens = {
|
|
24
|
+
"0": /\d/,
|
|
25
|
+
a: /\p{L}/u,
|
|
26
|
+
A: { pattern: /\p{L}/u, transform: (ch) => ch.toLocaleUpperCase() },
|
|
27
|
+
"*": /[\p{L}\d]/u,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Slot = { literal: string } | { test: (ch: string) => boolean; transform: (ch: string) => string }
|
|
31
|
+
|
|
32
|
+
export interface MaskResult {
|
|
33
|
+
masked: string
|
|
34
|
+
raw: string
|
|
35
|
+
/** Caret offset in `masked`. */
|
|
36
|
+
caret: number
|
|
37
|
+
/** Every token of the mask is filled. */
|
|
38
|
+
complete: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MaskOptions {
|
|
42
|
+
mask: MaskSpec
|
|
43
|
+
tokens?: MaskTokens
|
|
44
|
+
/**
|
|
45
|
+
* Caret position: an offset into `input`, or with `fromRaw` the number of raw
|
|
46
|
+
* characters before it. Defaults to the end.
|
|
47
|
+
*/
|
|
48
|
+
caret?: number
|
|
49
|
+
/** `input` holds raw characters only; literals in it are never consumed as mask literals. */
|
|
50
|
+
fromRaw?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tester(token: MaskToken): { test: (ch: string) => boolean; transform: (ch: string) => string } {
|
|
54
|
+
const pattern = token instanceof RegExp ? token : token.pattern
|
|
55
|
+
const re = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""))
|
|
56
|
+
const transform = token instanceof RegExp ? undefined : token.transform
|
|
57
|
+
return { test: (ch) => re.test(ch), transform: transform ?? ((ch) => ch) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseMask(mask: string, tokens: MaskTokens = DEFAULT_MASK_TOKENS): Slot[] {
|
|
61
|
+
const slots: Slot[] = []
|
|
62
|
+
const chars = Array.from(mask)
|
|
63
|
+
for (let i = 0; i < chars.length; i++) {
|
|
64
|
+
const ch = chars[i]!
|
|
65
|
+
if (ch === "\\" && i + 1 < chars.length) {
|
|
66
|
+
slots.push({ literal: chars[++i]! })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const token = tokens[ch]
|
|
70
|
+
slots.push(token ? tester(token) : { literal: ch })
|
|
71
|
+
}
|
|
72
|
+
return slots
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Run extends MaskResult {
|
|
76
|
+
overflow: boolean
|
|
77
|
+
/** Offsets in the input of the characters consumed as tokens. */
|
|
78
|
+
tokenOffsets: number[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function run(slots: Slot[], input: string, caret: number, fromRaw: boolean): Run {
|
|
82
|
+
const tokenStarts: number[] = []
|
|
83
|
+
const tokenEnds: number[] = []
|
|
84
|
+
const tokenOffsets: number[] = []
|
|
85
|
+
let out = ""
|
|
86
|
+
let raw = ""
|
|
87
|
+
let pending = ""
|
|
88
|
+
let si = 0
|
|
89
|
+
let offset = 0
|
|
90
|
+
let before = 0
|
|
91
|
+
let overflow = false
|
|
92
|
+
for (const ch of input) {
|
|
93
|
+
const at = offset
|
|
94
|
+
offset += ch.length
|
|
95
|
+
if (si >= slots.length) {
|
|
96
|
+
if (slots.some((s) => "test" in s && s.test(ch))) overflow = true
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
while (si < slots.length) {
|
|
100
|
+
const slot = slots[si]!
|
|
101
|
+
if ("literal" in slot) {
|
|
102
|
+
pending += slot.literal
|
|
103
|
+
si++
|
|
104
|
+
if (!fromRaw && slot.literal === ch) break
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (slot.test(ch)) {
|
|
108
|
+
const value = slot.transform(ch)
|
|
109
|
+
out += pending
|
|
110
|
+
pending = ""
|
|
111
|
+
tokenStarts.push(out.length)
|
|
112
|
+
out += value
|
|
113
|
+
tokenEnds.push(out.length)
|
|
114
|
+
raw += value
|
|
115
|
+
tokenOffsets.push(at)
|
|
116
|
+
si++
|
|
117
|
+
if (fromRaw ? raw.length <= caret : at < caret) before++
|
|
118
|
+
}
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const rest = slots.slice(si)
|
|
123
|
+
const complete = !rest.some((s) => "test" in s)
|
|
124
|
+
// A fully typed mask also shows its closing literals ("(000)" ends with ")").
|
|
125
|
+
if (complete && out) out += pending + rest.map((s) => ("literal" in s ? s.literal : "")).join("")
|
|
126
|
+
const at = before === 0 ? (tokenStarts[0] ?? 0) : tokenEnds[before - 1]!
|
|
127
|
+
return {
|
|
128
|
+
masked: out,
|
|
129
|
+
raw,
|
|
130
|
+
caret: complete && before === tokenEnds.length ? out.length : at,
|
|
131
|
+
complete,
|
|
132
|
+
overflow,
|
|
133
|
+
tokenOffsets,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolve(options: MaskOptions, input: string, caret: number): Run {
|
|
138
|
+
const tokens = options.tokens ?? DEFAULT_MASK_TOKENS
|
|
139
|
+
const fromRaw = !!options.fromRaw
|
|
140
|
+
const { mask } = options
|
|
141
|
+
if (typeof mask === "string") return run(parseMask(mask, tokens), input, caret, fromRaw)
|
|
142
|
+
if (typeof mask === "function") {
|
|
143
|
+
let current = mask("")
|
|
144
|
+
let result = run(parseMask(current, tokens), input, caret, fromRaw)
|
|
145
|
+
for (let i = 0; i < 4; i++) {
|
|
146
|
+
const next = mask(result.raw)
|
|
147
|
+
if (next === current) break
|
|
148
|
+
current = next
|
|
149
|
+
result = run(parseMask(current, tokens), input, caret, fromRaw)
|
|
150
|
+
}
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
let last: Run | undefined
|
|
154
|
+
for (const m of mask) {
|
|
155
|
+
last = run(parseMask(m, tokens), input, caret, fromRaw)
|
|
156
|
+
if (!last.overflow) return last
|
|
157
|
+
}
|
|
158
|
+
return last ?? run([], input, caret, fromRaw)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Format `input` through a mask and map the caret into the result. */
|
|
162
|
+
export function applyMask(input: string, options: MaskOptions): MaskResult {
|
|
163
|
+
const caret = options.caret ?? (options.fromRaw ? Number.POSITIVE_INFINITY : input.length)
|
|
164
|
+
const { masked, raw, caret: at, complete } = resolve(options, input, caret)
|
|
165
|
+
return { masked, raw, caret: at, complete }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The value after deleting one accepted character next to a collapsed caret,
|
|
170
|
+
* skipping over literals, or null when there is nothing to delete that way.
|
|
171
|
+
*/
|
|
172
|
+
export function maskDelete(
|
|
173
|
+
value: string,
|
|
174
|
+
caret: number,
|
|
175
|
+
direction: "backward" | "forward",
|
|
176
|
+
options: Omit<MaskOptions, "caret" | "fromRaw">,
|
|
177
|
+
): MaskResult | null {
|
|
178
|
+
const { tokenOffsets, raw } = resolve(options, value, caret)
|
|
179
|
+
const index =
|
|
180
|
+
direction === "backward"
|
|
181
|
+
? tokenOffsets.filter((o) => o < caret).length - 1
|
|
182
|
+
: tokenOffsets.findIndex((o) => o >= caret)
|
|
183
|
+
if (index < 0) return null
|
|
184
|
+
const chars = Array.from(raw)
|
|
185
|
+
chars.splice(index, 1)
|
|
186
|
+
return applyMask(chars.join(""), { ...options, caret: index, fromRaw: true })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface UseInputMaskOptions {
|
|
190
|
+
mask: MaybeRefOrGetter<MaskSpec | undefined>
|
|
191
|
+
tokens?: MaybeRefOrGetter<MaskTokens | undefined>
|
|
192
|
+
/** Called with every value the user produces. */
|
|
193
|
+
onChange?: (masked: string, raw: string, complete: boolean) => void
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Bind `onBeforeInput`, `onInput` and `onCompositionEnd` to the input, and show
|
|
198
|
+
* `format(model).masked` as its value.
|
|
199
|
+
*/
|
|
200
|
+
export function useInputMask(options: UseInputMaskOptions) {
|
|
201
|
+
const spec = computed(() => {
|
|
202
|
+
const mask = toValue(options.mask)
|
|
203
|
+
return mask === undefined || mask === "" ? undefined : { mask, tokens: toValue(options.tokens) }
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
function format(value: string | null | undefined): MaskResult {
|
|
207
|
+
const s = spec.value
|
|
208
|
+
const text = value ?? ""
|
|
209
|
+
return s ? applyMask(text, s) : { masked: text, raw: text, caret: text.length, complete: true }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function commit(el: HTMLInputElement, result: MaskResult): void {
|
|
213
|
+
if (el.value !== result.masked) el.value = result.masked
|
|
214
|
+
if (el.ownerDocument.activeElement === el) {
|
|
215
|
+
try {
|
|
216
|
+
el.setSelectionRange(result.caret, result.caret)
|
|
217
|
+
} catch {
|
|
218
|
+
// Input types without a selection cannot place a caret.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
options.onChange?.(result.masked, result.raw, result.complete)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function onBeforeInput(event: InputEvent): void {
|
|
225
|
+
const s = spec.value
|
|
226
|
+
const el = event.target
|
|
227
|
+
if (!s || event.isComposing || !(el instanceof HTMLInputElement)) return
|
|
228
|
+
const backward = event.inputType === "deleteContentBackward"
|
|
229
|
+
if (!backward && event.inputType !== "deleteContentForward") return
|
|
230
|
+
const start = el.selectionStart
|
|
231
|
+
if (start === null || start !== el.selectionEnd) return
|
|
232
|
+
event.preventDefault()
|
|
233
|
+
const result = maskDelete(el.value, start, backward ? "backward" : "forward", s)
|
|
234
|
+
if (result) commit(el, result)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function reformat(el: HTMLInputElement): void {
|
|
238
|
+
const s = spec.value
|
|
239
|
+
if (!s) return
|
|
240
|
+
commit(el, applyMask(el.value, { ...s, caret: el.selectionStart ?? el.value.length }))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Attach to `input`. Composition is left alone until it ends. */
|
|
244
|
+
function onInput(event: Event): void {
|
|
245
|
+
if ((event as InputEvent).isComposing || !(event.target instanceof HTMLInputElement)) return
|
|
246
|
+
reformat(event.target)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function onCompositionEnd(event: CompositionEvent): void {
|
|
250
|
+
if (event.target instanceof HTMLInputElement) reformat(event.target)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { active: computed(() => !!spec.value), format, onBeforeInput, onInput, onCompositionEnd }
|
|
254
|
+
}
|