@leavepulse/ui 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +49 -3
  2. package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-nGNnzXqQ.css} +21 -21
  3. package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
  4. package/dist/assets/{LpShortcutsDialog-ChUyU7oV.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
  5. package/dist/assets/{LpTable-DDQNdf86.css → LpTable-CPwWxTJS.css} +49 -9
  6. package/dist/chunks/{ContextMenuItems-CrfeNPWT.js → ContextMenuItems-BtKU7074.js} +2 -1
  7. package/dist/chunks/{LayoutCanvas-D-jPOAvg.js → LayoutCanvas-BkaGXTjx.js} +1 -1
  8. package/dist/chunks/{LayoutNode-D8Xy9N-o.js → LayoutNode-B3_9E1Xa.js} +9 -5
  9. package/dist/chunks/{LpActionBar-BbwCF6l5.js → LpActionBar-CnfFVIwC.js} +3 -3
  10. package/dist/chunks/{LpAddressInput-BrOqkZqg.js → LpAddressInput-BwX8uo4_.js} +7 -7
  11. package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
  12. package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-C53_RaGF.js} +4 -4
  13. package/dist/chunks/{LpAutocomplete-DxV3Y9h4.js → LpAutocomplete-C8IgSMsi.js} +2 -1
  14. package/dist/chunks/{LpAvatar-ClBbEYZN.js → LpAvatar-C1XStdOo.js} +3 -3
  15. package/dist/chunks/{LpBreadcrumbs-B8IJgFIc.js → LpBreadcrumbs-CQJd73Gm.js} +1 -1
  16. package/dist/chunks/{LpButton-kqJBHIIb.js → LpButton-CKSwC6Mu.js} +1 -1
  17. package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
  18. package/dist/chunks/{LpCard-CpRByR0q.js → LpCard-C2IvucmX.js} +1 -1
  19. package/dist/chunks/{LpCheckbox-DpqchzH4.js → LpCheckbox-Bkj13CbW.js} +1 -1
  20. package/dist/chunks/{LpCodeBlock-DeHJPq7p.js → LpCodeBlock-ca7ANFyW.js} +1 -1
  21. package/dist/chunks/{LpColorPicker-Bhf1qxhB.js → LpColorPicker-zz4VGkG4.js} +46 -20
  22. package/dist/chunks/{LpConfirmDialog-CIUOSnWo.js → LpConfirmDialog-DCw5jR1t.js} +3 -3
  23. package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-CbywMpMP.js} +1 -1
  24. package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
  25. package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-DuusgT69.js} +13 -9
  26. package/dist/chunks/LpDateRangePicker-DHeuznfC.js +208 -0
  27. package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
  28. package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-Dt4aB4Fr.js} +2 -2
  29. package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
  30. package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
  31. package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
  32. package/dist/chunks/{LpFileTree-CSI_5SK2.js → LpFileTree-CNurOvkV.js} +3 -3
  33. package/dist/chunks/{LpFileTreeNode-B4MiSQX2.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
  34. package/dist/chunks/{LpFormField-CIk5qosh.js → LpFormField-CtVYj6tO.js} +29 -15
  35. package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
  36. package/dist/chunks/{LpInput-C0e8y8_d.js → LpInput-D-YGQbEv.js} +39 -16
  37. package/dist/chunks/{LpLightbox-Mr0W_6HK.js → LpLightbox-DSDahF23.js} +1 -1
  38. package/dist/chunks/{LpLogViewer-YBOaFnc_.js → LpLogViewer-KypbgOyo.js} +252 -58
  39. package/dist/chunks/{LpMapPicker-NUzS9e37.js → LpMapPicker-Cc84LaM3.js} +1 -1
  40. package/dist/chunks/{LpMediaGallery-DfJmMW0I.js → LpMediaGallery-YKr-cQPN.js} +2 -2
  41. package/dist/chunks/{LpModal-DPwGKBPv.js → LpModal-DMMDQHAu.js} +3 -3
  42. package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-BVPDyIup.js} +4 -4
  43. package/dist/chunks/{LpNotificationBell-t5yn-siX.js → LpNotificationBell-CCwyJP5C.js} +26 -14
  44. package/dist/chunks/{LpNumberField-DWnQKktS.js → LpNumberField-B77O-z28.js} +1 -1
  45. package/dist/chunks/{LpPagination-DFngX7Xo.js → LpPagination-BcDlo3r_.js} +1 -1
  46. package/dist/chunks/{LpPasswordInput-CWVDuBJ5.js → LpPasswordInput-eKzQhbQP.js} +2 -2
  47. package/dist/chunks/{LpPhoneInput-CD-ZTeSd.js → LpPhoneInput-BCbrrhgE.js} +126 -23
  48. package/dist/chunks/{LpPie-BLco31Nx.js → LpPie-BEJ2SfI6.js} +9 -10
  49. package/dist/chunks/{LpPopover-p_e_shN3.js → LpPopover-DCKeqiVn.js} +1 -0
  50. package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
  51. package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
  52. package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
  53. package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
  54. package/dist/chunks/{LpSegmented-Dejd7cIx.js → LpSegmented-C09EEam2.js} +1 -1
  55. package/dist/chunks/{LpSelect-DrRD9I7L.js → LpSelect-DNIL6MB9.js} +20 -23
  56. package/dist/chunks/{LpSidebar-BgwUNWpJ.js → LpSidebar-CQNB0-yU.js} +3 -3
  57. package/dist/chunks/{LpSidebarNav-Bqxz8aWT.js → LpSidebarNav-kx-LdqNU.js} +3 -3
  58. package/dist/chunks/{LpStat-dXtyrdX9.js → LpStat-B-7udBRP.js} +1 -1
  59. package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
  60. package/dist/chunks/{LpSwitch-B1aLKulj.js → LpSwitch-OrcOJX9m.js} +1 -1
  61. package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
  62. package/dist/chunks/{LpTabs-_qMVaZJ6.js → LpTabs-29iW1XOx.js} +1 -1
  63. package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
  64. package/dist/chunks/{LpTextarea-yP8hGedR.js → LpTextarea-uuScWo6-.js} +1 -1
  65. package/dist/chunks/{LpThemeProvider-Wjm7Tuit.js → LpThemeProvider-Be4r2TQX.js} +1 -1
  66. package/dist/chunks/{LpThemeSwitcher-CKLc10KZ.js → LpThemeSwitcher-B4aVPbNC.js} +19 -22
  67. package/dist/chunks/{LpTimeField-C5HRgS5H.js → LpTimeField-Cqe7fXnh.js} +1 -1
  68. package/dist/chunks/{LpToaster--oT6lfhL.js → LpToaster-DpJvoCOh.js} +37 -3
  69. package/dist/chunks/{LpTooltip-CBdzeKNf.js → LpTooltip-CXVKcOXT.js} +3 -2
  70. package/dist/chunks/{LpTopologyCanvas-DocsLal3.js → LpTopologyCanvas-6ozNrQKh.js} +2 -1
  71. package/dist/chunks/{LpUptimeBar-DLtxOMRy.js → LpUptimeBar-ugcv25xj.js} +2 -2
  72. package/dist/chunks/icons-CkA0njzm.js +43 -0
  73. package/dist/chunks/{useTheme-C8JHUlPM.js → useTheme-DJm1JSsY.js} +47 -50
  74. package/dist/component-names.d.ts +1 -1
  75. package/dist/components/ContextMenuItems.vue.js +1 -1
  76. package/dist/components/LayoutCanvas.vue.js +1 -1
  77. package/dist/components/LayoutNode.vue.js +1 -1
  78. package/dist/components/LpActionBar.vue.js +1 -1
  79. package/dist/components/LpAddressInput.vue.js +1 -1
  80. package/dist/components/LpAlert.vue.js +1 -1
  81. package/dist/components/LpAppShell.vue.js +1 -1
  82. package/dist/components/LpAutocomplete.vue.js +1 -1
  83. package/dist/components/LpAvatar.vue.js +1 -1
  84. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  85. package/dist/components/LpButton.vue.d.ts +2 -2
  86. package/dist/components/LpButton.vue.js +1 -1
  87. package/dist/components/LpCalendar.vue.js +1 -1
  88. package/dist/components/LpCard.vue.js +1 -1
  89. package/dist/components/LpChart.vue.d.ts +16 -1
  90. package/dist/components/LpChart.vue.js +63 -5
  91. package/dist/components/LpCheckbox.vue.js +1 -1
  92. package/dist/components/LpCodeBlock.vue.js +1 -1
  93. package/dist/components/LpColorPicker.vue.d.ts +2 -2
  94. package/dist/components/LpColorPicker.vue.js +1 -1
  95. package/dist/components/LpCommandPalette.vue.d.ts +6 -0
  96. package/dist/components/LpCommandPalette.vue.js +61 -10
  97. package/dist/components/LpConfirmDialog.vue.js +1 -1
  98. package/dist/components/LpConfirmHost.vue.js +1 -1
  99. package/dist/components/LpContextMenu.vue.js +1 -1
  100. package/dist/components/LpDatePicker.vue.js +1 -1
  101. package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
  102. package/dist/components/LpDateRangePicker.vue.js +4 -0
  103. package/dist/components/LpDisclosure.vue.js +1 -1
  104. package/dist/components/LpDrawer.vue.js +1 -1
  105. package/dist/components/LpDropdownMenu.vue.js +1 -1
  106. package/dist/components/LpDropzone.vue.js +1 -1
  107. package/dist/components/LpEmptyState.vue.js +1 -1
  108. package/dist/components/LpFieldNotice.vue.d.ts +8 -0
  109. package/dist/components/LpFieldNotice.vue.js +4 -0
  110. package/dist/components/LpFileTree.vue.js +1 -1
  111. package/dist/components/LpFileTreeNode.vue.js +1 -1
  112. package/dist/components/LpFormField.vue.d.ts +4 -1
  113. package/dist/components/LpFormField.vue.js +1 -1
  114. package/dist/components/LpIcon.vue.d.ts +0 -1
  115. package/dist/components/LpIcon.vue.js +1 -1
  116. package/dist/components/LpImageEditor.vue.js +2 -2
  117. package/dist/components/LpInput.vue.d.ts +19 -0
  118. package/dist/components/LpInput.vue.js +1 -1
  119. package/dist/components/LpLightbox.vue.js +1 -1
  120. package/dist/components/LpLogViewer.vue.d.ts +52 -1
  121. package/dist/components/LpLogViewer.vue.js +1 -1
  122. package/dist/components/LpMapPicker.vue.js +1 -1
  123. package/dist/components/LpMediaGallery.vue.js +1 -1
  124. package/dist/components/LpModal.vue.js +1 -1
  125. package/dist/components/LpModalNav.vue.js +1 -1
  126. package/dist/components/LpNotificationBell.vue.js +1 -1
  127. package/dist/components/LpNumberField.vue.js +1 -1
  128. package/dist/components/LpPagination.vue.js +1 -1
  129. package/dist/components/LpPasswordInput.vue.js +1 -1
  130. package/dist/components/LpPhoneInput.vue.d.ts +13 -0
  131. package/dist/components/LpPhoneInput.vue.js +1 -1
  132. package/dist/components/LpPie.vue.js +1 -1
  133. package/dist/components/LpPopover.vue.js +1 -1
  134. package/dist/components/LpProgress.vue.d.ts +1 -1
  135. package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
  136. package/dist/components/LpRangeCalendar.vue.js +4 -0
  137. package/dist/components/LpRollingText.vue.d.ts +1 -1
  138. package/dist/components/LpRollingText.vue.js +1 -1
  139. package/dist/components/LpScrollArea.vue.d.ts +2 -2
  140. package/dist/components/LpScrollArea.vue.js +1 -1
  141. package/dist/components/LpSection.vue.js +1 -1
  142. package/dist/components/LpSegmented.vue.js +1 -1
  143. package/dist/components/LpSelect.vue.js +1 -1
  144. package/dist/components/LpShortcutsDialog.vue.d.ts +6 -0
  145. package/dist/components/LpShortcutsDialog.vue.js +56 -14
  146. package/dist/components/LpSidebar.vue.js +1 -1
  147. package/dist/components/LpSidebarNav.vue.js +1 -1
  148. package/dist/components/LpStat.vue.js +1 -1
  149. package/dist/components/LpStepper.vue.js +1 -1
  150. package/dist/components/LpSwitch.vue.js +1 -1
  151. package/dist/components/LpTable.vue.d.ts +84 -2
  152. package/dist/components/LpTable.vue.js +481 -107
  153. package/dist/components/LpTableFilter.vue.d.ts +31 -0
  154. package/dist/components/LpTableFilter.vue.js +4 -0
  155. package/dist/components/LpTabs.vue.js +1 -1
  156. package/dist/components/LpTagsInput.vue.d.ts +77 -0
  157. package/dist/components/LpTagsInput.vue.js +4 -0
  158. package/dist/components/LpTextarea.vue.js +1 -1
  159. package/dist/components/LpThemeProvider.vue.js +1 -1
  160. package/dist/components/LpThemeSwitcher.vue.d.ts +2 -2
  161. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  162. package/dist/components/LpTimeField.vue.js +1 -1
  163. package/dist/components/LpToaster.vue.js +1 -1
  164. package/dist/components/LpTooltip.vue.js +1 -1
  165. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  166. package/dist/components/LpUptimeBar.vue.js +1 -1
  167. package/dist/components/chart.js +3 -2
  168. package/dist/components/commandSearch.js +3 -0
  169. package/dist/components/dateRange.d.ts +43 -0
  170. package/dist/components/dateRange.js +76 -0
  171. package/dist/components/exportData.d.ts +58 -0
  172. package/dist/components/exportData.js +165 -0
  173. package/dist/components/logSearch.d.ts +18 -0
  174. package/dist/components/logSearch.js +36 -0
  175. package/dist/components/phone.d.ts +41 -0
  176. package/dist/components/phone.js +80 -0
  177. package/dist/components/tableFilter.d.ts +36 -0
  178. package/dist/components/tableFilter.js +84 -0
  179. package/dist/components/tags.d.ts +20 -0
  180. package/dist/components/tags.js +37 -0
  181. package/dist/composables/useField.d.ts +4 -0
  182. package/dist/composables/useFieldNotice.d.ts +17 -0
  183. package/dist/composables/useFieldNotice.js +28 -0
  184. package/dist/composables/useForm.d.ts +53 -0
  185. package/dist/composables/useForm.js +207 -0
  186. package/dist/composables/useHotkeys.d.ts +65 -5
  187. package/dist/composables/useHotkeys.js +170 -9
  188. package/dist/composables/useInputMask.d.ts +59 -0
  189. package/dist/composables/useInputMask.js +170 -0
  190. package/dist/composables/usePaintSelect.d.ts +22 -0
  191. package/dist/composables/usePaintSelect.js +169 -0
  192. package/dist/composables/useShift.js +7 -2
  193. package/dist/icons-all.d.ts +1 -0
  194. package/dist/icons-all.js +3 -0
  195. package/dist/icons.d.ts +14 -1
  196. package/dist/index.d.ts +29 -3
  197. package/dist/index.js +195 -138
  198. package/dist/kit-icons.d.ts +2 -0
  199. package/dist/theme/backdrop.d.ts +12 -0
  200. package/dist/theme/presets.d.ts +0 -2
  201. package/package.json +13 -4
  202. package/src/component-names.ts +4 -0
  203. package/src/components/ContextMenuItems.vue +1 -1
  204. package/src/components/LayoutNode.vue +11 -2
  205. package/src/components/LpActionBar.vue +1 -1
  206. package/src/components/LpAutocomplete.vue +1 -0
  207. package/src/components/LpAvatar.vue +2 -2
  208. package/src/components/LpButton.vue +1 -1
  209. package/src/components/LpChart.vue +80 -1
  210. package/src/components/LpColorPicker.vue +34 -1
  211. package/src/components/LpCommandPalette.vue +81 -7
  212. package/src/components/LpContextMenu.vue +1 -1
  213. package/src/components/LpDatePicker.vue +8 -3
  214. package/src/components/LpDateRangePicker.vue +224 -0
  215. package/src/components/LpDropdownMenu.vue +1 -0
  216. package/src/components/LpFieldNotice.vue +65 -0
  217. package/src/components/LpFormField.vue +33 -4
  218. package/src/components/LpIcon.vue +22 -4
  219. package/src/components/LpInput.vue +46 -6
  220. package/src/components/LpLogViewer.vue +225 -22
  221. package/src/components/LpModal.vue +1 -1
  222. package/src/components/LpNotificationBell.vue +15 -7
  223. package/src/components/LpPhoneInput.vue +179 -20
  224. package/src/components/LpPie.vue +8 -5
  225. package/src/components/LpPopover.vue +1 -0
  226. package/src/components/LpRangeCalendar.vue +365 -0
  227. package/src/components/LpRollingText.vue +3 -0
  228. package/src/components/LpScrollArea.vue +22 -2
  229. package/src/components/LpSelect.vue +0 -0
  230. package/src/components/LpShortcutsDialog.vue +77 -8
  231. package/src/components/LpStepper.vue +23 -10
  232. package/src/components/LpSwitch.vue +1 -1
  233. package/src/components/LpTable.vue +584 -65
  234. package/src/components/LpTableFilter.vue +175 -0
  235. package/src/components/LpTagsInput.vue +364 -0
  236. package/src/components/LpThemeSwitcher.vue +19 -23
  237. package/src/components/LpToaster.vue +49 -1
  238. package/src/components/LpTooltip.vue +9 -2
  239. package/src/components/LpTopologyCanvas.vue +7 -1
  240. package/src/components/chart.ts +5 -3
  241. package/src/components/commandSearch.ts +6 -0
  242. package/src/components/dateRange.ts +134 -0
  243. package/src/components/exportData.ts +228 -0
  244. package/src/components/logSearch.ts +46 -0
  245. package/src/components/phone.ts +140 -0
  246. package/src/components/tableFilter.ts +122 -0
  247. package/src/components/tags.ts +62 -0
  248. package/src/composables/useField.ts +4 -0
  249. package/src/composables/useFieldNotice.ts +59 -0
  250. package/src/composables/useForm.ts +314 -0
  251. package/src/composables/useHotkeys.ts +269 -13
  252. package/src/composables/useInputMask.ts +254 -0
  253. package/src/composables/usePaintSelect.ts +243 -0
  254. package/src/composables/useShift.ts +13 -2
  255. package/src/iconSubset.d.mts +10 -0
  256. package/src/iconSubset.mjs +51 -0
  257. package/src/icons-all.ts +10 -0
  258. package/src/icons.ts +59 -8
  259. package/src/index.ts +37 -3
  260. package/src/kit-icons.ts +5 -0
  261. package/src/nuxt.ts +30 -2
  262. package/src/runtime/theme.plugin.ts +2 -2
  263. package/src/theme/backdrop.ts +69 -0
  264. package/src/theme/presets.ts +6 -64
  265. package/src/tokens/tokens.css +46 -0
@@ -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
- export interface HotkeyBinding {
36
- /** Layout-produced key, compared case-insensitively ("l", "f2", "escape"). */
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
- * second key of a chord ("G+D") or a key that needs Shift to type ("?").
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
- /** Rendered one <kbd> per "+"-separated key, so write it the way it reads. */
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
- export function matchesHotkey(event: KeyboardEvent, binding: HotkeyBinding): boolean {
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
- function onKeydown(event: KeyboardEvent) {
244
- if (event.isComposing || event.defaultPrevented) return
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 (!matchesHotkey(event, binding)) continue
258
- if (binding.handler(event) === false) continue
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
- const key = binding.key.toLowerCase()
312
- const name = KEY_NAMES[key] ?? (key.length === 1 || /^f\d+$/.test(key) ? key.toUpperCase() : binding.key)
313
- return [binding.ctrl && "Ctrl", binding.alt && "Alt", binding.shift && "Shift", name].filter(Boolean).join("+")
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
- if (!entries.some((e) => e.combo === combo)) entries.push({ combo, label: binding.help.label })
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
+ }