@leavepulse/ui 0.78.0 → 0.78.2

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 (243) hide show
  1. package/dist/chunks/{DropzoneLinkField-DXIr0eFz.js → DropzoneLinkField-CyE7TJ5f.js} +1 -1
  2. package/dist/chunks/FieldLeading-mr81aEAP.js +27 -0
  3. package/dist/chunks/LpAddressInput-ft8fQOGx.js +401 -0
  4. package/dist/chunks/{LpAppShell-BNAVTQsY.js → LpAppShell-DkvU6IBB.js} +23 -4
  5. package/dist/chunks/LpAutocomplete-CrZbd0uv.js +310 -0
  6. package/dist/chunks/LpCheckbox-CCWCwD0y.js +129 -0
  7. package/dist/chunks/{LpCodeBlock-DuzmnIHl.js → LpCodeBlock-DBqy4RLd.js} +1 -1
  8. package/dist/chunks/{LpCodeDisplay-CO8fngxa.js → LpCodeDisplay-D59uPwRb.js} +3 -3
  9. package/dist/chunks/LpColorPicker-BONURBFN.js +574 -0
  10. package/dist/chunks/{LpConfirmDialog-6oCIJTDa.js → LpConfirmDialog-CnrmzkuN.js} +1 -1
  11. package/dist/chunks/{LpConfirmHost-DgHvfT6s.js → LpConfirmHost-CwbObVGH.js} +1 -1
  12. package/dist/chunks/{LpContextMenu--eMGIql6.js → LpContextMenu-Bx_IhQDS.js} +5 -5
  13. package/dist/chunks/{LpCopyButton-7EOSIuwa.js → LpCopyButton-sAMvWeLP.js} +1 -1
  14. package/dist/chunks/{LpDataList-DgBeM01y.js → LpDataList-CaVfsozH.js} +2 -2
  15. package/dist/chunks/LpDatePicker-C2AOtxgF.js +171 -0
  16. package/dist/chunks/{LpDateRangePicker-CNQXdTVv.js → LpDateRangePicker-CiXdIAyh.js} +43 -35
  17. package/dist/chunks/{LpDrawer-Bt9UUqt_.js → LpDrawer-DXNQFpAA.js} +1 -1
  18. package/dist/chunks/{LpFileTree-BSx1kBoV.js → LpFileTree-CpNurwZp.js} +3 -3
  19. package/dist/chunks/{LpFileTreeNode-4dVmFHb3.js → LpFileTreeNode-DdF8Cq-s.js} +2 -2
  20. package/dist/chunks/{LpHoverCard-DanH3Nz1.js → LpHoverCard-CHE9jAJG.js} +6 -6
  21. package/dist/chunks/{LpInfraNode-BAU40Km2.js → LpInfraNode-Bb5nbuFY.js} +17 -0
  22. package/dist/chunks/{LpInput-DL6HEJdi.js → LpInput-CbI7z5fQ.js} +21 -25
  23. package/dist/chunks/{LpInspector-CuoMAhiw.js → LpInspector-DO3fTCQ1.js} +2 -2
  24. package/dist/chunks/{LpLaneNode-HtnQf2FF.js → LpLaneNode-Bqcdm8Rf.js} +19 -1
  25. package/dist/chunks/{LpLightbox-xvpQ64MM.js → LpLightbox-CtDTQV01.js} +1 -1
  26. package/dist/chunks/{LpLogInspector-6cNnXqp4.js → LpLogInspector-CcSJSGan.js} +1 -1
  27. package/dist/chunks/{LpLogViewer-Dvb1E9_G.js → LpLogViewer-CWu49Dko.js} +4 -4
  28. package/dist/chunks/{LpMediaGallery-Df-tiWeq.js → LpMediaGallery-BYEwySoF.js} +2 -2
  29. package/dist/chunks/{LpModal-DrtQ_1A2.js → LpModal-DDvrSAnf.js} +1 -1
  30. package/dist/chunks/{LpModalNav-DkmGCnZE.js → LpModalNav-DI3BW5ff.js} +2 -2
  31. package/dist/chunks/{LpNotificationBell-D_wklO1m.js → LpNotificationBell-DUbVyK6R.js} +3 -3
  32. package/dist/chunks/LpNumberField-C8LfUz_R.js +154 -0
  33. package/dist/chunks/LpOtpInput-B1X9l9no.js +140 -0
  34. package/dist/chunks/LpPageScrollbar-D2O0xhJw.js +164 -0
  35. package/dist/chunks/{LpPaletteTheme-dVtxHx-F.js → LpPaletteTheme-B-xuZiKK.js} +2 -2
  36. package/dist/chunks/{LpPasswordInput-B-zD6tUS.js → LpPasswordInput-Duc_3wPv.js} +2 -2
  37. package/dist/chunks/{LpPhoneInput-BSL7NX4z.js → LpPhoneInput-CctcRK9K.js} +124 -118
  38. package/dist/chunks/{LpPie-9t_guO49.js → LpPie-oVNHjsKD.js} +2 -2
  39. package/dist/chunks/{LpPipeline-rjXvEDRJ.js → LpPipeline-BSXT6oUJ.js} +1 -1
  40. package/dist/chunks/LpRadioGroup-DTM0nd9G.js +103 -0
  41. package/dist/chunks/LpRangeSlider-DJsEZXad.js +97 -0
  42. package/dist/chunks/{LpRelativeTime-BovUk8MP.js → LpRelativeTime-0zI-44ve.js} +1 -1
  43. package/dist/chunks/LpRepeater-Czib3rmv.js +193 -0
  44. package/dist/chunks/{LpScrollArea-ay4Xo_hg.js → LpScrollArea-CqvwfZs2.js} +9 -12
  45. package/dist/chunks/{LpSecretField-DBvU6iAw.js → LpSecretField-BtlCk3IH.js} +4 -4
  46. package/dist/chunks/LpSegmented-sskrJKJJ.js +128 -0
  47. package/dist/chunks/LpSelect-JFZehOkd.js +515 -0
  48. package/dist/chunks/{LpServiceNode-D8uJ8sGk.js → LpServiceNode-Cln2ntdw.js} +17 -0
  49. package/dist/chunks/{LpSidebar-kSkpPh3-.js → LpSidebar-CvRvVBTt.js} +4 -4
  50. package/dist/chunks/{LpSidebarNav-C2mvqrY2.js → LpSidebarNav-CE0jQS8i.js} +2 -2
  51. package/dist/chunks/{LpSidebarRow-eRP4Q31q.js → LpSidebarRow-DOTg1txp.js} +2 -2
  52. package/dist/chunks/LpSlider-C4ilCtZX.js +76 -0
  53. package/dist/chunks/{LpStat-pMNZgFcq.js → LpStat-DPjc6_fX.js} +17 -4
  54. package/dist/chunks/LpSwitch-DyAYTchz.js +122 -0
  55. package/dist/chunks/{LpTableFilter-DgARTsA1.js → LpTableFilter-CJg5eIcV.js} +2 -2
  56. package/dist/chunks/{LpTagsInput-Dt0mteSj.js → LpTagsInput-CkNxpNYa.js} +4 -3
  57. package/dist/chunks/{LpTextarea-Co0b9PVx.js → LpTextarea-CFK_5QB8.js} +3 -2
  58. package/dist/chunks/{LpThemeStudio-DzYfE-5k.js → LpThemeStudio-R-wohbCY.js} +3 -3
  59. package/dist/chunks/{LpThemeSwitcher-CFLkdbBL.js → LpThemeSwitcher-Bzy15MCP.js} +2 -2
  60. package/dist/chunks/LpTimeField-B5n0m8m0.js +115 -0
  61. package/dist/chunks/{LpToaster-QFj0P-5m.js → LpToaster-Ct_ZOhCh.js} +1 -1
  62. package/dist/chunks/{LpTooltip-BJf3lZMq.js → LpTooltip-Bz_rrq5S.js} +5 -5
  63. package/dist/chunks/{LpTopologyCanvas-BOHHHZ_6.js → LpTopologyCanvas-BNgw9RyZ.js} +14 -9
  64. package/dist/chunks/{LpTruncate-C6u3gjc6.js → LpTruncate-ellq9RYM.js} +2 -2
  65. package/dist/chunks/{LpUptimeBar-BnYmR2rj.js → LpUptimeBar-BoxcCDVY.js} +1 -1
  66. package/dist/chunks/{LpWorkspaceSwitcher-D0e9upAy.js → LpWorkspaceSwitcher-ugv-fyig.js} +2 -2
  67. package/dist/chunks/{RevealToggle-CCPDE8Mv.js → RevealToggle-D_iyrB8d.js} +1 -1
  68. package/dist/component-names.d.ts +1 -1
  69. package/dist/components/DropzoneLinkField.vue.js +1 -1
  70. package/dist/components/FieldLeading.vue.d.ts +16 -0
  71. package/dist/components/FieldLeading.vue.js +4 -0
  72. package/dist/components/LpAddressInput.vue.d.ts +4 -0
  73. package/dist/components/LpAddressInput.vue.js +1 -1
  74. package/dist/components/LpAlert.vue.d.ts +3 -3
  75. package/dist/components/LpAppShell.vue.d.ts +16 -1
  76. package/dist/components/LpAppShell.vue.js +1 -1
  77. package/dist/components/LpAutocomplete.vue.d.ts +4 -0
  78. package/dist/components/LpAutocomplete.vue.js +1 -1
  79. package/dist/components/LpBadge.vue.d.ts +2 -2
  80. package/dist/components/LpChart.vue.d.ts +1 -1
  81. package/dist/components/LpChart.vue.js +1 -1
  82. package/dist/components/LpCheckbox.vue.d.ts +4 -0
  83. package/dist/components/LpCheckbox.vue.js +1 -1
  84. package/dist/components/LpCodeBlock.vue.js +1 -1
  85. package/dist/components/LpCodeDisplay.vue.js +1 -1
  86. package/dist/components/LpCollapse.vue.d.ts +1 -1
  87. package/dist/components/LpColorPicker.vue.d.ts +5 -3
  88. package/dist/components/LpColorPicker.vue.js +1 -1
  89. package/dist/components/LpCommandPalette.vue.js +1 -1
  90. package/dist/components/LpConfirmDialog.vue.js +1 -1
  91. package/dist/components/LpConfirmHost.vue.js +1 -1
  92. package/dist/components/LpContextMenu.vue.js +1 -1
  93. package/dist/components/LpCopyButton.vue.js +1 -1
  94. package/dist/components/LpDataList.vue.js +1 -1
  95. package/dist/components/LpDatePicker.vue.js +1 -1
  96. package/dist/components/LpDateRangePicker.vue.js +1 -1
  97. package/dist/components/LpDock.vue.js +1 -1
  98. package/dist/components/LpDrawer.vue.js +1 -1
  99. package/dist/components/LpDropzone.vue.js +1 -1
  100. package/dist/components/LpFileTree.vue.js +1 -1
  101. package/dist/components/LpFileTreeNode.vue.js +1 -1
  102. package/dist/components/LpHoverCard.vue.js +1 -1
  103. package/dist/components/LpImageEditor.vue.d.ts +1 -1
  104. package/dist/components/LpInfraNode.vue.d.ts +11 -2
  105. package/dist/components/LpInfraNode.vue.js +1 -1
  106. package/dist/components/LpInput.vue.js +1 -1
  107. package/dist/components/LpInspector.vue.js +1 -1
  108. package/dist/components/LpLaneNode.vue.d.ts +2 -3
  109. package/dist/components/LpLaneNode.vue.js +1 -1
  110. package/dist/components/LpLightbox.vue.js +1 -1
  111. package/dist/components/LpLogInspector.vue.js +1 -1
  112. package/dist/components/LpLogViewer.vue.js +1 -1
  113. package/dist/components/LpMediaGallery.vue.js +1 -1
  114. package/dist/components/LpModal.vue.js +1 -1
  115. package/dist/components/LpModalNav.vue.js +1 -1
  116. package/dist/components/LpNotificationBell.vue.js +1 -1
  117. package/dist/components/LpNumberField.vue.d.ts +17 -2
  118. package/dist/components/LpNumberField.vue.js +1 -1
  119. package/dist/components/LpOtpInput.vue.d.ts +4 -0
  120. package/dist/components/LpOtpInput.vue.js +1 -1
  121. package/dist/components/LpPageScrollbar.vue.d.ts +10 -0
  122. package/dist/components/LpPageScrollbar.vue.js +4 -0
  123. package/dist/components/LpPaletteTheme.vue.js +1 -1
  124. package/dist/components/LpPasswordInput.vue.js +1 -1
  125. package/dist/components/LpPhoneInput.vue.d.ts +4 -0
  126. package/dist/components/LpPhoneInput.vue.js +1 -1
  127. package/dist/components/LpPie.vue.js +1 -1
  128. package/dist/components/LpPipeline.vue.js +1 -1
  129. package/dist/components/LpRadioGroup.vue.d.ts +4 -0
  130. package/dist/components/LpRadioGroup.vue.js +1 -1
  131. package/dist/components/LpRangeSlider.vue.d.ts +4 -0
  132. package/dist/components/LpRangeSlider.vue.js +1 -1
  133. package/dist/components/LpRelativeTime.vue.js +1 -1
  134. package/dist/components/LpRepeater.vue.d.ts +2 -0
  135. package/dist/components/LpRepeater.vue.js +1 -1
  136. package/dist/components/LpScrollArea.vue.d.ts +1 -1
  137. package/dist/components/LpScrollArea.vue.js +1 -1
  138. package/dist/components/LpSecretField.vue.js +1 -1
  139. package/dist/components/LpSegmented.vue.d.ts +4 -0
  140. package/dist/components/LpSegmented.vue.js +1 -1
  141. package/dist/components/LpSelect.vue.d.ts +2 -0
  142. package/dist/components/LpSelect.vue.js +1 -1
  143. package/dist/components/LpServiceNode.vue.d.ts +8 -2
  144. package/dist/components/LpServiceNode.vue.js +1 -1
  145. package/dist/components/LpShinyText.vue.d.ts +1 -1
  146. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  147. package/dist/components/LpSidebar.vue.d.ts +1 -1
  148. package/dist/components/LpSidebar.vue.js +1 -1
  149. package/dist/components/LpSidebarNav.vue.js +1 -1
  150. package/dist/components/LpSidebarRow.vue.js +1 -1
  151. package/dist/components/LpSlider.vue.d.ts +4 -0
  152. package/dist/components/LpSlider.vue.js +1 -1
  153. package/dist/components/LpStat.vue.d.ts +16 -0
  154. package/dist/components/LpStat.vue.js +1 -1
  155. package/dist/components/LpSwitch.vue.d.ts +4 -0
  156. package/dist/components/LpSwitch.vue.js +1 -1
  157. package/dist/components/LpTable.vue.js +5 -5
  158. package/dist/components/LpTableFilter.vue.js +1 -1
  159. package/dist/components/LpTabs.vue.d.ts +1 -1
  160. package/dist/components/LpTabs.vue.js +1 -1
  161. package/dist/components/LpTagsInput.vue.js +1 -1
  162. package/dist/components/LpTextarea.vue.js +1 -1
  163. package/dist/components/LpThemeStudio.vue.js +1 -1
  164. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  165. package/dist/components/LpTimeField.vue.d.ts +4 -0
  166. package/dist/components/LpTimeField.vue.js +1 -1
  167. package/dist/components/LpToaster.vue.js +1 -1
  168. package/dist/components/LpTooltip.vue.js +1 -1
  169. package/dist/components/LpTopologyCanvas.vue.d.ts +2 -2
  170. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  171. package/dist/components/LpTruncate.vue.js +1 -1
  172. package/dist/components/LpUptimeBar.vue.js +1 -1
  173. package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
  174. package/dist/components/RevealToggle.vue.js +1 -1
  175. package/dist/components/calendar.d.ts +2 -0
  176. package/dist/components/menuWhen.js +1 -1
  177. package/dist/components/scrollbar.d.ts +34 -0
  178. package/dist/components/scrollbar.js +47 -0
  179. package/dist/composables/propCheck.d.ts +1 -1
  180. package/dist/composables/propCheck.js +15 -4
  181. package/dist/composables/useField.d.ts +50 -0
  182. package/dist/composables/useField.js +31 -2
  183. package/dist/composables/useMergedClass.d.ts +6 -2
  184. package/dist/composables/useMergedClass.js +6 -6
  185. package/dist/composables/usePaletteCommands.js +4 -2
  186. package/dist/index.d.ts +1 -0
  187. package/dist/index.js +170 -168
  188. package/package.json +1 -1
  189. package/src/component-names.ts +1 -0
  190. package/src/components/FieldLeading.vue +17 -0
  191. package/src/components/LpAddressInput.vue +8 -4
  192. package/src/components/LpAppShell.vue +23 -0
  193. package/src/components/LpAutocomplete.vue +11 -5
  194. package/src/components/LpCheckbox.vue +8 -6
  195. package/src/components/LpColorPicker.vue +7 -4
  196. package/src/components/LpContextMenu.vue +7 -0
  197. package/src/components/LpDatePicker.vue +8 -4
  198. package/src/components/LpDateRangePicker.vue +8 -4
  199. package/src/components/LpHoverCard.vue +7 -0
  200. package/src/components/LpInfraNode.vue +4 -3
  201. package/src/components/LpInput.vue +7 -7
  202. package/src/components/LpLaneNode.vue +5 -1
  203. package/src/components/LpNumberField.vue +18 -7
  204. package/src/components/LpOtpInput.vue +8 -6
  205. package/src/components/LpPageScrollbar.vue +195 -0
  206. package/src/components/LpPhoneInput.vue +11 -6
  207. package/src/components/LpRadioGroup.vue +9 -5
  208. package/src/components/LpRangeSlider.vue +8 -4
  209. package/src/components/LpRepeater.vue +16 -4
  210. package/src/components/LpScrollArea.vue +27 -23
  211. package/src/components/LpSegmented.vue +8 -4
  212. package/src/components/LpSelect.vue +8 -7
  213. package/src/components/LpServiceNode.vue +4 -2
  214. package/src/components/LpSlider.vue +8 -4
  215. package/src/components/LpStat.vue +25 -5
  216. package/src/components/LpSwitch.vue +8 -5
  217. package/src/components/LpTagsInput.vue +4 -3
  218. package/src/components/LpTextarea.vue +3 -2
  219. package/src/components/LpTimeField.vue +8 -6
  220. package/src/components/LpTooltip.vue +7 -0
  221. package/src/components/LpTopologyCanvas.vue +3 -1
  222. package/src/components/calendar.ts +2 -0
  223. package/src/components/scrollbar.ts +71 -0
  224. package/src/composables/propCheck.ts +20 -5
  225. package/src/composables/useField.ts +52 -0
  226. package/src/composables/useMergedClass.ts +11 -5
  227. package/src/composables/usePaletteCommands.ts +5 -2
  228. package/src/index.ts +1 -0
  229. package/dist/chunks/LpAddressInput-ZqoZ-N2Y.js +0 -393
  230. package/dist/chunks/LpAutocomplete-DkY6CSyD.js +0 -302
  231. package/dist/chunks/LpCheckbox-D6RuDW4X.js +0 -124
  232. package/dist/chunks/LpColorPicker-BxAHEnR5.js +0 -567
  233. package/dist/chunks/LpDatePicker-DwSDY0mt.js +0 -163
  234. package/dist/chunks/LpNumberField-CNHsmSDZ.js +0 -134
  235. package/dist/chunks/LpOtpInput-BRpLBbOD.js +0 -132
  236. package/dist/chunks/LpRadioGroup-BrazjLY_.js +0 -95
  237. package/dist/chunks/LpRangeSlider-fz61tzc-.js +0 -89
  238. package/dist/chunks/LpRepeater-BkJHSPI1.js +0 -180
  239. package/dist/chunks/LpSegmented-DbEJhbv-.js +0 -123
  240. package/dist/chunks/LpSelect-Y-iuSymW.js +0 -512
  241. package/dist/chunks/LpSlider-A4ixovgS.js +0 -68
  242. package/dist/chunks/LpSwitch-DnC2-0aV.js +0 -115
  243. package/dist/chunks/LpTimeField-DeOcKWJq.js +0 -110
@@ -0,0 +1,195 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * The kit's overlay scrollbar for the document itself, for a page that scrolls
4
+ * the window (LpAppShell's default): the same bar as LpScrollArea's, drawn by
5
+ * the kit over the content instead of the browser's beside it.
6
+ *
7
+ * <LpPageScrollbar top="4rem" />
8
+ *
9
+ * The browser's own bar is hidden while this is mounted, so the page no longer
10
+ * loses its width to a gutter, nor changes width when it starts to scroll. The
11
+ * scrolling stays the browser's: wheel, keyboard, touch, find-in-page, anchors,
12
+ * scroll restoration, sticky, a full-page screenshot and printing are untouched
13
+ * (the bar is not printed, and at rest it is not drawn). What the kit adds is
14
+ * the drawing, a thumb that drags, and a press beside it that pages.
15
+ *
16
+ * Where the browser's bars already float over the content (phones, macOS set
17
+ * to show them while scrolling) it draws nothing and leaves the browser's.
18
+ *
19
+ * `top` and `bottom` keep the bar clear of what covers the window's edges: a
20
+ * sticky header, a bottom bar. Any CSS length, a variable included, and a
21
+ * change eases.
22
+ */
23
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue"
24
+ import { mergeClass } from "../composables/useMergedClass"
25
+ import { nodeWindow } from "../composables/useView"
26
+ import { BAR_FADE, BAR_Y, BAR_Y_SIZE, hideNativeBar, pageToward, SCROLL_HIDE_DELAY_MS, THUMB_Y, THUMB_Y_ROUND } from "./scrollbar"
27
+
28
+ const props = withDefaults(defineProps<{ top?: string; bottom?: string }>(), { top: "0px", bottom: "0px" })
29
+
30
+ /* Shorter than this the thumb is hard to catch; reka's bar holds the same floor. */
31
+ const MIN_THUMB_PX = 18
32
+
33
+ const barEl = ref<HTMLElement | null>(null)
34
+ const active = ref(false)
35
+ const thumb = ref({ top: 0, height: 0 })
36
+ const scrollable = ref(false)
37
+ const scrolling = ref(false)
38
+ const hovered = ref(false)
39
+ const dragging = ref(false)
40
+ // Unset until the bar first shows: a bar mounting "hidden" would play its fade-out.
41
+ const state = computed(() => (scrolling.value || hovered.value || dragging.value ? "visible" : shownOnce.value ? "hidden" : undefined))
42
+ const shownOnce = ref(false)
43
+
44
+ let releaseNative: (() => void) | undefined
45
+ let doc: Document | undefined
46
+ let hideTimer: ReturnType<typeof setTimeout> | undefined
47
+ let frame = 0
48
+ let sizes: ResizeObserver | undefined
49
+
50
+ function scroller(): HTMLElement | null {
51
+ return (doc?.scrollingElement as HTMLElement | null) ?? null
52
+ }
53
+
54
+ /* The width the browser's bar takes from the layout: 0 where it floats over the content. */
55
+ function nativeBarWidth(d: Document): number {
56
+ const probe = d.createElement("div")
57
+ probe.style.cssText = "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll"
58
+ d.body.append(probe)
59
+ const width = probe.offsetWidth - probe.clientWidth
60
+ probe.remove()
61
+ return width
62
+ }
63
+
64
+ function measure() {
65
+ frame = 0
66
+ const view = scroller()
67
+ const bar = barEl.value
68
+ if (!view || !bar) return
69
+ const range = view.scrollHeight - view.clientHeight
70
+ scrollable.value = range > 1
71
+ if (!scrollable.value) return
72
+ const track = bar.clientHeight
73
+ const height = Math.max(MIN_THUMB_PX, Math.round((track * view.clientHeight) / view.scrollHeight))
74
+ thumb.value = { height, top: Math.round(((track - height) * view.scrollTop) / range) }
75
+ }
76
+ function measureSoon() {
77
+ if (!frame && doc) frame = nodeWindow(doc).requestAnimationFrame(measure)
78
+ }
79
+
80
+ function onScroll() {
81
+ measureSoon()
82
+ scrolling.value = true
83
+ shownOnce.value = true
84
+ clearTimeout(hideTimer)
85
+ hideTimer = setTimeout(() => (scrolling.value = false), SCROLL_HIDE_DELAY_MS)
86
+ }
87
+ function onEnter() {
88
+ hovered.value = true
89
+ shownOnce.value = true
90
+ }
91
+ function onLeave() {
92
+ hideTimer && clearTimeout(hideTimer)
93
+ hideTimer = setTimeout(() => {
94
+ hovered.value = false
95
+ scrolling.value = false
96
+ }, SCROLL_HIDE_DELAY_MS)
97
+ }
98
+
99
+ /* A dialog that locks the page locks the bar with it. */
100
+ function locked(): boolean {
101
+ return !!doc && nodeWindow(doc).getComputedStyle(doc.body).overflow === "hidden"
102
+ }
103
+
104
+ function onTrackPointerDown(event: PointerEvent) {
105
+ const view = scroller()
106
+ const thumbEl = barEl.value?.querySelector<HTMLElement>("[data-lp-thumb]")
107
+ if (!view || !thumbEl || event.button !== 0 || thumbEl.contains(event.target as Node) || locked()) return
108
+ event.preventDefault()
109
+ pageToward(view, "y", event.clientY < thumbEl.getBoundingClientRect().top)
110
+ }
111
+
112
+ function onThumbPointerDown(event: PointerEvent) {
113
+ const view = scroller()
114
+ const bar = barEl.value
115
+ if (!view || !bar || event.button !== 0 || locked()) return
116
+ // Not a text selection across the page, and the release is heard wherever it lands.
117
+ event.preventDefault()
118
+ const handle = event.currentTarget as HTMLElement
119
+ handle.setPointerCapture(event.pointerId)
120
+ dragging.value = true
121
+ const startY = event.clientY
122
+ const startTop = view.scrollTop
123
+ const perPixel = (view.scrollHeight - view.clientHeight) / Math.max(1, bar.clientHeight - thumb.value.height)
124
+ const move = (e: PointerEvent) => view.scrollTo({ top: startTop + (e.clientY - startY) * perPixel, behavior: "instant" })
125
+ const stop = () => {
126
+ dragging.value = false
127
+ handle.removeEventListener("pointermove", move)
128
+ handle.removeEventListener("pointerup", stop)
129
+ handle.removeEventListener("pointercancel", stop)
130
+ handle.removeEventListener("lostpointercapture", stop)
131
+ }
132
+ handle.addEventListener("pointermove", move)
133
+ handle.addEventListener("pointerup", stop)
134
+ handle.addEventListener("pointercancel", stop)
135
+ handle.addEventListener("lostpointercapture", stop)
136
+ }
137
+
138
+ onMounted(() => {
139
+ const host = barEl.value ?? undefined
140
+ doc = host?.ownerDocument
141
+ if (!doc || nativeBarWidth(doc) === 0) return
142
+ active.value = true
143
+ releaseNative = hideNativeBar(doc)
144
+ const win = nodeWindow(doc)
145
+ win.addEventListener("scroll", onScroll, { passive: true })
146
+ win.addEventListener("resize", measureSoon)
147
+ sizes = new win.ResizeObserver(measureSoon)
148
+ sizes.observe(doc.body)
149
+ if (host) sizes.observe(host)
150
+ measure()
151
+ })
152
+
153
+ onBeforeUnmount(() => {
154
+ clearTimeout(hideTimer)
155
+ sizes?.disconnect()
156
+ if (!doc || !active.value) return
157
+ const win = nodeWindow(doc)
158
+ if (frame) win.cancelAnimationFrame(frame)
159
+ win.removeEventListener("scroll", onScroll)
160
+ win.removeEventListener("resize", measureSoon)
161
+ releaseNative?.()
162
+ })
163
+
164
+ const barClass = computed(() =>
165
+ mergeClass(
166
+ BAR_Y,
167
+ BAR_Y_SIZE.wall,
168
+ BAR_FADE,
169
+ "fixed right-0 z-(--z-sticky) transition-[top,bottom] duration-[var(--duration-medium)] ease-[var(--ease-settle)] print:hidden",
170
+ ),
171
+ )
172
+ </script>
173
+
174
+ <template>
175
+ <!-- Always in the DOM, so it can be measured; drawn only where the kit owns the bar. -->
176
+ <div
177
+ ref="barEl"
178
+ aria-hidden="true"
179
+ data-lp-page-scrollbar
180
+ :class="barClass"
181
+ :data-state="active && scrollable ? state : undefined"
182
+ :style="{ top: props.top, bottom: props.bottom, visibility: active && scrollable ? undefined : 'hidden' }"
183
+ @pointerenter="onEnter"
184
+ @pointerleave="onLeave"
185
+ @pointerdown="onTrackPointerDown"
186
+ >
187
+ <div
188
+ data-lp-thumb
189
+ class="absolute right-px"
190
+ :class="[THUMB_Y, THUMB_Y_ROUND.wall]"
191
+ :style="{ top: `${thumb.top}px`, height: `${thumb.height}px` }"
192
+ @pointerdown.stop="onThumbPointerDown"
193
+ />
194
+ </div>
195
+ </template>
@@ -36,6 +36,7 @@ export type PhoneInputLabels = {
36
36
  * is what a form should store. Until the library arrives the field takes digits
37
37
  * as before, unformatted.
38
38
  */
39
+ import FieldWrapper from "./FieldWrapper.vue"
39
40
  import { CONTROL_SIZE } from "./controlSize"
40
41
  import {
41
42
  ComboboxAnchor,
@@ -48,8 +49,8 @@ import {
48
49
  ComboboxTrigger,
49
50
  ComboboxViewport,
50
51
  } from "reka-ui"
51
- import { computed, onMounted, ref, shallowRef } from "vue"
52
- import { useField, useAttrsField, mergeDescribedBy } from "../composables/useField"
52
+ import { computed, onMounted, ref, shallowRef, useAttrs } from "vue"
53
+ import { useField, useOwnField, mergeDescribedBy } from "../composables/useField"
53
54
  import { useFieldNotice, type FieldNoticeOptions } from "../composables/useFieldNotice"
54
55
  import { useInputFilter } from "../composables/useInputFilter"
55
56
  import { useLabels } from "../composables/useLpConfig"
@@ -101,6 +102,8 @@ const props = withDefaults(
101
102
  placeholder?: string
102
103
  disabled?: boolean
103
104
  invalid?: boolean
105
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
106
+ error?: string
104
107
  size?: "sm" | "md" | "lg"
105
108
  emptyText?: string
106
109
  searchPlaceholder?: string
@@ -322,7 +325,8 @@ const sizeClass = CONTROL_SIZE
322
325
  // placing it in a narrow column has to change, so `class="w-48"` must win.
323
326
  // style lays out the frame; every other attr (name, autocomplete, @blur…)
324
327
  // belongs to the number <input>.
325
- const { attrs, field } = useAttrsField(() => ({ invalid: props.invalid || flagged.value, disabled: props.disabled }))
328
+ const attrs = useAttrs()
329
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, invalid: props.invalid || flagged.value, disabled: props.disabled }))
326
330
  const text = useLabels<PhoneInputLabels>("phoneInput", {
327
331
  selectCountry: "Select country",
328
332
  placeholder: "Phone number",
@@ -345,8 +349,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
345
349
  ? "border-danger focus-within:border-danger focus-within:ring-danger-soft"
346
350
  : "border-line focus-within:border-brand",
347
351
  field.value.disabled ? "cursor-not-allowed opacity-55" : "",
348
- ].join(" "),
349
- )
352
+ ].join(" "), () => ownAttrs())
350
353
  const inputAttrs = computed(() => {
351
354
  const { style: _s, ...others } = rest.value
352
355
  return others
@@ -360,10 +363,11 @@ const guardOutside = useOutsideGuard()
360
363
  </script>
361
364
 
362
365
  <template>
366
+ <FieldWrapper v-bind="wrapper">
363
367
  <div
364
368
  data-lp-ring-owner
365
369
  :class="rootClass"
366
- :style="attrs.style as never"
370
+ :style="ownAttrs() ? (attrs.style as never) : undefined"
367
371
  >
368
372
  <!-- reset-search-term-on-{select,blur}: as in LpSelect, the trigger shows
369
373
  the selection and the box only filters, so reka seeding the chosen value
@@ -460,4 +464,5 @@ const guardOutside = useOutsideGuard()
460
464
  />
461
465
  <LpFieldNotice v-if="ownNotice" :notice="notice" :id="noticeId" />
462
466
  </div>
467
+ </FieldWrapper>
463
468
  </template>
@@ -6,10 +6,11 @@ export default { inheritAttrs: false }
6
6
  </script>
7
7
 
8
8
  <script setup lang="ts">
9
+ import FieldWrapper from "./FieldWrapper.vue"
9
10
  import { usePaletteControl, type PaletteControlOptions } from "../composables/usePaletteCommands"
10
11
  import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from "reka-ui"
11
12
  import { useId } from "vue"
12
- import { useFieldControl, mergeDescribedBy } from "../composables/useField"
13
+ import { useOwnField, mergeDescribedBy } from "../composables/useField"
13
14
  import { useMergedAttrs } from "../composables/useMergedClass"
14
15
  import LpIcon from "./LpIcon.vue"
15
16
  import type { LpOptionBase } from "./options"
@@ -22,21 +23,23 @@ export interface RadioOption extends LpOptionBase<string> {
22
23
  // a disabled option stays visible but cannot be picked. For anything richer
23
24
  // (badges, custom layout) pass <LpRadio> children in the default slot instead —
24
25
  // they share the same RadioGroupRoot, so selection still flows through v-model.
25
- const props = withDefaults(defineProps<{ palette?: PaletteControlOptions; modelValue?: string; options?: RadioOption[]; disabled?: boolean }>(), { palette: undefined })
26
+ const props = withDefaults(defineProps<{ palette?: PaletteControlOptions; modelValue?: string; options?: RadioOption[]; disabled?: boolean
27
+ /** A message under the group, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
28
+ error?: string }>(), { palette: undefined })
26
29
  defineEmits<{ (e: "update:modelValue", value: string): void }>()
27
30
 
28
- const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }))
31
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, disabled: props.disabled }))
29
32
  const uid = useId()
30
33
 
31
34
  // A radio group stacks with a fixed gap; a dense settings list needs it smaller, and a horizontal layout needs it gone.
32
35
  const { class: rootClass, attrs: rest } = useMergedAttrs(
33
- "flex flex-col gap-2",
34
- )
36
+ "flex flex-col gap-2", () => ownAttrs())
35
37
 
36
38
  usePaletteControl({ disabled: () => field.value.disabled })
37
39
  </script>
38
40
 
39
41
  <template>
42
+ <FieldWrapper v-bind="wrapper">
40
43
  <RadioGroupRoot
41
44
  :model-value="modelValue"
42
45
  :disabled="field.disabled"
@@ -82,4 +85,5 @@ usePaletteControl({ disabled: () => field.value.disabled })
82
85
  </label>
83
86
  </slot>
84
87
  </RadioGroupRoot>
88
+ </FieldWrapper>
85
89
  </template>
@@ -6,6 +6,7 @@ export default { inheritAttrs: false }
6
6
  </script>
7
7
 
8
8
  <script setup lang="ts">
9
+ import FieldWrapper from "./FieldWrapper.vue"
9
10
  import SliderRail from "./SliderRail.vue"
10
11
  import { usePaletteControl, type PaletteControlOptions } from "../composables/usePaletteCommands"
11
12
  /*
@@ -16,7 +17,7 @@ import { usePaletteControl, type PaletteControlOptions } from "../composables/us
16
17
  * focused or dragged, so the numbers are readable without a second readout.
17
18
  */
18
19
  import { SliderRoot, SliderThumb } from "reka-ui"
19
- import { useFieldControl, mergeDescribedBy } from "../composables/useField"
20
+ import { useOwnField, mergeDescribedBy } from "../composables/useField"
20
21
  import { useMergedAttrs } from "../composables/useMergedClass"
21
22
  import { SLIDER_GLIDE, useSliderDrag } from "./slider"
22
23
 
@@ -31,6 +32,8 @@ const props = withDefaults(
31
32
  /** The least distance the two ends keep between them, in model units. */
32
33
  minGap?: number
33
34
  disabled?: boolean
35
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
36
+ error?: string
34
37
  /** Accessible names for the two thumbs when there's no visible label. */
35
38
  ariaLabels?: [string, string]
36
39
  /** Label over each thumb while it is in use: `(value) => "1:05"`. */
@@ -58,11 +61,10 @@ function onCommit(v: number[] | undefined) {
58
61
  if (value) emit("change", value)
59
62
  }
60
63
 
61
- const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }))
64
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, disabled: props.disabled }))
62
65
 
63
66
  const { class: rootClass, attrs: rest } = useMergedAttrs(
64
- `relative flex h-5 w-full touch-none select-none items-center data-[disabled]:opacity-55 ${SLIDER_GLIDE}`,
65
- )
67
+ `relative flex h-5 w-full touch-none select-none items-center data-[disabled]:opacity-55 ${SLIDER_GLIDE}`, () => ownAttrs())
66
68
  const { dragging, onPointerDown } = useSliderDrag()
67
69
 
68
70
  const thumbClass =
@@ -74,6 +76,7 @@ usePaletteControl({ disabled: () => field.value.disabled })
74
76
  </script>
75
77
 
76
78
  <template>
79
+ <FieldWrapper v-bind="wrapper">
77
80
  <SliderRoot
78
81
  :model-value="[props.modelValue[0], props.modelValue[1]]"
79
82
  :min="min"
@@ -101,4 +104,5 @@ usePaletteControl({ disabled: () => field.value.disabled })
101
104
  <span v-if="format" :class="labelClass">{{ format(props.modelValue[index]) }}</span>
102
105
  </SliderThumb>
103
106
  </SliderRoot>
107
+ </FieldWrapper>
104
108
  </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts" generic="T extends object">
2
+ import FieldWrapper from "./FieldWrapper.vue"
2
3
  import { nextTick, ref } from "vue"
3
- import { useFieldControl } from "../composables/useField"
4
+ import { useOwnField, mergeDescribedBy } from "../composables/useField"
4
5
  import { useLabels } from "../composables/useLpConfig"
5
6
  import { useMergedAttrs } from "../composables/useMergedClass"
6
7
  import { useRepeater, type RepeaterItemKey } from "../composables/useRepeater"
@@ -17,6 +18,8 @@ const props = defineProps<{
17
18
  itemKey?: RepeaterItemKey<T>
18
19
  label?: string
19
20
  disabled?: boolean
21
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
22
+ error?: string
20
23
  labels?: Partial<{ add: string; remove: string; empty: string }>
21
24
  }>()
22
25
  const model = defineModel<T[]>({ default: () => [] })
@@ -25,7 +28,7 @@ defineSlots<{
25
28
  empty?(): unknown
26
29
  }>()
27
30
 
28
- const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }))
31
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, disabled: props.disabled }))
29
32
  const text = useLabels("repeater", { add: "Add item", remove: "Remove item {index}", empty: "No items" }, () => props.labels)
30
33
  const list = useRepeater({
31
34
  value: () => model.value,
@@ -78,11 +81,19 @@ async function remove(index: number) {
78
81
  else (addButton.value?.$el as HTMLElement | undefined)?.focus({ preventScroll: true })
79
82
  }
80
83
  defineExpose({ add, remove, update: list.update })
81
- const { class: rootClass, attrs: rest } = useMergedAttrs("flex min-w-0 flex-col gap-2")
84
+ const { class: rootClass, attrs: rest } = useMergedAttrs("flex min-w-0 flex-col gap-2", () => ownAttrs())
82
85
  </script>
83
86
 
84
87
  <template>
85
- <div v-bind="rest" :class="rootClass">
88
+ <FieldWrapper v-bind="wrapper">
89
+ <!-- A group, named by its label, so the field's message is read with it. -->
90
+ <div
91
+ v-bind="rest"
92
+ role="group"
93
+ :aria-label="label || undefined"
94
+ :aria-describedby="mergeDescribedBy(rest['aria-describedby'], field.describedBy)"
95
+ :class="rootClass"
96
+ >
86
97
  <div class="flex items-center justify-between gap-2">
87
98
  <span v-if="label" class="text-sm font-medium text-ink">{{ label }}</span>
88
99
  <LpButton ref="addButton" size="xs" variant="ghost" :disabled="field.disabled" @click="add">
@@ -105,6 +116,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs("flex min-w-0 flex-col
105
116
  </div>
106
117
  </LpShift>
107
118
  </div>
119
+ </FieldWrapper>
108
120
  </template>
109
121
 
110
122
  <style>
@@ -13,8 +13,20 @@ import {
13
13
  ScrollAreaThumb,
14
14
  ScrollAreaViewport,
15
15
  } from "reka-ui"
16
- import { prefersReducedMotion } from "../composables/easing"
17
16
  import { nodeWindow } from "../composables/useView"
17
+ import {
18
+ BAR_FADE,
19
+ BAR_X,
20
+ BAR_X_SIZE,
21
+ BAR_Y,
22
+ BAR_Y_SIZE,
23
+ pageToward,
24
+ SCROLL_HIDE_DELAY_MS,
25
+ THUMB_X,
26
+ THUMB_X_ROUND,
27
+ THUMB_Y,
28
+ THUMB_Y_ROUND,
29
+ } from "./scrollbar"
18
30
 
19
31
  // `fade` softens whichever of the top/bottom edges currently has content beyond
20
32
  // it, so a clipped list reads as continuing rather than as cut off. On by
@@ -80,8 +92,7 @@ function onViewportBlur() {
80
92
  // `opacity-0` + the hidden-state fade is what keeps the bar invisible at rest:
81
93
  // with force-mount the element stays in the DOM, so a one-shot fade-out alone
82
94
  // would snap back to visible once the animation finished.
83
- const barFade =
84
- "opacity-0 data-[state=visible]:animate-[fade-in_180ms_ease] data-[state=visible]:opacity-100 data-[state=hidden]:animate-[fade-out_240ms_ease]"
95
+ const edge = computed(() => (props.floating ? "floating" : "wall"))
85
96
 
86
97
  // Dragging the thumb pulls the pointer off the bar and across the content, and
87
98
  // the browser reads that as a text selection — the rows highlight blue while
@@ -197,13 +208,9 @@ onBeforeUnmount(() => {
197
208
  })
198
209
 
199
210
  /*
200
- * A press on the bar beside the thumb. reka treats any press on the track as
201
- * "put the thumb's middle under the pointer" and sets the scroll in one go,
202
- * so a press that just missed the narrow thumb yanked the content by half a
203
- * thumb, and a press further away teleported it. Like a native bar, a press
204
- * off the thumb now pages one viewport towards the pointer, smoothly; the
205
- * thumb itself has a hit area as wide as the bar (see its `before:`), so a
206
- * press that meant to grab it does.
211
+ * A press on the bar beside the thumb pages towards the pointer (pageToward)
212
+ * instead of reka's jump; the thumb's hit area is as wide as the bar (its
213
+ * `before:`), so a press that meant to grab it does.
207
214
  */
208
215
  function onTrackPointerDown(event: PointerEvent, axis: "x" | "y") {
209
216
  const el = viewportEl.value
@@ -213,10 +220,7 @@ function onTrackPointerDown(event: PointerEvent, axis: "x" | "y") {
213
220
  event.preventDefault()
214
221
  event.stopImmediatePropagation()
215
222
  const rect = thumb.getBoundingClientRect()
216
- const before = axis === "y" ? event.clientY < rect.top : event.clientX < rect.left
217
- const step = (axis === "y" ? el.clientHeight : el.clientWidth) * 0.85 * (before ? -1 : 1)
218
- const behavior = prefersReducedMotion() ? "auto" : "smooth"
219
- el.scrollBy(axis === "y" ? { top: step, behavior } : { left: step, behavior })
223
+ pageToward(el, axis, axis === "y" ? event.clientY < rect.top : event.clientX < rect.left)
220
224
  }
221
225
 
222
226
  let stopThumbDrag: (() => void) | null = null
@@ -253,7 +257,7 @@ function onThumbPointerDown(event: PointerEvent) {
253
257
  class="relative flex flex-col overflow-hidden"
254
258
  :class="dragging ? 'select-none' : undefined"
255
259
  type="hover"
256
- :scroll-hide-delay="500"
260
+ :scroll-hide-delay="SCROLL_HIDE_DELAY_MS"
257
261
  >
258
262
  <!-- The bar itself is a pointer target far wider than it looks: the visible
259
263
  rail is the thumb, sized below, while the track pads it out to a
@@ -273,8 +277,8 @@ function onThumbPointerDown(event: PointerEvent) {
273
277
  <ScrollAreaScrollbar
274
278
  orientation="vertical"
275
279
  force-mount
276
- class="group z-1 flex touch-none select-none justify-end pl-2.5"
277
- :class="[barFade, floating ? 'w-4 py-1 pr-1' : 'w-3.5 py-px pr-px']"
280
+ class="z-1"
281
+ :class="[BAR_Y, BAR_FADE, BAR_Y_SIZE[edge]]"
278
282
  :style="barInsetTop ? { marginTop: barInsetTop } : undefined"
279
283
  @pointerdown.capture="onTrackPointerDown($event, 'y')"
280
284
  >
@@ -286,22 +290,22 @@ function onThumbPointerDown(event: PointerEvent) {
286
290
  the positioning reka owns. -->
287
291
  <ScrollAreaThumb
288
292
  data-lp-thumb
289
- class="relative min-w-1 bg-brand/40 before:absolute before:inset-y-0 before:right-0 before:-left-2.5 before:content-[''] transition-[min-width,background-color] duration-[var(--duration-fast)] group-hover:min-w-1.5 group-hover:bg-brand"
290
- :class="floating ? 'rounded-pill' : 'rounded-l-pill'"
293
+ class="relative"
294
+ :class="[THUMB_Y, THUMB_Y_ROUND[edge]]"
291
295
  @pointerdown="onThumbPointerDown"
292
296
  />
293
297
  </ScrollAreaScrollbar>
294
298
 
295
299
  <ScrollAreaScrollbar
296
300
  orientation="horizontal"
297
- class="group z-1 flex flex-col touch-none select-none justify-end pt-2.5"
298
- :class="[barFade, floating ? 'h-4 px-1 pb-1' : 'h-3.5 px-px pb-px']"
301
+ class="z-1"
302
+ :class="[BAR_X, BAR_FADE, BAR_X_SIZE[edge]]"
299
303
  @pointerdown.capture="onTrackPointerDown($event, 'x')"
300
304
  >
301
305
  <ScrollAreaThumb
302
306
  data-lp-thumb
303
- class="relative min-h-1 bg-brand/40 before:absolute before:inset-x-0 before:bottom-0 before:-top-2.5 before:content-[''] transition-[min-height,background-color] duration-[var(--duration-fast)] group-hover:min-h-1.5 group-hover:bg-brand"
304
- :class="floating ? 'rounded-pill' : 'rounded-t-pill'"
307
+ class="relative"
308
+ :class="[THUMB_X, THUMB_X_ROUND[edge]]"
305
309
  @pointerdown="onThumbPointerDown"
306
310
  />
307
311
  </ScrollAreaScrollbar>
@@ -6,6 +6,7 @@ export default { inheritAttrs: false }
6
6
  </script>
7
7
 
8
8
  <script setup lang="ts">
9
+ import FieldWrapper from "./FieldWrapper.vue"
9
10
  import { CONTROL_SIZE } from "./controlSize"
10
11
  import { isHTMLElement } from "../composables/useView"
11
12
  import { usePaletteControl, type PaletteControlOptions } from "../composables/usePaletteCommands"
@@ -19,7 +20,7 @@ import { usePaletteControl, type PaletteControlOptions } from "../composables/us
19
20
  import { Motion } from "motion-v"
20
21
  import { ToggleGroupItem, ToggleGroupRoot } from "reka-ui"
21
22
  import { ref, useId } from "vue"
22
- import { useFieldControl, mergeDescribedBy } from "../composables/useField"
23
+ import { useOwnField, mergeDescribedBy } from "../composables/useField"
23
24
  import { useMergedAttrs } from "../composables/useMergedClass"
24
25
  import { usePillTransition } from "../composables/usePillTransition"
25
26
  import AttentionDot from "./AttentionDot.vue"
@@ -47,6 +48,8 @@ const props = withDefaults(
47
48
  /** Stretch to fill the container with equal-width segments. */
48
49
  block?: boolean
49
50
  disabled?: boolean
51
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
52
+ error?: string
50
53
  /**
51
54
  * The pill glides under the hovered segment and springs back on leave (the
52
55
  * default, as in LpTabs). False pins it to the selected segment and hover
@@ -80,10 +83,9 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
80
83
  // `w-fit`, not left to the parent: in a column a flex item stretches, and
81
84
  // the options sat on the left of an empty bar (the theme studio met it).
82
85
  props.block ? "flex w-full" : "w-fit",
83
- ].join(" "),
84
- )
86
+ ].join(" "), () => ownAttrs())
85
87
 
86
- const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }))
88
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, disabled: props.disabled }))
87
89
 
88
90
  const pillId = `lp-segmented-${useId()}`
89
91
  const pillTransition = usePillTransition()
@@ -114,6 +116,7 @@ usePaletteControl({ disabled: () => field.value.disabled })
114
116
  </script>
115
117
 
116
118
  <template>
119
+ <FieldWrapper v-bind="wrapper">
117
120
  <ToggleGroupRoot
118
121
  :model-value="modelValue"
119
122
  type="single"
@@ -152,4 +155,5 @@ usePaletteControl({ disabled: () => field.value.disabled })
152
155
  <AttentionDot v-if="opt.dot && opt.label" class="mt-1.5" />
153
156
  </ToggleGroupItem>
154
157
  </ToggleGroupRoot>
158
+ </FieldWrapper>
155
159
  </template>
@@ -39,7 +39,8 @@ import {
39
39
  ComboboxViewport,
40
40
  } from "reka-ui"
41
41
  import { computed, nextTick, reactive, ref, useAttrs, useId, watch } from "vue"
42
- import { useFieldBlur, useFieldControl, mergeDescribedBy } from "../composables/useField"
42
+ import FieldWrapper from "./FieldWrapper.vue"
43
+ import { useFieldBlur, useOwnField, mergeDescribedBy } from "../composables/useField"
43
44
  import { useLabels } from "../composables/useLpConfig"
44
45
  import { useMergedAttrs } from "../composables/useMergedClass"
45
46
  import { OPTION_ITEM, optionStagger, POPOVER_PANEL } from "./dropdown"
@@ -75,6 +76,8 @@ const props = withDefaults(
75
76
  clearable?: boolean
76
77
  disabled?: boolean
77
78
  invalid?: boolean
79
+ /** A message under the select, as on LpInput: it marks the select invalid (what `form.field()` hands it). */
80
+ error?: string
78
81
  emptyText?: string
79
82
  // Server-side search: bind with `v-model:searchTerm`. When provided, the
80
83
  // parent owns filtering (fetches matching `options` per term), so the
@@ -110,11 +113,7 @@ const props = withDefaults(
110
113
  const SIZE_HEIGHT = CONTROL_SIZE
111
114
  const anchorHeight = computed(() => SIZE_HEIGHT[props.size])
112
115
 
113
- const field = useFieldControl(() => ({
114
- wrapped: false,
115
- invalid: props.invalid,
116
- disabled: props.disabled,
117
- }))
116
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, invalid: props.invalid, disabled: props.disabled }))
118
117
  const labels = useLabels<SelectLabels>(
119
118
  "select",
120
119
  {
@@ -136,7 +135,7 @@ const emptyLabel = computed(() => props.emptyText ?? labels.value.empty)
136
135
  const loadingLabel = computed(() => props.loadingText ?? labels.value.loading)
137
136
 
138
137
  // The consumer's `class="w-40"` has to beat the root's own layout.
139
- const { class: rootClass, attrs: rootAttrs } = useMergedAttrs("relative")
138
+ const { class: rootClass, attrs: rootAttrs } = useMergedAttrs("relative", ownAttrs)
140
139
 
141
140
  const emit = defineEmits<{
142
141
  // Full v-model state (single value, array for multiple, or empty).
@@ -450,6 +449,7 @@ usePaletteControl({ disabled: () => field.value.disabled, invalid: () => field.v
450
449
  the seeding left the list pinned to one option until it was cleared by
451
450
  hand. Both flags default true and reach the same writer, so both must go:
452
451
  we own the term (`query`) and reka must not write to it. -->
452
+ <FieldWrapper v-bind="wrapper">
453
453
  <ComboboxRoot
454
454
  :model-value="comboModel"
455
455
  :multiple="multiple"
@@ -635,4 +635,5 @@ usePaletteControl({ disabled: () => field.value.disabled, invalid: () => field.v
635
635
  </ComboboxContent>
636
636
  </ComboboxPortal>
637
637
  </ComboboxRoot>
638
+ </FieldWrapper>
638
639
  </template>
@@ -5,7 +5,7 @@
5
5
  * depends_on edges connect these across host frames. Tailwind utilities only
6
6
  * (no scoped CSS) so styles reach consumer apps via @source.
7
7
  */
8
- import { Handle, Position } from "@vue-flow/core"
8
+ import { Handle, Position, type NodeProps } from "@vue-flow/core"
9
9
  import { computed } from "vue"
10
10
 
11
11
  export interface ServiceNodeData {
@@ -16,7 +16,9 @@ export interface ServiceNodeData {
16
16
  dimmed?: boolean
17
17
  }
18
18
 
19
- const props = defineProps<{ data: ServiceNodeData; selected?: boolean }>()
19
+ // Vue Flow hands a custom node all of NodeProps. Declared, they stop falling
20
+ // through onto the node's root as attributes (position="[object Object]").
21
+ const props = defineProps<Omit<NodeProps<ServiceNodeData>, "selected"> & { selected?: boolean }>()
20
22
 
21
23
  const kindVar: Record<string, string> = {
22
24
  service: "var(--color-brand)",