@leavepulse/ui 0.78.1 → 0.78.3

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 (147) hide show
  1. package/dist/chunks/{DropzoneLinkField-CLLhd3WB.js → DropzoneLinkField-CyE7TJ5f.js} +1 -1
  2. package/dist/chunks/LpAddressInput-ft8fQOGx.js +401 -0
  3. package/dist/chunks/{LpAppShell-BKbYCHkx.js → LpAppShell-DRHoQtr-.js} +34 -17
  4. package/dist/chunks/LpAutocomplete-CrZbd0uv.js +310 -0
  5. package/dist/chunks/LpCheckbox-CCWCwD0y.js +129 -0
  6. package/dist/chunks/{LpCodeDisplay-D3BUAhV1.js → LpCodeDisplay-D59uPwRb.js} +1 -1
  7. package/dist/chunks/LpColorPicker-BONURBFN.js +574 -0
  8. package/dist/chunks/LpDatePicker-C2AOtxgF.js +171 -0
  9. package/dist/chunks/{LpDateRangePicker-CNQXdTVv.js → LpDateRangePicker-CiXdIAyh.js} +43 -35
  10. package/dist/chunks/{LpFileTree-C-rJKzEQ.js → LpFileTree-CpNurwZp.js} +2 -2
  11. package/dist/chunks/{LpFileTreeNode-BgYn4U1J.js → LpFileTreeNode-DdF8Cq-s.js} +1 -1
  12. package/dist/chunks/{LpInput-D0O6dpuA.js → LpInput-CbI7z5fQ.js} +3 -4
  13. package/dist/chunks/{LpLogInspector-BdQlX6S-.js → LpLogInspector-CcSJSGan.js} +1 -1
  14. package/dist/chunks/{LpLogViewer-umCZBapN.js → LpLogViewer-CWu49Dko.js} +2 -2
  15. package/dist/chunks/{LpModalNav-gOi1Giz1.js → LpModalNav-DI3BW5ff.js} +1 -1
  16. package/dist/chunks/LpNumberField-C8LfUz_R.js +154 -0
  17. package/dist/chunks/LpOtpInput-B1X9l9no.js +140 -0
  18. package/dist/chunks/{LpPaletteTheme-BIXpZLGe.js → LpPaletteTheme-B-xuZiKK.js} +2 -2
  19. package/dist/chunks/{LpPasswordInput-D4HPhqfV.js → LpPasswordInput-Duc_3wPv.js} +1 -1
  20. package/dist/chunks/{LpPhoneInput-BSL7NX4z.js → LpPhoneInput-CctcRK9K.js} +124 -118
  21. package/dist/chunks/LpRadioGroup-DTM0nd9G.js +103 -0
  22. package/dist/chunks/LpRangeSlider-DJsEZXad.js +97 -0
  23. package/dist/chunks/LpRepeater-Czib3rmv.js +193 -0
  24. package/dist/chunks/{LpSecretField-B2roc4Iz.js → LpSecretField-BtlCk3IH.js} +1 -1
  25. package/dist/chunks/LpSegmented-sskrJKJJ.js +128 -0
  26. package/dist/chunks/LpSelect-JFZehOkd.js +515 -0
  27. package/dist/chunks/{LpSidebar-DDNJzb69.js → LpSidebar-CvRvVBTt.js} +1 -1
  28. package/dist/chunks/LpSlider-C4ilCtZX.js +76 -0
  29. package/dist/chunks/LpSwitch-DyAYTchz.js +122 -0
  30. package/dist/chunks/{LpTableFilter-B_XMOeAh.js → LpTableFilter-CJg5eIcV.js} +2 -2
  31. package/dist/chunks/{LpTagsInput-Dt0mteSj.js → LpTagsInput-CkNxpNYa.js} +4 -3
  32. package/dist/chunks/{LpTextarea-Co0b9PVx.js → LpTextarea-CFK_5QB8.js} +3 -2
  33. package/dist/chunks/{LpThemeStudio-ChU0_Ga8.js → LpThemeStudio-R-wohbCY.js} +2 -2
  34. package/dist/chunks/{LpThemeSwitcher-DmCZ21P_.js → LpThemeSwitcher-Bzy15MCP.js} +1 -1
  35. package/dist/chunks/LpTimeField-B5n0m8m0.js +115 -0
  36. package/dist/chunks/{LpTopologyCanvas-jqz1KCq2.js → LpTopologyCanvas-BNgw9RyZ.js} +2 -2
  37. package/dist/chunks/{LpWorkspaceSwitcher-BeNHCLd5.js → LpWorkspaceSwitcher-ugv-fyig.js} +1 -1
  38. package/dist/components/DropzoneLinkField.vue.js +1 -1
  39. package/dist/components/LpAddressInput.vue.d.ts +4 -0
  40. package/dist/components/LpAddressInput.vue.js +1 -1
  41. package/dist/components/LpAlert.vue.d.ts +3 -3
  42. package/dist/components/LpAppShell.vue.d.ts +19 -1
  43. package/dist/components/LpAppShell.vue.js +1 -1
  44. package/dist/components/LpAutocomplete.vue.d.ts +4 -0
  45. package/dist/components/LpAutocomplete.vue.js +1 -1
  46. package/dist/components/LpBadge.vue.d.ts +2 -2
  47. package/dist/components/LpChart.vue.d.ts +1 -1
  48. package/dist/components/LpCheckbox.vue.d.ts +4 -0
  49. package/dist/components/LpCheckbox.vue.js +1 -1
  50. package/dist/components/LpCodeDisplay.vue.js +1 -1
  51. package/dist/components/LpCollapse.vue.d.ts +1 -1
  52. package/dist/components/LpColorPicker.vue.d.ts +5 -3
  53. package/dist/components/LpColorPicker.vue.js +1 -1
  54. package/dist/components/LpDatePicker.vue.js +1 -1
  55. package/dist/components/LpDateRangePicker.vue.js +1 -1
  56. package/dist/components/LpDropzone.vue.js +1 -1
  57. package/dist/components/LpFileTree.vue.js +1 -1
  58. package/dist/components/LpFileTreeNode.vue.js +1 -1
  59. package/dist/components/LpImageEditor.vue.d.ts +1 -1
  60. package/dist/components/LpInput.vue.js +1 -1
  61. package/dist/components/LpLogInspector.vue.js +1 -1
  62. package/dist/components/LpLogViewer.vue.js +1 -1
  63. package/dist/components/LpModalNav.vue.js +1 -1
  64. package/dist/components/LpNumberField.vue.d.ts +4 -0
  65. package/dist/components/LpNumberField.vue.js +1 -1
  66. package/dist/components/LpOtpInput.vue.d.ts +4 -0
  67. package/dist/components/LpOtpInput.vue.js +1 -1
  68. package/dist/components/LpPaletteTheme.vue.js +1 -1
  69. package/dist/components/LpPasswordInput.vue.js +1 -1
  70. package/dist/components/LpPhoneInput.vue.d.ts +4 -0
  71. package/dist/components/LpPhoneInput.vue.js +1 -1
  72. package/dist/components/LpRadioGroup.vue.d.ts +4 -0
  73. package/dist/components/LpRadioGroup.vue.js +1 -1
  74. package/dist/components/LpRangeSlider.vue.d.ts +4 -0
  75. package/dist/components/LpRangeSlider.vue.js +1 -1
  76. package/dist/components/LpRepeater.vue.d.ts +2 -0
  77. package/dist/components/LpRepeater.vue.js +1 -1
  78. package/dist/components/LpScrollArea.vue.d.ts +1 -1
  79. package/dist/components/LpSecretField.vue.js +1 -1
  80. package/dist/components/LpSegmented.vue.d.ts +4 -0
  81. package/dist/components/LpSegmented.vue.js +1 -1
  82. package/dist/components/LpSelect.vue.d.ts +2 -0
  83. package/dist/components/LpSelect.vue.js +1 -1
  84. package/dist/components/LpShinyText.vue.d.ts +1 -1
  85. package/dist/components/LpSidebar.vue.d.ts +1 -1
  86. package/dist/components/LpSidebar.vue.js +1 -1
  87. package/dist/components/LpSlider.vue.d.ts +4 -0
  88. package/dist/components/LpSlider.vue.js +1 -1
  89. package/dist/components/LpSwitch.vue.d.ts +4 -0
  90. package/dist/components/LpSwitch.vue.js +1 -1
  91. package/dist/components/LpTable.vue.js +2 -2
  92. package/dist/components/LpTableFilter.vue.js +1 -1
  93. package/dist/components/LpTabs.vue.d.ts +1 -1
  94. package/dist/components/LpTagsInput.vue.js +1 -1
  95. package/dist/components/LpTextarea.vue.js +1 -1
  96. package/dist/components/LpThemeStudio.vue.js +1 -1
  97. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  98. package/dist/components/LpTimeField.vue.d.ts +4 -0
  99. package/dist/components/LpTimeField.vue.js +1 -1
  100. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  101. package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
  102. package/dist/components/calendar.d.ts +2 -0
  103. package/dist/composables/useField.d.ts +50 -0
  104. package/dist/composables/useField.js +31 -2
  105. package/dist/composables/useMergedClass.d.ts +6 -2
  106. package/dist/composables/useMergedClass.js +6 -6
  107. package/dist/index.js +34 -34
  108. package/package.json +1 -1
  109. package/src/components/LpAddressInput.vue +8 -4
  110. package/src/components/LpAppShell.vue +56 -18
  111. package/src/components/LpAutocomplete.vue +11 -5
  112. package/src/components/LpCheckbox.vue +8 -6
  113. package/src/components/LpColorPicker.vue +7 -4
  114. package/src/components/LpDatePicker.vue +8 -4
  115. package/src/components/LpDateRangePicker.vue +8 -4
  116. package/src/components/LpInput.vue +3 -4
  117. package/src/components/LpNumberField.vue +11 -6
  118. package/src/components/LpOtpInput.vue +8 -6
  119. package/src/components/LpPhoneInput.vue +11 -6
  120. package/src/components/LpRadioGroup.vue +9 -5
  121. package/src/components/LpRangeSlider.vue +8 -4
  122. package/src/components/LpRepeater.vue +16 -4
  123. package/src/components/LpSegmented.vue +8 -4
  124. package/src/components/LpSelect.vue +8 -7
  125. package/src/components/LpSlider.vue +8 -4
  126. package/src/components/LpSwitch.vue +8 -5
  127. package/src/components/LpTagsInput.vue +4 -3
  128. package/src/components/LpTextarea.vue +3 -2
  129. package/src/components/LpTimeField.vue +8 -6
  130. package/src/components/calendar.ts +2 -0
  131. package/src/composables/useField.ts +52 -0
  132. package/src/composables/useMergedClass.ts +11 -5
  133. package/dist/chunks/LpAddressInput-BSS49jpm.js +0 -393
  134. package/dist/chunks/LpAutocomplete-DkY6CSyD.js +0 -302
  135. package/dist/chunks/LpCheckbox-D6RuDW4X.js +0 -124
  136. package/dist/chunks/LpColorPicker-COHzV68R.js +0 -567
  137. package/dist/chunks/LpDatePicker-DwSDY0mt.js +0 -163
  138. package/dist/chunks/LpNumberField-Csg-1Zh_.js +0 -148
  139. package/dist/chunks/LpOtpInput-BRpLBbOD.js +0 -132
  140. package/dist/chunks/LpRadioGroup-BrazjLY_.js +0 -95
  141. package/dist/chunks/LpRangeSlider-fz61tzc-.js +0 -89
  142. package/dist/chunks/LpRepeater-BkJHSPI1.js +0 -180
  143. package/dist/chunks/LpSegmented-DbEJhbv-.js +0 -123
  144. package/dist/chunks/LpSelect-Y-iuSymW.js +0 -512
  145. package/dist/chunks/LpSlider-A4ixovgS.js +0 -68
  146. package/dist/chunks/LpSwitch-DnC2-0aV.js +0 -115
  147. package/dist/chunks/LpTimeField-DeOcKWJq.js +0 -110
@@ -7,7 +7,7 @@ import { CONTROL_SIZE } from "./controlSize"
7
7
  import { usePaletteControl, type PaletteControlOptions } from "../composables/usePaletteCommands"
8
8
  import { tv, type VariantProps } from "tailwind-variants"
9
9
  import { computed, ref, useAttrs, useSlots } from "vue"
10
- import { fieldWrapperBinds, isAttrOn, mergeDescribedBy, useFieldControl } from "../composables/useField"
10
+ import { fieldWrapperBinds, isAttrOn, mergeDescribedBy, useField, useFieldControl, wrapsItself } from "../composables/useField"
11
11
  import { useLabels } from "../composables/useLpConfig"
12
12
  import { useInputFilter } from "../composables/useInputFilter"
13
13
  import { DEFAULT_MASK_TOKENS, useInputMask, type MaskSpec, type MaskTokens } from "../composables/useInputMask"
@@ -138,9 +138,8 @@ const slots = useSlots()
138
138
  const attrs = useAttrs()
139
139
  const hasLeading = computed(() => !!slots.leading || !!props.icon)
140
140
  const hasTrailing = computed(() => !!slots.trailing || showClear.value)
141
- const hasField = computed(
142
- () => !!props.label || !!props.hint || !!props.error,
143
- )
141
+ const outerFieldOf = useField()
142
+ const hasField = computed(() => wrapsItself(props, outerFieldOf))
144
143
  const field = useFieldControl(() => ({
145
144
  wrapped: hasField.value,
146
145
  hasMessage: !!props.error || !!props.hint,
@@ -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 { usePaletteControl, type PaletteControlOptions } from "../composables/usePaletteCommands"
11
12
  // Numeric input with stepper buttons (reka NumberField). Themed via tokens.
@@ -15,10 +16,10 @@ import {
15
16
  NumberFieldInput,
16
17
  NumberFieldRoot,
17
18
  } from "reka-ui"
18
- import { computed, useId } from "vue"
19
+ import { computed, useId, useAttrs } from "vue"
19
20
  import FieldLeading from "./FieldLeading.vue"
20
21
  import LpIcon from "./LpIcon.vue"
21
- import { focusLeft, useAttrsField, mergeDescribedBy } from "../composables/useField"
22
+ import { focusLeft, useOwnField, mergeDescribedBy } from "../composables/useField"
22
23
  import { useLabels } from "../composables/useLpConfig"
23
24
  import { useMergedClass } from "../composables/useMergedClass"
24
25
 
@@ -71,6 +72,8 @@ const props = withDefaults(defineProps<{
71
72
  /** A leading icon, as on LpInput; the `leading` slot replaces it. */
72
73
  icon?: string
73
74
  invalid?: boolean
75
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
76
+ error?: string
74
77
  /** Control height, matching LpInput. */
75
78
  size?: "sm" | "md" | "lg"
76
79
  labels?: Partial<NumberFieldLabels>
@@ -99,7 +102,8 @@ const text = useLabels<NumberFieldLabels>(
99
102
 
100
103
  const SIZE = CONTROL_SIZE
101
104
 
102
- const { attrs, field } = useAttrsField(() => ({ invalid: props.invalid, disabled: props.disabled }))
105
+ const attrs = useAttrs()
106
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, invalid: props.invalid, disabled: props.disabled }))
103
107
  const describedBy = computed(
104
108
  () => mergeDescribedBy(attrs["aria-describedby"], field.value.describedBy, props.unit ? unitId : undefined),
105
109
  )
@@ -126,13 +130,13 @@ const rootClass = useMergedClass(() =>
126
130
  field.value.invalid
127
131
  ? "border-danger focus-within:border-danger focus-within:ring-danger-soft"
128
132
  : "border-line focus-within:border-brand focus-within:ring-ring",
129
- ].join(" "),
130
- )
133
+ ].join(" "), () => ownAttrs())
131
134
 
132
135
  usePaletteControl({ disabled: () => field.value.disabled, invalid: () => field.value.invalid })
133
136
  </script>
134
137
 
135
138
  <template>
139
+ <FieldWrapper v-bind="wrapper">
136
140
  <NumberFieldRoot
137
141
  :model-value="modelValue ?? undefined"
138
142
  :min="min"
@@ -149,7 +153,7 @@ usePaletteControl({ disabled: () => field.value.disabled, invalid: () => field.v
149
153
  :required="field.required"
150
154
  data-lp-ring-owner
151
155
  :class="rootClass"
152
- :style="attrs.style"
156
+ :style="ownAttrs() ? attrs.style : undefined"
153
157
  :name="typeof attrs.name === 'string' ? attrs.name : undefined"
154
158
  @update:model-value="(v) => emit('update:modelValue', Number.isNaN(v) ? null : (v ?? null))"
155
159
  @focusout="onFocusOut"
@@ -192,4 +196,5 @@ usePaletteControl({ disabled: () => field.value.disabled, invalid: () => field.v
192
196
  />
193
197
  </NumberFieldIncrement>
194
198
  </NumberFieldRoot>
199
+ </FieldWrapper>
195
200
  </template>
@@ -19,9 +19,10 @@ export default { inheritAttrs: false }
19
19
  * size's width and no further, and shrinks with the row when the container is
20
20
  * narrower, so eight cells fit a phone instead of running off its edge.
21
21
  */
22
+ import FieldWrapper from "./FieldWrapper.vue"
22
23
  import { PinInputInput, PinInputRoot } from "reka-ui"
23
24
  import { computed, ref, watch } from "vue"
24
- import { useFieldControl, mergeDescribedBy } from "../composables/useField"
25
+ import { useOwnField, mergeDescribedBy } from "../composables/useField"
25
26
  import { useMergedAttrs } from "../composables/useMergedClass"
26
27
  import { groupBreaks, joinCells, splitCode, upperCells, type OtpCells } from "./otp"
27
28
 
@@ -50,6 +51,8 @@ const props = withDefaults(
50
51
  group?: number | number[]
51
52
  /** Error state — tints the cells red (e.g. wrong code). */
52
53
  invalid?: boolean
54
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
55
+ error?: string
53
56
  size?: "sm" | "md" | "lg"
54
57
  }>(),
55
58
  { length: 6, size: "md", alphanumeric: false, uppercase: undefined },
@@ -97,9 +100,7 @@ const cells = computed<string[]>({
97
100
  },
98
101
  })
99
102
 
100
- const field = useFieldControl(() => ({
101
- wrapped: false,
102
- invalid: props.invalid,
103
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, invalid: props.invalid,
103
104
  disabled: props.disabled,
104
105
  }))
105
106
 
@@ -144,11 +145,11 @@ const cellSize = {
144
145
  }
145
146
 
146
147
  const { class: rootClass, attrs: rest } = useMergedAttrs(
147
- "flex min-w-0 max-w-full items-center gap-1.5 sm:gap-2",
148
- )
148
+ "flex min-w-0 max-w-full items-center gap-1.5 sm:gap-2", () => ownAttrs())
149
149
  </script>
150
150
 
151
151
  <template>
152
+ <FieldWrapper v-bind="wrapper">
152
153
  <PinInputRoot
153
154
  ref="root"
154
155
  v-model="cells"
@@ -188,4 +189,5 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
188
189
  />
189
190
  </template>
190
191
  </PinInputRoot>
192
+ </FieldWrapper>
191
193
  </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>
@@ -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>
@@ -6,12 +6,13 @@ 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
  // Single-value slider (reka Slider). reka models value as an array; we expose
12
13
  // a plain number for the common single-thumb case. Themed via tokens.
13
14
  import { SliderRoot, SliderThumb } from "reka-ui"
14
- import { useFieldControl, mergeDescribedBy } from "../composables/useField"
15
+ import { useOwnField, mergeDescribedBy } from "../composables/useField"
15
16
  import { useMergedAttrs } from "../composables/useMergedClass"
16
17
  import { SLIDER_GLIDE, useSliderDrag } from "./slider"
17
18
 
@@ -24,6 +25,8 @@ const props = withDefaults(
24
25
  max?: number
25
26
  step?: number
26
27
  disabled?: boolean
28
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
29
+ error?: string
27
30
  /** Accessible name for the thumb when there's no visible label. */
28
31
  ariaLabel?: string
29
32
  }>(),
@@ -44,18 +47,18 @@ function onCommit(v: number[] | undefined) {
44
47
  if (v && v.length) emit("change", v[0])
45
48
  }
46
49
 
47
- const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }))
50
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, disabled: props.disabled }))
48
51
 
49
52
  // A slider is full-width by default; putting one in a toolbar means constraining it, and a fixed h-5 has to yield to a denser row.
50
53
  const { class: rootClass, attrs: rest } = useMergedAttrs(
51
- `relative flex h-5 w-full touch-none select-none items-center data-[disabled]:opacity-55 ${SLIDER_GLIDE}`,
52
- )
54
+ `relative flex h-5 w-full touch-none select-none items-center data-[disabled]:opacity-55 ${SLIDER_GLIDE}`, () => ownAttrs())
53
55
  const { dragging, onPointerDown } = useSliderDrag()
54
56
 
55
57
  usePaletteControl({ disabled: () => field.value.disabled })
56
58
  </script>
57
59
 
58
60
  <template>
61
+ <FieldWrapper v-bind="wrapper">
59
62
  <SliderRoot
60
63
  :model-value="[props.modelValue]"
61
64
  :min="min"
@@ -78,4 +81,5 @@ usePaletteControl({ disabled: () => field.value.disabled })
78
81
  class="block size-4 rounded-pill border-2 border-brand bg-surface shadow outline-none transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
79
82
  />
80
83
  </SliderRoot>
84
+ </FieldWrapper>
81
85
  </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 { computed, useId, useSlots } from "vue"
11
12
  import { SwitchRoot, SwitchThumb } from "reka-ui"
12
- import { mergeDescribedBy, useFieldControl } from "../composables/useField"
13
+ import { mergeDescribedBy, useOwnField } from "../composables/useField"
13
14
  import { useOptimisticToggle } from "../composables/useOptimisticToggle"
14
15
  import LpLoader from "./LpLoader.vue"
15
16
  import { useMergedAttrs } from "../composables/useMergedClass"
@@ -20,6 +21,8 @@ const props = withDefaults(
20
21
  palette?: PaletteControlOptions
21
22
  modelValue?: boolean
22
23
  disabled?: boolean
24
+ /** A message under the control, as on LpInput: it marks it invalid and shows under it (what `form.field()` hands it). */
25
+ error?: string
23
26
  label?: string
24
27
  /** Secondary line under the label. */
25
28
  description?: string
@@ -67,13 +70,11 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() => {
67
70
  return props.labelPosition === "start"
68
71
  ? "flex w-full cursor-pointer items-center justify-between gap-3 text-sm text-ink"
69
72
  : "inline-flex cursor-pointer items-center gap-2 text-sm text-ink"
70
- })
73
+ }, () => ownAttrs())
71
74
 
72
75
  // Whatever is not class (name, aria-*, @focus…) belongs to the switch itself,
73
76
  // labelled or not: on the label row it would land on the <label>.
74
- const field = useFieldControl(() => ({
75
- wrapped: false,
76
- id: typeof rest.value.id === "string" ? rest.value.id : undefined,
77
+ const { field, wrapper, ownAttrs } = useOwnField(() => ({ error: props.error, id: typeof rest.value.id === "string" ? rest.value.id : undefined,
77
78
  disabled: props.disabled,
78
79
  }))
79
80
  const descriptionId = `${useId()}-description`
@@ -89,6 +90,7 @@ usePaletteControl({ label: () => props.label, disabled: () => field.value.disabl
89
90
  </script>
90
91
 
91
92
  <template>
93
+ <FieldWrapper v-bind="wrapper">
92
94
  <component :is="labelled ? 'label' : 'span'" :class="labelled ? [rootClass, field.disabled && 'cursor-not-allowed'] : 'contents'">
93
95
  <span v-if="labelled && labelPosition === 'start'" class="flex min-w-0 flex-col">
94
96
  <slot>{{ label }}</slot>
@@ -121,4 +123,5 @@ usePaletteControl({ label: () => props.label, disabled: () => field.value.disabl
121
123
  <span v-if="description" :id="descriptionId" class="text-xs text-muted">{{ description }}</span>
122
124
  </span>
123
125
  </component>
126
+ </FieldWrapper>
124
127
  </template>
@@ -39,7 +39,7 @@ import {
39
39
  TagsInputRoot,
40
40
  } from "reka-ui"
41
41
  import { computed, ref, useAttrs, useId, watch } from "vue"
42
- import { fieldWrapperBinds, fieldMessageId, useField, useFieldControl, mergeDescribedBy } from "../composables/useField"
42
+ import { wrapsItself, fieldWrapperBinds, fieldMessageId, useField, useFieldControl, mergeDescribedBy } from "../composables/useField"
43
43
  import { prefersReducedMotion } from "../composables/easing"
44
44
  import { useFieldNotice, type FieldNoticeOptions } from "../composables/useFieldNotice"
45
45
  import { useShift } from "../composables/useShift"
@@ -137,7 +137,8 @@ const text = useLabels<TagsInputLabels>(
137
137
  )
138
138
 
139
139
  const attrs = useAttrs()
140
- const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
140
+ const outerFieldOf = useField()
141
+ const hasField = computed(() => wrapsItself(props, outerFieldOf))
141
142
  /*
142
143
  * Notes go to the field that frames the control, which fades its own hint out
143
144
  * under them: this component's own LpFormField when it has a label or hint,
@@ -145,7 +146,7 @@ const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
145
146
  * alone.
146
147
  */
147
148
  const notice = useFieldNotice()
148
- const outerField = useField()
149
+ const outerField = outerFieldOf
149
150
  type FieldHandle = { notify: (text: string, options?: FieldNoticeOptions) => void; clearNotice: () => void }
150
151
  const ownField = ref<FieldHandle | null>(null)
151
152
  function bindField(c: unknown): void {
@@ -6,7 +6,7 @@ export default { inheritAttrs: false }
6
6
  import { nodeWindow } from "../composables/realm"
7
7
  import { usePaletteControl, type PaletteControlOptions } from "../composables/usePaletteCommands"
8
8
  import { computed, nextTick, onMounted, ref, useAttrs, watch } from "vue"
9
- import { fieldWrapperBinds, useFieldControl, mergeDescribedBy } from "../composables/useField"
9
+ import { wrapsItself, useField, fieldWrapperBinds, useFieldControl, mergeDescribedBy } from "../composables/useField"
10
10
  import { mergeClass } from "../composables/useMergedClass"
11
11
  import FieldWrapper from "./FieldWrapper.vue"
12
12
 
@@ -37,7 +37,8 @@ const props = withDefaults(defineProps<{
37
37
 
38
38
  defineEmits<{ (e: "update:modelValue", value: string): void }>()
39
39
 
40
- const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
40
+ const outerFieldOf = useField()
41
+ const hasField = computed(() => wrapsItself(props, outerFieldOf))
41
42
 
42
43
  // class/style lay out the field when there is one, and style the <textarea>
43
44
  // when it stands alone; every other attr (name, maxlength, autofocus, @blur…)