@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.
Files changed (271) hide show
  1. package/README.md +49 -3
  2. package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-HzAA4gri.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-CJOYGZlI.js} +7 -7
  11. package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
  12. package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-B07Lf48e.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-BTVAR4tF.js} +3 -3
  23. package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-Ci_uCHgQ.js} +1 -1
  24. package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
  25. package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-BrdFtSTy.js} +16 -10
  26. package/dist/chunks/LpDateRangePicker-Drd7EbIz.js +210 -0
  27. package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
  28. package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-CfRhHwmE.js} +3 -3
  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-BzTVeNqe.js} +3 -3
  42. package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-CjABSGCu.js} +14 -8
  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-Bwws_X29.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-DTtWuKvq.js} +76 -9
  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 +62 -11
  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.d.ts +2 -0
  164. package/dist/components/LpToaster.vue.js +1 -1
  165. package/dist/components/LpTooltip.vue.js +1 -1
  166. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  167. package/dist/components/LpUptimeBar.vue.js +1 -1
  168. package/dist/components/chart.js +3 -2
  169. package/dist/components/commandSearch.js +3 -0
  170. package/dist/components/dateRange.d.ts +43 -0
  171. package/dist/components/dateRange.js +76 -0
  172. package/dist/components/exportData.d.ts +58 -0
  173. package/dist/components/exportData.js +165 -0
  174. package/dist/components/logSearch.d.ts +18 -0
  175. package/dist/components/logSearch.js +36 -0
  176. package/dist/components/phone.d.ts +41 -0
  177. package/dist/components/phone.js +80 -0
  178. package/dist/components/tableFilter.d.ts +36 -0
  179. package/dist/components/tableFilter.js +84 -0
  180. package/dist/components/tags.d.ts +20 -0
  181. package/dist/components/tags.js +37 -0
  182. package/dist/composables/useField.d.ts +4 -0
  183. package/dist/composables/useFieldNotice.d.ts +17 -0
  184. package/dist/composables/useFieldNotice.js +28 -0
  185. package/dist/composables/useForm.d.ts +53 -0
  186. package/dist/composables/useForm.js +207 -0
  187. package/dist/composables/useHotkeys.d.ts +65 -5
  188. package/dist/composables/useHotkeys.js +170 -9
  189. package/dist/composables/useInputMask.d.ts +59 -0
  190. package/dist/composables/useInputMask.js +170 -0
  191. package/dist/composables/useLocale.d.ts +2 -0
  192. package/dist/composables/useLocale.js +9 -0
  193. package/dist/composables/usePaintSelect.d.ts +22 -0
  194. package/dist/composables/usePaintSelect.js +169 -0
  195. package/dist/composables/useShift.js +7 -2
  196. package/dist/icons-all.d.ts +1 -0
  197. package/dist/icons-all.js +3 -0
  198. package/dist/icons.d.ts +14 -1
  199. package/dist/index.d.ts +29 -3
  200. package/dist/index.js +195 -138
  201. package/dist/kit-icons.d.ts +2 -0
  202. package/dist/theme/backdrop.d.ts +12 -0
  203. package/dist/theme/presets.d.ts +0 -2
  204. package/package.json +13 -4
  205. package/src/component-names.ts +4 -0
  206. package/src/components/ContextMenuItems.vue +1 -1
  207. package/src/components/LayoutNode.vue +11 -2
  208. package/src/components/LpActionBar.vue +1 -1
  209. package/src/components/LpAutocomplete.vue +1 -0
  210. package/src/components/LpAvatar.vue +2 -2
  211. package/src/components/LpButton.vue +1 -1
  212. package/src/components/LpChart.vue +80 -1
  213. package/src/components/LpColorPicker.vue +34 -1
  214. package/src/components/LpCommandPalette.vue +82 -8
  215. package/src/components/LpContextMenu.vue +1 -1
  216. package/src/components/LpDatePicker.vue +11 -4
  217. package/src/components/LpDateRangePicker.vue +226 -0
  218. package/src/components/LpDrawer.vue +1 -1
  219. package/src/components/LpDropdownMenu.vue +1 -0
  220. package/src/components/LpFieldNotice.vue +65 -0
  221. package/src/components/LpFormField.vue +33 -4
  222. package/src/components/LpIcon.vue +22 -4
  223. package/src/components/LpInput.vue +46 -6
  224. package/src/components/LpLogViewer.vue +225 -22
  225. package/src/components/LpModal.vue +2 -2
  226. package/src/components/LpModalNav.vue +13 -2
  227. package/src/components/LpNotificationBell.vue +15 -7
  228. package/src/components/LpPhoneInput.vue +179 -20
  229. package/src/components/LpPie.vue +8 -5
  230. package/src/components/LpPopover.vue +1 -0
  231. package/src/components/LpRangeCalendar.vue +365 -0
  232. package/src/components/LpRollingText.vue +3 -0
  233. package/src/components/LpScrollArea.vue +22 -2
  234. package/src/components/LpSelect.vue +0 -0
  235. package/src/components/LpShortcutsDialog.vue +77 -8
  236. package/src/components/LpStepper.vue +23 -10
  237. package/src/components/LpSwitch.vue +1 -1
  238. package/src/components/LpTable.vue +584 -65
  239. package/src/components/LpTableFilter.vue +175 -0
  240. package/src/components/LpTagsInput.vue +364 -0
  241. package/src/components/LpThemeSwitcher.vue +19 -23
  242. package/src/components/LpToaster.vue +91 -5
  243. package/src/components/LpTooltip.vue +9 -2
  244. package/src/components/LpTopologyCanvas.vue +7 -1
  245. package/src/components/chart.ts +5 -3
  246. package/src/components/commandSearch.ts +6 -0
  247. package/src/components/dateRange.ts +134 -0
  248. package/src/components/exportData.ts +228 -0
  249. package/src/components/logSearch.ts +46 -0
  250. package/src/components/phone.ts +140 -0
  251. package/src/components/tableFilter.ts +122 -0
  252. package/src/components/tags.ts +62 -0
  253. package/src/composables/useField.ts +4 -0
  254. package/src/composables/useFieldNotice.ts +59 -0
  255. package/src/composables/useForm.ts +314 -0
  256. package/src/composables/useHotkeys.ts +269 -13
  257. package/src/composables/useInputMask.ts +254 -0
  258. package/src/composables/useLocale.ts +14 -0
  259. package/src/composables/usePaintSelect.ts +243 -0
  260. package/src/composables/useShift.ts +13 -2
  261. package/src/iconSubset.d.mts +10 -0
  262. package/src/iconSubset.mjs +51 -0
  263. package/src/icons-all.ts +10 -0
  264. package/src/icons.ts +59 -8
  265. package/src/index.ts +37 -3
  266. package/src/kit-icons.ts +5 -0
  267. package/src/nuxt.ts +30 -2
  268. package/src/runtime/theme.plugin.ts +2 -2
  269. package/src/theme/backdrop.ts +69 -0
  270. package/src/theme/presets.ts +6 -64
  271. 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
- 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
  }