@leavepulse/ui 0.43.4 → 0.44.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 (337) hide show
  1. package/README.md +75 -7
  2. package/dist/assets/{LpCommandPalette-B-0Est4Z.css → LpCommandPalette-DCP5g2ik.css} +21 -21
  3. package/dist/assets/{LpDropzone-BBoWvi-J.css → LpDropzone-i4BdMi1H.css} +12 -12
  4. package/dist/assets/{LpImageEditor-Ct7Y3aNu.css → LpImageEditor-CbMA82g8.css} +6 -6
  5. package/dist/assets/{LpShortcutsDialog-C6YoQ8_M.css → LpShortcutsDialog-ChUyU7oV.css} +7 -7
  6. package/dist/assets/{LpSkeleton-D7xU8IPV.css → LpSkeleton-H3Z5dEIk.css} +4 -1
  7. package/dist/assets/{LpTable-BgPLJOXa.css → LpTable-DDQNdf86.css} +9 -9
  8. package/dist/chunks/ContextMenuItems-CrfeNPWT.js +123 -0
  9. package/dist/chunks/{LayoutCanvas-Dd20boUG.js → LayoutCanvas-D-jPOAvg.js} +26 -9
  10. package/dist/chunks/{LayoutNode-DGpZA9LX.js → LayoutNode-D8Xy9N-o.js} +20 -8
  11. package/dist/chunks/{LpActionBar-BlvtGB2P.js → LpActionBar-BbwCF6l5.js} +24 -10
  12. package/dist/chunks/{LpAddressInput-B-Zvxsn7.js → LpAddressInput-BrOqkZqg.js} +53 -48
  13. package/dist/chunks/{LpAlert-BAYHDSS1.js → LpAlert-DkOIjsbq.js} +15 -8
  14. package/dist/chunks/{LpAppShell-BGQVIY-3.js → LpAppShell-BUKPcHco.js} +51 -24
  15. package/dist/chunks/{LpAutocomplete-DExlIZe_.js → LpAutocomplete-DxV3Y9h4.js} +50 -23
  16. package/dist/chunks/{LpAvatar-B3-xAOH3.js → LpAvatar-ClBbEYZN.js} +17 -8
  17. package/dist/chunks/LpBreadcrumbs-B8IJgFIc.js +95 -0
  18. package/dist/chunks/{LpButton-5VXXCjwF.js → LpButton-kqJBHIIb.js} +44 -14
  19. package/dist/chunks/{LpCard-BhI1N_vL.js → LpCard-CpRByR0q.js} +1 -1
  20. package/dist/chunks/{LpCheckbox-CVccKYaH.js → LpCheckbox-DpqchzH4.js} +7 -3
  21. package/dist/chunks/{LpCodeBlock-Bo0B4TFR.js → LpCodeBlock-DeHJPq7p.js} +48 -16
  22. package/dist/chunks/{LpColorPicker-BlXea7LM.js → LpColorPicker-Bhf1qxhB.js} +32 -9
  23. package/dist/chunks/{LpConfirmDialog-Q-bxff8z.js → LpConfirmDialog-CIUOSnWo.js} +58 -16
  24. package/dist/chunks/LpConfirmHost-CDm0Wtpv.js +49 -0
  25. package/dist/chunks/LpContextMenu-DQw5dGfG.js +68 -0
  26. package/dist/chunks/LpDatePicker-Drirx1AK.js +191 -0
  27. package/dist/chunks/{LpDrawer-BZ6k3hvo.js → LpDrawer-bScx-KMO.js} +63 -26
  28. package/dist/chunks/{LpFileTree-_5vFBgaK.js → LpFileTree-CSI_5SK2.js} +23 -8
  29. package/dist/chunks/{LpFileTreeNode-BdcIYKIE.js → LpFileTreeNode-B4MiSQX2.js} +2 -2
  30. package/dist/chunks/LpFormField-CIk5qosh.js +87 -0
  31. package/dist/chunks/{LpInput-9EXWUM9B.js → LpInput-C0e8y8_d.js} +26 -11
  32. package/dist/chunks/{LpLightbox-2sYVMY-S.js → LpLightbox-Mr0W_6HK.js} +147 -60
  33. package/dist/chunks/{LpLink-DCND-zyu.js → LpLink-Krkjhe_y.js} +15 -6
  34. package/dist/chunks/{LpLogViewer-CaRy5MeS.js → LpLogViewer-YBOaFnc_.js} +68 -34
  35. package/dist/chunks/{LpMapPicker-ul4oYno6.js → LpMapPicker-NUzS9e37.js} +38 -13
  36. package/dist/chunks/{LpMediaGallery-FF-NquIo.js → LpMediaGallery-DfJmMW0I.js} +6 -3
  37. package/dist/chunks/{LpModal-Bt0pQCI_.js → LpModal-DPwGKBPv.js} +49 -26
  38. package/dist/chunks/{LpModalNav-YaifHrXC.js → LpModalNav-DPg5H9f3.js} +9 -6
  39. package/dist/chunks/{LpNotificationBell-DyGGsMAM.js → LpNotificationBell-t5yn-siX.js} +58 -24
  40. package/dist/chunks/{LpNumberField-CWuX08iX.js → LpNumberField-DWnQKktS.js} +20 -7
  41. package/dist/chunks/{LpNumberFlow-amWYaeuA.js → LpNumberFlow-z4WPZcZu.js} +22 -12
  42. package/dist/chunks/{LpOtpInput-y2v-pmiN.js → LpOtpInput-CziPF3Oq.js} +37 -8
  43. package/dist/chunks/{LpPagination-5FxjmmB5.js → LpPagination-DFngX7Xo.js} +30 -19
  44. package/dist/chunks/{LpPasswordInput--sjYCkLS.js → LpPasswordInput-CWVDuBJ5.js} +10 -7
  45. package/dist/chunks/{LpPhoneInput-BDSKVl_z.js → LpPhoneInput-CD-ZTeSd.js} +57 -33
  46. package/dist/chunks/{LpPie-D6OnzwlJ.js → LpPie-BLco31Nx.js} +78 -21
  47. package/dist/chunks/{LpPopover-8268UmlC.js → LpPopover-p_e_shN3.js} +4 -2
  48. package/dist/chunks/{LpProgress-Btq5W4Ut.js → LpProgress-CBU1iI69.js} +5 -4
  49. package/dist/chunks/{LpRadioGroup-xPIuVDO_.js → LpRadioGroup-DmEW0l78.js} +9 -2
  50. package/dist/chunks/{LpSegmented-fV0Sk92p.js → LpSegmented-Dejd7cIx.js} +7 -3
  51. package/dist/chunks/{LpSelect-ANIVtrpM.js → LpSelect-DrRD9I7L.js} +40 -18
  52. package/dist/chunks/{LpSidebar-ClBUwoEz.js → LpSidebar-BgwUNWpJ.js} +30 -22
  53. package/dist/chunks/{LpSidebarNav-llOcs_Fn.js → LpSidebarNav-Bqxz8aWT.js} +14 -9
  54. package/dist/chunks/{LpSkeleton-CTI-vBgW.js → LpSkeleton-BB6XIV-w.js} +1 -1
  55. package/dist/chunks/{LpSlider-BwNIBkAK.js → LpSlider-Cuzmrd7p.js} +7 -2
  56. package/dist/chunks/{LpSparkline-C6xQ2dwv.js → LpSparkline-CB80N8SJ.js} +11 -4
  57. package/dist/chunks/{LpStat-CQaW8R--.js → LpStat-dXtyrdX9.js} +1 -1
  58. package/dist/chunks/{LpSwitch-Blizvqyt.js → LpSwitch-B1aLKulj.js} +40 -26
  59. package/dist/chunks/{LpTableOfContents-CjvxPMpU.js → LpTableOfContents-BUcAfGPQ.js} +36 -41
  60. package/dist/chunks/{LpTabs-Cefe_lys.js → LpTabs-_qMVaZJ6.js} +19 -5
  61. package/dist/chunks/LpTextarea-yP8hGedR.js +77 -0
  62. package/dist/chunks/LpThemeProvider-Wjm7Tuit.js +35 -0
  63. package/dist/chunks/LpThemeSwitcher-CKLc10KZ.js +169 -0
  64. package/dist/chunks/{LpTilt-DuyKbpkA.js → LpTilt-B7KHX1zH.js} +2 -2
  65. package/dist/chunks/{LpTimeField-K6nAGboX.js → LpTimeField-C5HRgS5H.js} +13 -7
  66. package/dist/chunks/{LpToaster-gDB1EaU3.js → LpToaster--oT6lfhL.js} +57 -28
  67. package/dist/chunks/{LpTooltip-hCLPAqMG.js → LpTooltip-CBdzeKNf.js} +13 -6
  68. package/dist/chunks/LpTooltipProvider-CftjV7Xc.js +25 -0
  69. package/dist/chunks/{LpTopologyCanvas-Cm1L39uL.js → LpTopologyCanvas-DocsLal3.js} +6 -1
  70. package/dist/chunks/{LpUptimeBar-DvAMno0S.js → LpUptimeBar-DLtxOMRy.js} +43 -14
  71. package/dist/chunks/{LpThemeSwitcher-Cue-myST.js → useTheme-C8JHUlPM.js} +173 -215
  72. package/dist/component-names.d.ts +1 -1
  73. package/dist/components/ContextMenuItems.vue.d.ts +8 -0
  74. package/dist/components/ContextMenuItems.vue.js +4 -0
  75. package/dist/components/LayoutCanvas.vue.js +1 -1
  76. package/dist/components/LayoutNode.vue.d.ts +4 -0
  77. package/dist/components/LayoutNode.vue.js +1 -1
  78. package/dist/components/LpActionBar.vue.d.ts +3 -0
  79. package/dist/components/LpActionBar.vue.js +1 -1
  80. package/dist/components/LpAddressInput.vue.d.ts +1 -1
  81. package/dist/components/LpAddressInput.vue.js +1 -1
  82. package/dist/components/LpAlert.vue.d.ts +9 -0
  83. package/dist/components/LpAlert.vue.js +1 -1
  84. package/dist/components/LpAppShell.vue.d.ts +10 -0
  85. package/dist/components/LpAppShell.vue.js +1 -1
  86. package/dist/components/LpAutocomplete.vue.d.ts +31 -6
  87. package/dist/components/LpAutocomplete.vue.js +1 -1
  88. package/dist/components/LpAvatar.vue.d.ts +3 -3
  89. package/dist/components/LpAvatar.vue.js +1 -1
  90. package/dist/components/LpBadge.vue.d.ts +2 -2
  91. package/dist/components/LpBreadcrumbs.vue.d.ts +13 -9
  92. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  93. package/dist/components/LpButton.vue.d.ts +35 -98
  94. package/dist/components/LpButton.vue.js +1 -1
  95. package/dist/components/LpCard.vue.js +1 -1
  96. package/dist/components/LpChart.vue.d.ts +31 -2
  97. package/dist/components/LpChart.vue.js +317 -95
  98. package/dist/components/LpCheckbox.vue.js +1 -1
  99. package/dist/components/LpCodeBlock.vue.d.ts +2 -0
  100. package/dist/components/LpCodeBlock.vue.js +1 -1
  101. package/dist/components/LpColorPicker.vue.js +1 -1
  102. package/dist/components/LpCommandPalette.vue.d.ts +8 -3
  103. package/dist/components/LpCommandPalette.vue.js +86 -72
  104. package/dist/components/LpConfirmDialog.vue.d.ts +15 -3
  105. package/dist/components/LpConfirmDialog.vue.js +1 -1
  106. package/dist/components/LpConfirmHost.vue.d.ts +9 -0
  107. package/dist/components/LpConfirmHost.vue.js +4 -0
  108. package/dist/components/LpContextMenu.vue.d.ts +3 -5
  109. package/dist/components/LpContextMenu.vue.js +1 -1
  110. package/dist/components/LpDatePicker.vue.d.ts +24 -6
  111. package/dist/components/LpDatePicker.vue.js +1 -1
  112. package/dist/components/LpDrawer.vue.d.ts +10 -0
  113. package/dist/components/LpDrawer.vue.js +1 -1
  114. package/dist/components/LpDropzone.vue.js +22 -6
  115. package/dist/components/LpFileTree.vue.d.ts +11 -2
  116. package/dist/components/LpFileTree.vue.js +1 -1
  117. package/dist/components/LpFileTreeNode.vue.js +1 -1
  118. package/dist/components/LpFormField.vue.d.ts +9 -0
  119. package/dist/components/LpFormField.vue.js +1 -1
  120. package/dist/components/LpImageEditor.vue.js +92 -57
  121. package/dist/components/LpInput.vue.d.ts +3 -3
  122. package/dist/components/LpInput.vue.js +1 -1
  123. package/dist/components/LpLightbox.vue.d.ts +17 -0
  124. package/dist/components/LpLightbox.vue.js +1 -1
  125. package/dist/components/LpLink.vue.d.ts +11 -11
  126. package/dist/components/LpLink.vue.js +1 -1
  127. package/dist/components/LpLogViewer.vue.d.ts +20 -2
  128. package/dist/components/LpLogViewer.vue.js +1 -1
  129. package/dist/components/LpMapPicker.vue.d.ts +4 -4
  130. package/dist/components/LpMapPicker.vue.js +1 -1
  131. package/dist/components/LpMediaGallery.vue.js +1 -1
  132. package/dist/components/LpModal.vue.d.ts +16 -6
  133. package/dist/components/LpModal.vue.js +1 -1
  134. package/dist/components/LpModalNav.vue.d.ts +0 -1
  135. package/dist/components/LpModalNav.vue.js +1 -1
  136. package/dist/components/LpNotificationBell.vue.d.ts +11 -2
  137. package/dist/components/LpNotificationBell.vue.js +1 -1
  138. package/dist/components/LpNumberField.vue.d.ts +2 -0
  139. package/dist/components/LpNumberField.vue.js +1 -1
  140. package/dist/components/LpNumberFlow.vue.js +1 -1
  141. package/dist/components/LpOtpInput.vue.js +1 -1
  142. package/dist/components/LpPagination.vue.d.ts +2 -0
  143. package/dist/components/LpPagination.vue.js +1 -1
  144. package/dist/components/LpPasswordInput.vue.js +1 -1
  145. package/dist/components/LpPhoneInput.vue.js +1 -1
  146. package/dist/components/LpPie.vue.d.ts +21 -2
  147. package/dist/components/LpPie.vue.js +1 -1
  148. package/dist/components/LpPopover.vue.d.ts +2 -0
  149. package/dist/components/LpPopover.vue.js +1 -1
  150. package/dist/components/LpProgress.vue.js +1 -1
  151. package/dist/components/LpRadioGroup.vue.js +1 -1
  152. package/dist/components/LpSection.vue.d.ts +1 -1
  153. package/dist/components/LpSegmented.vue.d.ts +5 -0
  154. package/dist/components/LpSegmented.vue.js +1 -1
  155. package/dist/components/LpSelect.vue.d.ts +1 -0
  156. package/dist/components/LpSelect.vue.js +1 -1
  157. package/dist/components/LpShortcutsDialog.vue.d.ts +1 -1
  158. package/dist/components/LpShortcutsDialog.vue.js +11 -8
  159. package/dist/components/LpSidebar.vue.d.ts +6 -2
  160. package/dist/components/LpSidebar.vue.js +1 -1
  161. package/dist/components/LpSidebarNav.vue.js +1 -1
  162. package/dist/components/LpSkeleton.vue.js +1 -1
  163. package/dist/components/LpSlider.vue.js +1 -1
  164. package/dist/components/LpSparkline.vue.d.ts +1 -0
  165. package/dist/components/LpSparkline.vue.js +1 -1
  166. package/dist/components/LpStat.vue.js +1 -1
  167. package/dist/components/LpSwitch.vue.js +1 -1
  168. package/dist/components/LpTable.vue.d.ts +23 -1
  169. package/dist/components/LpTable.vue.js +74 -36
  170. package/dist/components/LpTableOfContents.vue.d.ts +4 -2
  171. package/dist/components/LpTableOfContents.vue.js +1 -1
  172. package/dist/components/LpTabs.vue.d.ts +2 -0
  173. package/dist/components/LpTabs.vue.js +1 -1
  174. package/dist/components/LpTextarea.vue.d.ts +19 -6
  175. package/dist/components/LpTextarea.vue.js +1 -1
  176. package/dist/components/LpThemeProvider.vue.d.ts +22 -0
  177. package/dist/components/LpThemeProvider.vue.js +4 -0
  178. package/dist/components/LpThemeSwitcher.vue.d.ts +13 -0
  179. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  180. package/dist/components/LpTilt.vue.js +1 -1
  181. package/dist/components/LpTimeField.vue.js +1 -1
  182. package/dist/components/LpToaster.vue.d.ts +6 -1
  183. package/dist/components/LpToaster.vue.js +1 -1
  184. package/dist/components/LpTooltip.vue.d.ts +1 -2
  185. package/dist/components/LpTooltip.vue.js +1 -1
  186. package/dist/components/LpTooltipProvider.vue.d.ts +22 -0
  187. package/dist/components/LpTooltipProvider.vue.js +4 -0
  188. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  189. package/dist/components/LpUptimeBar.vue.d.ts +4 -0
  190. package/dist/components/LpUptimeBar.vue.js +1 -1
  191. package/dist/components/chart.d.ts +32 -1
  192. package/dist/components/chart.js +39 -4
  193. package/dist/components/codeHighlight.d.ts +6 -0
  194. package/dist/components/codeHighlight.js +92 -20
  195. package/dist/components/color.d.ts +1 -1
  196. package/dist/components/color.js +20 -9
  197. package/dist/components/confirm.d.ts +25 -0
  198. package/dist/components/confirm.js +26 -0
  199. package/dist/components/countries.d.ts +6 -0
  200. package/dist/components/countries.js +7 -0
  201. package/dist/components/fileTree.js +16 -7
  202. package/dist/components/imageCrop.d.ts +42 -0
  203. package/dist/components/imageCrop.js +47 -0
  204. package/dist/components/link.d.ts +12 -0
  205. package/dist/components/link.js +19 -0
  206. package/dist/components/modalDepth.d.ts +18 -2
  207. package/dist/components/modalDepth.js +17 -5
  208. package/dist/components/otp.d.ts +5 -0
  209. package/dist/components/otp.js +10 -0
  210. package/dist/components/sidebar.d.ts +7 -1
  211. package/dist/components/tableSort.d.ts +8 -0
  212. package/dist/components/tableSort.js +31 -0
  213. package/dist/composables/useField.d.ts +34 -0
  214. package/dist/composables/useField.js +45 -0
  215. package/dist/composables/useFormDraft.d.ts +10 -3
  216. package/dist/composables/useFormDraft.js +9 -14
  217. package/dist/composables/useHotkeys.d.ts +17 -1
  218. package/dist/composables/useHotkeys.js +55 -18
  219. package/dist/composables/useInputFilter.d.ts +17 -0
  220. package/dist/composables/useInputFilter.js +47 -10
  221. package/dist/composables/useLpConfig.d.ts +24 -0
  222. package/dist/composables/useLpConfig.js +44 -0
  223. package/dist/composables/useRovingFocus.js +7 -0
  224. package/dist/composables/useTilt.d.ts +2 -2
  225. package/dist/composables/useTilt.js +5 -6
  226. package/dist/composables/useToast.d.ts +20 -0
  227. package/dist/composables/useToast.js +28 -4
  228. package/dist/composables/useZoomPan.d.ts +3 -3
  229. package/dist/composables/useZoomPan.js +4 -4
  230. package/dist/index.d.ts +32 -2
  231. package/dist/index.js +184 -137
  232. package/dist/layout/tree.d.ts +2 -0
  233. package/dist/layout/tree.js +16 -1
  234. package/dist/layout/useLayout.d.ts +15 -0
  235. package/dist/layout/useLayout.js +41 -14
  236. package/dist/theme/useTheme.d.ts +32 -9
  237. package/package.json +8 -7
  238. package/src/component-names.ts +5 -7
  239. package/src/components/ContextMenuItems.vue +88 -0
  240. package/src/components/LayoutCanvas.vue +26 -6
  241. package/src/components/LayoutNode.vue +19 -2
  242. package/src/components/LpActionBar.vue +30 -7
  243. package/src/components/LpAddressInput.vue +57 -41
  244. package/src/components/LpAlert.vue +16 -3
  245. package/src/components/LpAppShell.vue +45 -12
  246. package/src/components/LpAutocomplete.vue +46 -10
  247. package/src/components/LpAvatar.vue +19 -5
  248. package/src/components/LpBreadcrumbs.vue +65 -41
  249. package/src/components/LpButton.vue +59 -13
  250. package/src/components/LpChart.vue +272 -28
  251. package/src/components/LpCheckbox.vue +7 -3
  252. package/src/components/LpCodeBlock.vue +50 -14
  253. package/src/components/LpColorPicker.vue +39 -9
  254. package/src/components/LpCommandPalette.vue +34 -13
  255. package/src/components/LpConfirmDialog.vue +68 -17
  256. package/src/components/LpConfirmHost.vue +59 -0
  257. package/src/components/LpContextMenu.vue +14 -115
  258. package/src/components/LpDatePicker.vue +99 -68
  259. package/src/components/LpDrawer.vue +56 -19
  260. package/src/components/LpDropzone.vue +21 -4
  261. package/src/components/LpFileTree.vue +33 -7
  262. package/src/components/LpFormField.vue +30 -7
  263. package/src/components/LpImageEditor.vue +96 -57
  264. package/src/components/LpInput.vue +18 -7
  265. package/src/components/LpLightbox.vue +140 -29
  266. package/src/components/LpLink.vue +24 -13
  267. package/src/components/LpLogViewer.vue +86 -17
  268. package/src/components/LpMapPicker.vue +36 -13
  269. package/src/components/LpMediaGallery.vue +8 -2
  270. package/src/components/LpModal.vue +53 -27
  271. package/src/components/LpModalNav.vue +5 -2
  272. package/src/components/LpNotificationBell.vue +63 -17
  273. package/src/components/LpNumberField.vue +21 -6
  274. package/src/components/LpNumberFlow.vue +26 -10
  275. package/src/components/LpOtpInput.vue +43 -8
  276. package/src/components/LpPagination.vue +23 -7
  277. package/src/components/LpPasswordInput.vue +4 -1
  278. package/src/components/LpPhoneInput.vue +53 -27
  279. package/src/components/LpPie.vue +94 -15
  280. package/src/components/LpPopover.vue +3 -0
  281. package/src/components/LpProgress.vue +5 -3
  282. package/src/components/LpRadioGroup.vue +9 -2
  283. package/src/components/LpSegmented.vue +12 -2
  284. package/src/components/LpSelect.vue +0 -0
  285. package/src/components/LpShortcutsDialog.vue +8 -3
  286. package/src/components/LpSidebar.vue +14 -9
  287. package/src/components/LpSidebarNav.vue +17 -5
  288. package/src/components/LpSkeleton.vue +4 -1
  289. package/src/components/LpSlider.vue +7 -1
  290. package/src/components/LpSparkline.vue +11 -3
  291. package/src/components/LpSwitch.vue +30 -7
  292. package/src/components/LpTable.vue +91 -32
  293. package/src/components/LpTableOfContents.vue +50 -32
  294. package/src/components/LpTabs.vue +23 -6
  295. package/src/components/LpTextarea.vue +44 -6
  296. package/src/components/LpThemeProvider.vue +41 -0
  297. package/src/components/LpThemeSwitcher.vue +64 -17
  298. package/src/components/LpTilt.vue +2 -2
  299. package/src/components/LpTimeField.vue +14 -3
  300. package/src/components/LpToaster.vue +66 -26
  301. package/src/components/LpTooltip.vue +22 -5
  302. package/src/components/LpTooltipProvider.vue +24 -0
  303. package/src/components/LpTopologyCanvas.vue +12 -1
  304. package/src/components/LpUptimeBar.vue +46 -8
  305. package/src/components/chart.ts +77 -5
  306. package/src/components/codeHighlight.ts +111 -15
  307. package/src/components/color.ts +30 -9
  308. package/src/components/confirm.ts +58 -0
  309. package/src/components/countries.ts +12 -0
  310. package/src/components/fileTree.ts +19 -7
  311. package/src/components/imageCrop.ts +107 -0
  312. package/src/components/link.ts +34 -0
  313. package/src/components/modalDepth.ts +48 -6
  314. package/src/components/otp.ts +16 -0
  315. package/src/components/sidebar.ts +7 -1
  316. package/src/components/tableSort.ts +37 -0
  317. package/src/composables/useField.ts +79 -0
  318. package/src/composables/useFormDraft.ts +25 -20
  319. package/src/composables/useHotkeys.ts +89 -18
  320. package/src/composables/useInputFilter.ts +77 -14
  321. package/src/composables/useLpConfig.ts +87 -0
  322. package/src/composables/useRovingFocus.ts +11 -0
  323. package/src/composables/useTilt.ts +5 -6
  324. package/src/composables/useToast.ts +56 -4
  325. package/src/composables/useZoomPan.ts +6 -6
  326. package/src/index.ts +37 -2
  327. package/src/layout/tree.ts +30 -0
  328. package/src/layout/useLayout.ts +68 -18
  329. package/src/nuxt.ts +59 -17
  330. package/src/runtime/config.plugin.ts +14 -0
  331. package/src/runtime/theme.plugin.ts +42 -22
  332. package/src/theme/useTheme.ts +206 -100
  333. package/dist/chunks/LpBreadcrumbs-p4jHe_V-.js +0 -62
  334. package/dist/chunks/LpContextMenu-CwW1QXe_.js +0 -217
  335. package/dist/chunks/LpDatePicker-CwOToxGq.js +0 -164
  336. package/dist/chunks/LpFormField-CazBc2-a.js +0 -62
  337. package/dist/chunks/LpTextarea-t7F6gv8E.js +0 -47
@@ -25,6 +25,7 @@ import { prefersReducedMotion } from "../composables/easing"
25
25
  import { type LatLon, OSM_TILES, type TileSource, project, tileUrl, unproject } from "./address"
26
26
  import LpIcon from "./LpIcon.vue"
27
27
  import { useMergedAttrs } from "../composables/useMergedClass"
28
+ import { useLabels } from "../composables/useLpConfig"
28
29
 
29
30
  const props = withDefaults(
30
31
  defineProps<{
@@ -45,6 +46,8 @@ const props = withDefaults(
45
46
  zoomOutLabel?: string
46
47
  locateLabel?: string
47
48
  recentreLabel?: string
49
+ /** Accessible name of the map itself. */
50
+ mapLabel?: string
48
51
  }>(),
49
52
  {
50
53
  tiles: () => OSM_TILES,
@@ -52,15 +55,26 @@ const props = withDefaults(
52
55
  center: () => ({ lat: 50.11, lon: 14.44 }),
53
56
  height: "20rem",
54
57
  locatable: true,
55
- zoomInLabel: "Zoom in",
56
- zoomOutLabel: "Zoom out",
57
- locateLabel: "Use my location",
58
- recentreLabel: "Back to the pin",
59
58
  },
60
59
  )
61
60
 
62
61
  const emit = defineEmits<{ (e: "update:modelValue", value: LatLon): void }>()
63
62
 
63
+ const labelSet = useLabels("mapPicker", {
64
+ zoomIn: "Zoom in",
65
+ zoomOut: "Zoom out",
66
+ locate: "Use my location",
67
+ recentre: "Back to the pin",
68
+ map: "Map — arrows pan, +/− zoom, Enter drops the pin in the centre",
69
+ })
70
+ const text = computed(() => ({
71
+ zoomIn: props.zoomInLabel ?? labelSet.value.zoomIn,
72
+ zoomOut: props.zoomOutLabel ?? labelSet.value.zoomOut,
73
+ locate: props.locateLabel ?? labelSet.value.locate,
74
+ recentre: props.recentreLabel ?? labelSet.value.recentre,
75
+ map: props.mapLabel ?? labelSet.value.map,
76
+ }))
77
+
64
78
  const PAN_STEP = 80
65
79
  const ZOOM_MS = 220
66
80
 
@@ -452,6 +466,15 @@ function onKeydown(e: KeyboardEvent) {
452
466
  e.preventDefault()
453
467
  recentre()
454
468
  break
469
+ // Without this a keyboard could pan and zoom but never actually pick.
470
+ case "Enter":
471
+ case " ": {
472
+ const el = viewport.value
473
+ if (!el) return
474
+ e.preventDefault()
475
+ emit("update:modelValue", toLatLon({ x: el.clientWidth / 2, y: el.clientHeight / 2 }))
476
+ break
477
+ }
455
478
  }
456
479
  }
457
480
 
@@ -563,7 +586,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
563
586
  :class="disabled ? 'cursor-not-allowed opacity-55' : panning ? 'cursor-grabbing' : 'cursor-grab'"
564
587
  :tabindex="disabled ? -1 : 0"
565
588
  role="application"
566
- :aria-label="recentreLabel"
589
+ :aria-label="text.map"
567
590
  @pointerdown="onPointerDown"
568
591
  @pointermove="onPointerMove"
569
592
  @pointerup="onPointerUp"
@@ -616,8 +639,8 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
616
639
  :class="CONTROL"
617
640
  type="button"
618
641
  :disabled="disabled || level >= maxZoom"
619
- :title="zoomInLabel"
620
- :aria-label="zoomInLabel"
642
+ :title="text.zoomIn"
643
+ :aria-label="text.zoomIn"
621
644
  @click="zoomBy(1)"
622
645
  >
623
646
  <LpIcon name="lucide:plus" :size="16" />
@@ -626,8 +649,8 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
626
649
  :class="CONTROL"
627
650
  type="button"
628
651
  :disabled="disabled || level <= minZoom"
629
- :title="zoomOutLabel"
630
- :aria-label="zoomOutLabel"
652
+ :title="text.zoomOut"
653
+ :aria-label="text.zoomOut"
631
654
  @click="zoomBy(-1)"
632
655
  >
633
656
  <LpIcon name="lucide:minus" :size="16" />
@@ -636,8 +659,8 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
636
659
  v-if="canRecentre"
637
660
  :class="CONTROL"
638
661
  type="button"
639
- :title="recentreLabel"
640
- :aria-label="recentreLabel"
662
+ :title="text.recentre"
663
+ :aria-label="text.recentre"
641
664
  @click="recentre"
642
665
  >
643
666
  <LpIcon name="lucide:locate-fixed" :size="16" />
@@ -647,8 +670,8 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
647
670
  :class="CONTROL"
648
671
  type="button"
649
672
  :disabled="disabled || locating"
650
- :title="locateLabel"
651
- :aria-label="locateLabel"
673
+ :title="text.locate"
674
+ :aria-label="text.locate"
652
675
  @click="locate"
653
676
  >
654
677
  <LpIcon
@@ -24,7 +24,7 @@
24
24
  * cover is per image and the reveal is remembered per image, because a set is
25
25
  * usually mixed — one marked screenshot among plain ones.
26
26
  */
27
- import { computed, ref } from "vue"
27
+ import { computed, ref, watch } from "vue"
28
28
  import type { LightboxItem } from "./lightbox"
29
29
  import { useMergedAttrs } from "../composables/useMergedClass"
30
30
  import LpIcon from "./LpIcon.vue"
@@ -88,6 +88,12 @@ defineSlots<{
88
88
  const open = ref(false)
89
89
  const index = ref(0)
90
90
 
91
+ // Closing happens inside the lightbox (Escape, the scrim, the X), so the gallery
92
+ // reports both edges of v-model:open, not only the one it starts.
93
+ watch(open, (value, was) => {
94
+ if (!value && was) emit("update:open", false)
95
+ })
96
+
91
97
  // `max` counts what is SHOWN, so the badge reads "+N more" rather than "N
92
98
  // total". A set of 7 capped at 4 shows 4 cells, the last one badged +3.
93
99
  const visible = computed(() =>
@@ -188,8 +194,8 @@ function activate(i: number) {
188
194
  const at = openIndexOf(i)
189
195
  if (at === -1) return
190
196
  index.value = at
197
+ if (!open.value) emit("update:open", true)
191
198
  open.value = true
192
- emit("update:open", true)
193
199
  }
194
200
 
195
201
  /** The badge is about the FIRST hidden image, so that is where it opens. */
@@ -10,6 +10,7 @@ import {
10
10
  } from "reka-ui"
11
11
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
12
12
  import { useGlass } from "../composables/useGlass"
13
+ import { useLabels } from "../composables/useLpConfig"
13
14
  import { useShift } from "../composables/useShift"
14
15
  import { useModalLayer } from "./modalDepth"
15
16
  import { CLOSE_ICON } from "./dropdown"
@@ -33,7 +34,8 @@ import LpScrollArea from "./LpScrollArea.vue"
33
34
  * same fix, as the lightbox's own rungs in tokens.css — only the depth here is
34
35
  * not known until a dialog finds itself inside another.
35
36
  */
36
- const { claim, release, layer } = useModalLayer()
37
+ const panelRef = ref<HTMLElement | null>(null)
38
+ const { claim, release, layer } = useModalLayer({ element: () => panelRef.value?.parentElement })
37
39
 
38
40
  const props = withDefaults(
39
41
  defineProps<{
@@ -70,10 +72,20 @@ const props = withDefaults(
70
72
  * `provideGlass`, and without one it is off.
71
73
  */
72
74
  glass?: boolean
75
+ /**
76
+ * Accessible name for a dialog without a visible `title`. Rendered as a
77
+ * visually hidden title, so assistive tech still announces what opened.
78
+ */
79
+ ariaLabel?: string
80
+ /** `alertdialog` for a prompt that interrupts the user (a confirmation). */
81
+ role?: "dialog" | "alertdialog"
82
+ labels?: Partial<{ close: string }>
73
83
  }>(),
74
84
  { size: "md", autoFocus: "auto", glass: undefined },
75
85
  )
76
86
 
87
+ const text = useLabels("modal", { close: "Close" }, () => props.labels)
88
+
77
89
  const frosted = useGlass(() => props.glass)
78
90
 
79
91
  defineEmits<{ (e: "update:open", value: boolean): void }>()
@@ -95,9 +107,10 @@ const onInteractOutside = (event: Event) => {
95
107
  }
96
108
  }
97
109
 
98
- const describedByAttrs = computed(() =>
99
- props.description ? {} : { "aria-describedby": undefined },
100
- )
110
+ const describedByAttrs = computed(() => ({
111
+ ...(props.description ? {} : { "aria-describedby": undefined }),
112
+ ...(props.role ? { role: props.role } : {}),
113
+ }))
101
114
 
102
115
  const widthClass = computed(() => {
103
116
  if (props.width) return ""
@@ -152,15 +165,13 @@ const bodyPad = computed(() =>
152
165
  * scroll area exactly as before. A panel whose size never moves is pinned once
153
166
  * and never transitions.
154
167
  */
155
- const panelRef = ref<HTMLElement | null>(null)
156
-
157
168
  /**
158
169
  * Controls that are worth landing on when the dialog opens, in order of
159
170
  * preference. A field the user is going to type in wins over a button, since
160
171
  * typing is the reason most dialogs exist.
161
172
  */
162
173
  const AUTO_FOCUS_ORDER = [
163
- "input:not([type=hidden]):not([disabled]):not([readonly])",
174
+ "input:not([type=hidden]):not([disabled]):not([readonly]):not([tabindex='-1'])",
164
175
  "textarea:not([disabled]):not([readonly])",
165
176
  "select:not([disabled])",
166
177
  "[data-lp-autofocus]",
@@ -178,6 +189,18 @@ const AUTO_FOCUS_ORDER = [
178
189
  *
179
190
  * Both are fixed here: pick a meaningful target, and mark it so the ring shows.
180
191
  */
192
+ /**
193
+ * Whether a candidate can actually take focus: reka renders hidden form inputs
194
+ * for its switches and checkboxes, and a control hidden at this breakpoint
195
+ * (`lg:hidden`) is in the DOM but not on screen.
196
+ */
197
+ function focusable(el: HTMLElement): boolean {
198
+ if (el.hasAttribute("disabled") || el.getAttribute("tabindex") === "-1") return false
199
+ if (el.closest("[aria-hidden='true'], [data-hidden], [inert]")) return false
200
+ if (typeof el.checkVisibility === "function") return el.checkVisibility({ visibilityProperty: true })
201
+ return el.getClientRects().length > 0
202
+ }
203
+
181
204
  function onOpenAutoFocus(event: Event) {
182
205
  if (props.autoFocus === "none") return
183
206
  const panel = panelRef.value
@@ -186,26 +209,26 @@ function onOpenAutoFocus(event: Event) {
186
209
  const selectors =
187
210
  props.autoFocus === "auto" ? AUTO_FOCUS_ORDER : [props.autoFocus]
188
211
  for (const selector of selectors) {
189
- const target = panel.querySelector<HTMLElement>(selector)
190
- if (!target || target.hasAttribute("disabled")) continue
191
- event.preventDefault()
192
- // `focus-visible` is the browser's own heuristic and cannot be forced, so
193
- // the ring is driven off an attribute the kit owns. Removed as soon as the
194
- // element loses focus, leaving normal focus-visible behaviour in charge.
195
- //
196
- // Skipped for a control whose own shell already draws a ring on
197
- // focus-within: our fields put the border and ring on the wrapper, so
198
- // adding one to the bare <input> stacks a second ring inside the first.
199
- if (!target.closest("[data-lp-ring-owner]")) {
200
- target.setAttribute("data-lp-focus-ring", "")
201
- target.addEventListener(
202
- "blur",
203
- () => target.removeAttribute("data-lp-focus-ring"),
204
- { once: true },
205
- )
212
+ for (const target of panel.querySelectorAll<HTMLElement>(selector)) {
213
+ if (!focusable(target)) continue
214
+ target.focus()
215
+ // Only once focus really landed: a prevented auto-focus that failed would
216
+ // leave the keyboard on the page behind the dialog.
217
+ if (document.activeElement !== target) continue
218
+ event.preventDefault()
219
+ // `focus-visible` is the browser's own heuristic and cannot be forced, so
220
+ // the ring is driven off an attribute the kit owns. Skipped for a control
221
+ // whose own shell already draws a ring on focus-within.
222
+ if (!target.closest("[data-lp-ring-owner]")) {
223
+ target.setAttribute("data-lp-focus-ring", "")
224
+ target.addEventListener(
225
+ "blur",
226
+ () => target.removeAttribute("data-lp-focus-ring"),
227
+ { once: true },
228
+ )
229
+ }
230
+ return
206
231
  }
207
- target.focus()
208
- return
209
232
  }
210
233
  }
211
234
  // The panel's height eases between content-derived sizes — see
@@ -305,6 +328,9 @@ watch(
305
328
  :class="[frosted ? (layer.nested ? 'lp-glass-overlay--nested' : 'lp-glass-overlay') : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
306
329
  :style="{ ...(width ? { width } : {}), ...(hasAside ? { alignSelf: 'stretch' } : {}) }"
307
330
  >
331
+ <DialogTitle v-if="!(title || $slots.title) && ariaLabel" class="sr-only">
332
+ {{ ariaLabel }}
333
+ </DialogTitle>
308
334
  <header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
309
335
  <div class="flex flex-col gap-1">
310
336
  <DialogTitle class="text-base font-semibold text-ink">
@@ -319,7 +345,7 @@ watch(
319
345
  <DialogClose
320
346
  data-lp-dialog-close
321
347
  class="group flex shrink-0 items-center rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
322
- aria-label="Close"
348
+ :aria-label="text.close"
323
349
  >
324
350
  <LpIcon
325
351
  name="lucide:x"
@@ -14,6 +14,7 @@
14
14
  * one the sections are simply unreachable on a phone.
15
15
  */
16
16
  import { computed, ref } from "vue"
17
+ import { useLabels } from "../composables/useLpConfig"
17
18
  import LpButton from "./LpButton.vue"
18
19
  import LpIcon from "./LpIcon.vue"
19
20
  import LpModal from "./LpModal.vue"
@@ -62,7 +63,6 @@ const props = withDefaults(
62
63
  size: "2xl",
63
64
  navWidth: "13rem",
64
65
  mobileBreakpoint: "lg",
65
- navLabel: "Sections",
66
66
  navSide: "start",
67
67
  glass: undefined,
68
68
  },
@@ -73,6 +73,9 @@ const emit = defineEmits<{
73
73
  (e: "update:modelValue", value: string): void
74
74
  }>()
75
75
 
76
+ const text = useLabels("modalNav", { nav: "Sections" })
77
+ const navName = computed(() => props.navLabel ?? text.value.nav)
78
+
76
79
  const navOpen = ref(false)
77
80
 
78
81
  const active = computed({
@@ -187,7 +190,7 @@ const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
187
190
  variant="ghost"
188
191
  size="sm"
189
192
  square
190
- :aria-label="navLabel"
193
+ :aria-label="navName"
191
194
  :class="triggerClass"
192
195
  @click="navOpen = true"
193
196
  >
@@ -8,7 +8,8 @@
8
8
  * `select`. Mirrors the kit convention (cf. LpSidebar): data in, events out,
9
9
  * no data fetching inside.
10
10
  */
11
- import { computed, getCurrentInstance } from "vue"
11
+ import { computed, getCurrentInstance, onBeforeUnmount, onMounted, ref } from "vue"
12
+ import { useLabels } from "../composables/useLpConfig"
12
13
  import LpBadge from "./LpBadge.vue"
13
14
  import LpButton from "./LpButton.vue"
14
15
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
@@ -56,6 +57,15 @@ export interface NotificationLabels {
56
57
  dismiss?: string
57
58
  empty?: string
58
59
  ariaLabel?: string
60
+ /** Appended to the button's name while there is unread news; `{count}` is filled in. */
61
+ unread?: string
62
+ /** Screen-reader marker on an unread row. */
63
+ unreadItem?: string
64
+ /** Relative times; `{n}` is filled in. */
65
+ justNow?: string
66
+ minutes?: string
67
+ hours?: string
68
+ days?: string
59
69
  }
60
70
 
61
71
  const props = withDefaults(
@@ -83,16 +93,32 @@ const props = withDefaults(
83
93
 
84
94
  // Single lookup for every label, so the template never repeats the fallbacks.
85
95
  // `title` stays a top-level prop for compatibility and wins over labels.title.
96
+ const text = useLabels(
97
+ "notifications",
98
+ {
99
+ title: "Notifications",
100
+ markAllRead: "Mark all read",
101
+ clear: "Clear",
102
+ markAsRead: "Mark as read",
103
+ markAsUnread: "Mark as unread",
104
+ open: "Open",
105
+ dismiss: "Dismiss",
106
+ empty: "No notifications",
107
+ ariaLabel: "",
108
+ unread: "{count} unread",
109
+ unreadItem: "Unread",
110
+ justNow: "just now",
111
+ minutes: "{n}m",
112
+ hours: "{n}h",
113
+ days: "{n}d",
114
+ },
115
+ () => props.labels,
116
+ )
86
117
  const l = computed(() => ({
87
- title: props.title ?? props.labels?.title ?? "Notifications",
88
- markAllRead: props.labels?.markAllRead ?? "Mark all read",
89
- clear: props.labels?.clear ?? "Clear",
90
- markAsRead: props.labels?.markAsRead ?? "Mark as read",
91
- markAsUnread: props.labels?.markAsUnread ?? "Mark as unread",
92
- open: props.labels?.open ?? "Open",
93
- dismiss: props.labels?.dismiss ?? "Dismiss",
94
- empty: props.emptyLabel ?? props.labels?.empty ?? "No notifications",
95
- ariaLabel: props.labels?.ariaLabel ?? props.labels?.title ?? "Notifications",
118
+ ...text.value,
119
+ title: props.title ?? text.value.title,
120
+ empty: props.emptyLabel ?? text.value.empty,
121
+ ariaLabel: text.value.ariaLabel || text.value.title,
96
122
  }))
97
123
 
98
124
  // Popover open state. A defineModel (not a plain `open` prop) gives a local
@@ -119,6 +145,11 @@ const badgeLabel = computed(() =>
119
145
  )
120
146
  const hasUnread = computed(() => unread.value > 0)
121
147
 
148
+ // The badge is drawn, not read: its count goes into the button's own name.
149
+ const buttonLabel = computed(() =>
150
+ hasUnread.value ? `${l.value.ariaLabel}, ${l.value.unread.replace("{count}", String(unread.value))}` : l.value.ariaLabel,
151
+ )
152
+
122
153
  /** Icon tints for an unread row, keyed by the item's category tone. */
123
154
  const TONE_CLASS: Record<string, string> = {
124
155
  brand: "bg-brand-soft text-brand",
@@ -208,19 +239,32 @@ function rowMenu(item: NotificationItem): ContextMenuItemDef[] {
208
239
 
209
240
  // Compact relative time ("just now", "5m", "3h", "2d"); falls back to a date.
210
241
  // A `formatTime` prop takes over entirely — that's the localisation seam.
242
+ //
243
+ // The clock is only read after mount and then ticks, so a server render and the
244
+ // hydrating client agree (both print nothing) and "just now" does not stay
245
+ // "just now" for the life of the page.
246
+ const now = ref<number | null>(null)
247
+ let clock: ReturnType<typeof setInterval> | undefined
248
+ onMounted(() => {
249
+ now.value = Date.now()
250
+ clock = setInterval(() => (now.value = Date.now()), 30_000)
251
+ })
252
+ onBeforeUnmount(() => clearInterval(clock))
253
+
211
254
  function timeAgo(iso?: string): string {
212
- if (!iso) return ""
255
+ if (!iso || now.value === null) return ""
213
256
  if (props.formatTime) return props.formatTime(iso)
214
257
  const then = new Date(iso).getTime()
215
258
  if (Number.isNaN(then)) return ""
216
- const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
217
- if (secs < 45) return "just now"
259
+ const secs = Math.max(0, Math.round((now.value - then) / 1000))
260
+ const n = (template: string, value: number) => template.replace("{n}", String(value))
261
+ if (secs < 45) return l.value.justNow
218
262
  const mins = Math.round(secs / 60)
219
- if (mins < 60) return `${mins}m`
263
+ if (mins < 60) return n(l.value.minutes, mins)
220
264
  const hrs = Math.round(mins / 60)
221
- if (hrs < 24) return `${hrs}h`
265
+ if (hrs < 24) return n(l.value.hours, hrs)
222
266
  const days = Math.round(hrs / 24)
223
- if (days < 7) return `${days}d`
267
+ if (days < 7) return n(l.value.days, days)
224
268
  return new Date(then).toLocaleDateString()
225
269
  }
226
270
  </script>
@@ -243,12 +287,13 @@ function timeAgo(iso?: string): string {
243
287
  variant="ghost"
244
288
  size="sm"
245
289
  class="relative"
246
- :aria-label="l.ariaLabel"
290
+ :aria-label="buttonLabel"
247
291
  @click="open = !open"
248
292
  >
249
293
  <LpIcon name="lucide:bell" :size="18" />
250
294
  <LpBadge
251
295
  v-if="hasUnread"
296
+ aria-hidden="true"
252
297
  tone="danger"
253
298
  class="absolute -right-1 -top-1 min-w-4 justify-center px-1 py-0 text-[10px] leading-4"
254
299
  >
@@ -327,6 +372,7 @@ function timeAgo(iso?: string): string {
327
372
  {{ item.title }}
328
373
  </span>
329
374
  <span v-if="!item.read" class="size-1.5 shrink-0 rounded-full bg-brand" />
375
+ <span v-if="!item.read" class="sr-only">{{ l.unreadItem }}</span>
330
376
  </span>
331
377
  <span v-if="item.body" class="mt-0.5 line-clamp-2 block text-xs text-muted">
332
378
  {{ item.body }}
@@ -13,11 +13,12 @@ import {
13
13
  NumberFieldInput,
14
14
  NumberFieldRoot,
15
15
  } from "reka-ui"
16
- import { useId } from "vue"
16
+ import { computed, useId } from "vue"
17
17
  import LpIcon from "./LpIcon.vue"
18
+ import { useFieldControl } from "../composables/useField"
18
19
  import { useMergedAttrs } from "../composables/useMergedClass"
19
20
 
20
- defineProps<{
21
+ const props = defineProps<{
21
22
  // null = empty field (reka's native state); consumers that need a number
22
23
  // should default it themselves, the field doesn't invent a value.
23
24
  modelValue?: number | null
@@ -30,6 +31,7 @@ defineProps<{
30
31
  // "Depth", "Weight" tells the reader nothing about which one wants grams.
31
32
  // Not part of the value: it is never typed, selected, or submitted.
32
33
  unit?: string
34
+ invalid?: boolean
33
35
  }>()
34
36
 
35
37
  defineEmits<{ (e: "update:modelValue", value: number | null): void }>()
@@ -39,9 +41,19 @@ defineEmits<{ (e: "update:modelValue", value: number | null): void }>()
39
41
  // and leaves out the milliamps that make it mean anything.
40
42
  const unitId = useId()
41
43
 
44
+ const field = useFieldControl(() => ({ wrapped: false, invalid: props.invalid, disabled: props.disabled }))
45
+ const describedBy = computed(
46
+ () => [field.value.describedBy, props.unit ? unitId : undefined].filter(Boolean).join(" ") || undefined,
47
+ )
48
+
42
49
  // The field is full-width with its own control frame — the two things a consumer laying out a compact numeric column needs to change.
43
- const { class: rootClass, attrs: rest } = useMergedAttrs(
44
- "flex h-(--size-control-md) w-full items-center rounded-control border border-line bg-surface-soft transition-colors duration-[var(--duration-fast)] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring data-[disabled]:cursor-not-allowed data-[disabled]:opacity-55",
50
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
51
+ [
52
+ "flex h-(--size-control-md) w-full items-center rounded-control border bg-surface-soft transition-colors duration-[var(--duration-fast)] focus-within:ring-2 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-55",
53
+ field.value.invalid
54
+ ? "border-danger focus-within:border-danger focus-within:ring-danger-soft"
55
+ : "border-line focus-within:border-brand focus-within:ring-ring",
56
+ ].join(" "),
45
57
  )
46
58
  </script>
47
59
 
@@ -51,7 +63,8 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
51
63
  :min="min"
52
64
  :max="max"
53
65
  :step="step"
54
- :disabled="disabled"
66
+ :disabled="field.disabled"
67
+ :required="field.required"
55
68
  data-lp-ring-owner
56
69
  :class="rootClass"
57
70
  v-bind="rest"
@@ -67,7 +80,9 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
67
80
  />
68
81
  </NumberFieldDecrement>
69
82
  <NumberFieldInput
70
- :aria-describedby="unit ? unitId : undefined"
83
+ :id="field.id"
84
+ :aria-describedby="describedBy"
85
+ :aria-invalid="field.invalid || undefined"
71
86
  class="min-w-0 flex-1 bg-transparent text-center text-sm text-ink outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
72
87
  />
73
88
  <!-- The unit sits between the value and the increment button, so a long one
@@ -126,6 +126,15 @@ const travel = (to: number) => {
126
126
  const from = displayed.value
127
127
  if (from === to) return
128
128
 
129
+ // Infinity and NaN (a rate divided by zero) have no digits to roll through.
130
+ if (!Number.isFinite(to) || !Number.isFinite(from)) {
131
+ displayed.value = to
132
+ lastTarget = to
133
+ settled.value = true
134
+ padTo.value = 0
135
+ return
136
+ }
137
+
129
138
  if (prefersReducedMotion() || props.duration <= 0) {
130
139
  displayed.value = to
131
140
  return
@@ -221,7 +230,11 @@ const UNITS = [
221
230
 
222
231
  const scaleFor = (v: number) => {
223
232
  const abs = Math.abs(v)
224
- const unit = UNITS.find((u) => abs >= u.limit)
233
+ let at = UNITS.findIndex((u) => abs >= u.limit)
234
+ // 999 950 at one place reads "1000.0k"; that is the next unit up.
235
+ if (at > 0 && Math.abs(Number((v / UNITS[at].limit).toFixed(1))) >= 1000) at--
236
+ if (at === -1 && abs >= 999.5) at = UNITS.length - 1
237
+ const unit = UNITS[at]
225
238
  if (!unit) return { divisor: 1, suffix: "", decimals: 0 }
226
239
  // One decimal is what makes 1.2k readable; a value that lands exactly on the
227
240
  // unit (5M) keeps none, matching how these are written by hand.
@@ -234,11 +247,13 @@ const scaleFor = (v: number) => {
234
247
  }
235
248
 
236
249
 
237
- const formatted = computed(() => {
238
- // Rounded first: every branch below should render the shape the endpoints
239
- // imply, not whatever precision an intermediate value happens to carry.
240
- const v = round(displayed.value)
250
+ const formatted = computed(() => formatAt(round(displayed.value), !settled.value))
251
+
252
+ // Rounded first by the caller: every branch should render the shape the
253
+ // endpoints imply, not whatever precision an intermediate value carries.
254
+ function formatAt(v: number, padded: boolean): string {
241
255
  if (props.format) return props.format(v)
256
+ if (!Number.isFinite(v)) return String(v)
242
257
  if (props.compact) {
243
258
  /*
244
259
  * The unit tracks the CURRENT value, not the destination — the way a real
@@ -254,7 +269,7 @@ const formatted = computed(() => {
254
269
  const places = divisor === 1 ? 0 : 1
255
270
  return `${(v / divisor).toFixed(places)}${suffix}`
256
271
  }
257
- const pad = settled.value ? 1 : Math.max(1, padTo.value)
272
+ const pad = padded ? Math.max(1, padTo.value) : 1
258
273
  if (props.grouped)
259
274
  return v.toLocaleString(props.locale, {
260
275
  minimumFractionDigits: props.decimals,
@@ -271,7 +286,7 @@ const formatted = computed(() => {
271
286
  const int = dot === -1 ? body : body.slice(0, dot)
272
287
  const rest = dot === -1 ? "" : body.slice(dot)
273
288
  return `${neg ? "-" : ""}${int.padStart(pad, "0")}${rest}`
274
- })
289
+ }
275
290
 
276
291
  interface Cell {
277
292
  /** Stable across renders so a column keeps its identity as digits shift. */
@@ -357,8 +372,10 @@ const offsetFor = (digit: number) => {
357
372
  return digit
358
373
  }
359
374
 
375
+ /* What a screen reader hears: the settled value, formatted the way it will
376
+ land, never a frame of the roll. */
360
377
  const label = computed(
361
- () => `${props.prefix ?? ""}${props.value}${props.suffix ?? ""}`,
378
+ () => `${props.prefix ?? ""}${formatAt(props.value, false)}${props.suffix ?? ""}`,
362
379
  )
363
380
  </script>
364
381
 
@@ -368,9 +385,8 @@ const label = computed(
368
385
  <span
369
386
  ref="revealAnchor"
370
387
  class="lp-nf inline-flex items-baseline tabular-nums"
371
- :aria-label="label"
372
- role="text"
373
388
  >
389
+ <span class="sr-only">{{ label }}</span>
374
390
  <span v-if="prefix" aria-hidden="true">{{ prefix }}</span>
375
391
 
376
392
  <span class="lp-nf__track inline-flex items-baseline" aria-hidden="true">