@leavepulse/ui 0.70.0 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +12 -0
  2. package/dist/assets/LpRepeater-C1Cw27hj.css +21 -0
  3. package/dist/assets/{LpRollingText-BGnE6q9j.css → LpRollingText-dyOXedGO.css} +7 -0
  4. package/dist/chunks/{ContextMenuItems-B5uE8Znw.js → ContextMenuItems-6kxEivBI.js} +10 -4
  5. package/dist/chunks/{LpAddressInput-CNnLTDZg.js → LpAddressInput-Chv_Xf8q.js} +1 -1
  6. package/dist/chunks/{LpAppShell-CEUAB8kK.js → LpAppShell-BiWByLmt.js} +1 -1
  7. package/dist/chunks/{LpBadge-HOEPBYmi.js → LpBadge-BBq1Er9T.js} +1 -1
  8. package/dist/chunks/{LpBreadcrumbs-DYoPc3P4.js → LpBreadcrumbs-JKEi_yFF.js} +1 -1
  9. package/dist/chunks/{LpCodeDisplay-CIKKT2iW.js → LpCodeDisplay-CbJMs-IK.js} +2 -2
  10. package/dist/chunks/{LpColorPicker-DFugXNUi.js → LpColorPicker-B7HSIFmn.js} +2 -2
  11. package/dist/chunks/{LpContextMenu-COwOwRgI.js → LpContextMenu-Cj96ENDC.js} +5 -4
  12. package/dist/chunks/{LpCountdown-Dtq1ihlJ.js → LpCountdown-BQnmB4gq.js} +1 -1
  13. package/dist/chunks/{LpDataList-B-5nHfNw.js → LpDataList-CHNYQw7h.js} +4 -3
  14. package/dist/chunks/{LpDatePicker-BUe1VUC0.js → LpDatePicker-ac1_6o5P.js} +4 -3
  15. package/dist/chunks/{LpDateRangePicker-C_k65vGR.js → LpDateRangePicker-B15gYrZJ.js} +4 -3
  16. package/dist/chunks/{LpDropdownMenu-CRgrhdAH.js → LpDropdownMenu-R4aYVtVV.js} +5 -2
  17. package/dist/chunks/{LpEditable-DTqLt2lT.js → LpEditable-Ws3DNQHn.js} +5 -3
  18. package/dist/chunks/{LpFileTree-DMISXVHC.js → LpFileTree-DDNg34tJ.js} +1 -1
  19. package/dist/chunks/{LpFileTreeNode-CPXB_Yy3.js → LpFileTreeNode-DmPuYNZ_.js} +2 -2
  20. package/dist/chunks/{LpGauge-Bk8TwJj0.js → LpGauge-C14Z9503.js} +1 -1
  21. package/dist/chunks/{LpHintLayer-p4hqzdyA.js → LpHintLayer-HvwUA5Ec.js} +1 -1
  22. package/dist/chunks/{LpHints-CCrhRfy9.js → LpHints-DxbPKOzQ.js} +2 -2
  23. package/dist/chunks/{LpInfraNode-CUC3t4na.js → LpInfraNode-D64hywJI.js} +2 -2
  24. package/dist/chunks/{LpLogViewer-elZHbj85.js → LpLogViewer-DKK4ySZl.js} +2 -2
  25. package/dist/chunks/{LpMediaGallery-D5swlmZ_.js → LpMediaGallery-rmI1DjKN.js} +1 -1
  26. package/dist/chunks/{LpModalNav-D4Z0wVHR.js → LpModalNav-DiyFCHiZ.js} +1 -1
  27. package/dist/chunks/{LpNotificationBell-DPZmP5dM.js → LpNotificationBell-D-WhE5m2.js} +5 -5
  28. package/dist/chunks/{LpPasswordInput-DTV18FIh.js → LpPasswordInput-GL2KOFK8.js} +10 -6
  29. package/dist/chunks/{LpPie-CFuV7pZv.js → LpPie-BgXZ3xmm.js} +2 -2
  30. package/dist/chunks/{LpPipeline-D81yh_NX.js → LpPipeline-B31h41Mu.js} +2 -2
  31. package/dist/chunks/{LpPopover-BEIcqb9p.js → LpPopover-DmZ3wUtP.js} +6 -5
  32. package/dist/chunks/{LpQuota-2Sw_gqcJ.js → LpQuota-DaL8TgNe.js} +1 -1
  33. package/dist/chunks/{LpRelativeTime-CKzVqAo9.js → LpRelativeTime-yin9uQP7.js} +1 -1
  34. package/dist/chunks/LpRepeater-D0S7jjwr.js +180 -0
  35. package/dist/chunks/{LpRollingText-DB7upf-5.js → LpRollingText-DiL717LK.js} +4 -3
  36. package/dist/chunks/{LpSecretField-DMfdJ1cQ.js → LpSecretField-CfWyHv9m.js} +19 -10
  37. package/dist/chunks/{LpSelect-B4f_yRi5.js → LpSelect-C_8cJtj6.js} +10 -15
  38. package/dist/chunks/{LpSidebar-D-M0Y_7U.js → LpSidebar-CZRXtvem.js} +1 -1
  39. package/dist/chunks/{LpSidebarNav-DF-TYxOV.js → LpSidebarNav-DBg1U1oW.js} +2 -2
  40. package/dist/chunks/{LpStat-B2ix1Q7t.js → LpStat-DFKlPJgy.js} +1 -1
  41. package/dist/chunks/{LpStatus-C7kx5PKE.js → LpStatus-BsGLrpor.js} +2 -2
  42. package/dist/chunks/{LpStepMark-DK-5BKAt.js → LpStepMark-TrzOioa9.js} +1 -1
  43. package/dist/chunks/{LpStepper-DRFfdJaq.js → LpStepper-BbnS5jUr.js} +1 -1
  44. package/dist/chunks/{LpTagsInput-DgpBFRV2.js → LpTagsInput-DZg80W-B.js} +1 -1
  45. package/dist/chunks/{LpThemeStudio-DguwHdGt.js → LpThemeStudio-DxVZMtzN.js} +1 -1
  46. package/dist/chunks/{LpThemeSwitcher-D5uz0MhK.js → LpThemeSwitcher-CadVBTBn.js} +3 -3
  47. package/dist/chunks/{LpTopologyCanvas-ZhKthfgz.js → LpTopologyCanvas-C17A1XCb.js} +1 -1
  48. package/dist/chunks/{LpTour-NfRJNEIP.js → LpTour-CY2bFXge.js} +1 -1
  49. package/dist/chunks/{LpUptimeBar-DWegV2p6.js → LpUptimeBar-Tew1Ykx4.js} +1 -1
  50. package/dist/chunks/{PickerField-CHRS1h7K.js → PickerField-DoHv0WMH.js} +25 -16
  51. package/dist/chunks/{TourCard-BXuVHsfn.js → TourCard-BJ8i7dk0.js} +1 -1
  52. package/dist/components/ContextMenuItems.vue.js +1 -1
  53. package/dist/components/LpAddressInput.vue.js +1 -1
  54. package/dist/components/LpAppShell.vue.js +1 -1
  55. package/dist/components/LpBadge.vue.js +1 -1
  56. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  57. package/dist/components/LpChart.vue.js +26 -24
  58. package/dist/components/LpCodeDisplay.vue.js +1 -1
  59. package/dist/components/LpColorPicker.vue.js +1 -1
  60. package/dist/components/LpContextMenu.vue.js +1 -1
  61. package/dist/components/LpCountdown.vue.js +1 -1
  62. package/dist/components/LpDataList.vue.js +1 -1
  63. package/dist/components/LpDatePicker.vue.d.ts +2 -0
  64. package/dist/components/LpDatePicker.vue.js +1 -1
  65. package/dist/components/LpDateRangePicker.vue.d.ts +2 -0
  66. package/dist/components/LpDateRangePicker.vue.js +1 -1
  67. package/dist/components/LpDropdownMenu.vue.js +1 -1
  68. package/dist/components/LpEditable.vue.js +1 -1
  69. package/dist/components/LpFileTree.vue.js +1 -1
  70. package/dist/components/LpFileTreeNode.vue.js +1 -1
  71. package/dist/components/LpGauge.vue.js +1 -1
  72. package/dist/components/LpHintLayer.vue.js +1 -1
  73. package/dist/components/LpHints.vue.js +1 -1
  74. package/dist/components/LpInfraNode.vue.js +1 -1
  75. package/dist/components/LpLogViewer.vue.js +1 -1
  76. package/dist/components/LpMediaGallery.vue.js +1 -1
  77. package/dist/components/LpModalNav.vue.js +1 -1
  78. package/dist/components/LpNotificationBell.vue.js +1 -1
  79. package/dist/components/LpPaletteFacts.vue.js +1 -1
  80. package/dist/components/LpPaletteStats.vue.js +1 -1
  81. package/dist/components/LpPasswordInput.vue.d.ts +7 -1
  82. package/dist/components/LpPasswordInput.vue.js +1 -1
  83. package/dist/components/LpPie.vue.js +1 -1
  84. package/dist/components/LpPipeline.vue.js +1 -1
  85. package/dist/components/LpPopover.vue.js +1 -1
  86. package/dist/components/LpQuota.vue.js +1 -1
  87. package/dist/components/LpRelativeTime.vue.js +1 -1
  88. package/dist/components/LpRepeater.vue.js +1 -1
  89. package/dist/components/LpRollingText.vue.d.ts +2 -0
  90. package/dist/components/LpRollingText.vue.js +1 -1
  91. package/dist/components/LpSecretField.vue.js +1 -1
  92. package/dist/components/LpSelect.vue.js +1 -1
  93. package/dist/components/LpSidebar.vue.js +1 -1
  94. package/dist/components/LpSidebarNav.vue.js +1 -1
  95. package/dist/components/LpStat.vue.js +1 -1
  96. package/dist/components/LpStatus.vue.js +1 -1
  97. package/dist/components/LpStepMark.vue.js +1 -1
  98. package/dist/components/LpStepper.vue.js +1 -1
  99. package/dist/components/LpTable.vue.js +4 -4
  100. package/dist/components/LpTabs.vue.js +3 -3
  101. package/dist/components/LpTagsInput.vue.js +1 -1
  102. package/dist/components/LpThemeStudio.vue.js +1 -1
  103. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  104. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  105. package/dist/components/LpTour.vue.js +1 -1
  106. package/dist/components/LpUptimeBar.vue.js +1 -1
  107. package/dist/components/PickerField.vue.d.ts +5 -3
  108. package/dist/components/PickerField.vue.js +1 -1
  109. package/dist/components/TourCard.vue.js +1 -1
  110. package/dist/components/menuWhen.js +1 -1
  111. package/dist/composables/useField.d.ts +5 -0
  112. package/dist/composables/useField.js +21 -1
  113. package/dist/composables/useForm.js +13 -4
  114. package/dist/composables/useView.js +4 -1
  115. package/dist/index.js +43 -43
  116. package/package.json +4 -1
  117. package/src/components/ContextMenuItems.vue +5 -2
  118. package/src/components/LpChart.vue +19 -17
  119. package/src/components/LpContextMenu.vue +4 -3
  120. package/src/components/LpDataList.vue +2 -2
  121. package/src/components/LpDatePicker.vue +2 -0
  122. package/src/components/LpDateRangePicker.vue +2 -0
  123. package/src/components/LpDropdownMenu.vue +1 -1
  124. package/src/components/LpEditable.vue +3 -1
  125. package/src/components/LpPasswordInput.vue +10 -5
  126. package/src/components/LpPopover.vue +4 -0
  127. package/src/components/LpRepeater.vue +64 -13
  128. package/src/components/LpRollingText.vue +12 -1
  129. package/src/components/LpSecretField.vue +15 -8
  130. package/src/components/LpSelect.vue +4 -9
  131. package/src/components/PickerField.vue +5 -4
  132. package/src/composables/useField.ts +26 -1
  133. package/src/composables/useForm.ts +14 -5
  134. package/src/composables/useView.ts +3 -0
  135. package/dist/chunks/LpRepeater-D0VjmD6y.js +0 -140
@@ -4,8 +4,10 @@ import { useFieldControl } from "../composables/useField"
4
4
  import { useLabels } from "../composables/useLpConfig"
5
5
  import { useMergedAttrs } from "../composables/useMergedClass"
6
6
  import { useRepeater, type RepeaterItemKey } from "../composables/useRepeater"
7
+ import { usePrefersReducedMotion } from "../composables/useReducedMotion"
7
8
  import LpButton from "./LpButton.vue"
8
9
  import LpIcon from "./LpIcon.vue"
10
+ import LpShift from "./LpShift.vue"
9
11
 
10
12
  defineOptions({ inheritAttrs: false })
11
13
  const props = defineProps<{
@@ -33,11 +35,31 @@ const list = useRepeater({
33
35
  disabled: () => field.value.disabled,
34
36
  })
35
37
  const { rows } = list
36
- const root = ref<HTMLElement | null>(null)
38
+ const rowsHost = ref<HTMLElement | null>(null)
39
+ const reducedMotion = usePrefersReducedMotion()
37
40
  const addButton = ref<InstanceType<typeof LpButton> | null>(null)
38
41
  const focusable = 'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex="0"], [contenteditable="true"]'
39
42
  function focusRow(index: number) {
40
- root.value?.querySelectorAll<HTMLElement>("[data-lp-repeater-row]")[index]?.querySelector<HTMLElement>(focusable)?.focus()
43
+ rowAt(index)?.querySelector<HTMLElement>(focusable)?.focus({ preventScroll: true })
44
+ }
45
+ function rowAt(index: number): HTMLElement | undefined {
46
+ return Array.from(rowsHost.value?.children ?? []).filter((node): node is HTMLElement =>
47
+ node.hasAttribute("data-lp-repeater-row") && !node.hasAttribute("inert"),
48
+ )[index]
49
+ }
50
+ function beforeLeave(el: Element) {
51
+ const row = el as HTMLElement
52
+ row.style.width = `${row.offsetWidth}px`
53
+ row.style.top = `${row.offsetTop}px`
54
+ row.setAttribute("inert", "")
55
+ row.setAttribute("aria-hidden", "true")
56
+ }
57
+ function beforeEnter(el: Element) {
58
+ const row = el as HTMLElement
59
+ row.removeAttribute("inert")
60
+ row.removeAttribute("aria-hidden")
61
+ row.style.removeProperty("top")
62
+ row.style.removeProperty("width")
41
63
  }
42
64
  async function add() {
43
65
  if (field.value.disabled) return
@@ -47,34 +69,63 @@ async function add() {
47
69
  }
48
70
  async function remove(index: number) {
49
71
  if (field.value.disabled) return
50
- const row = root.value?.querySelectorAll<HTMLElement>("[data-lp-repeater-row]")[index]
72
+ const row = rowAt(index)
51
73
  const restore = row?.contains(row.ownerDocument.activeElement)
52
74
  list.remove(index)
53
75
  if (!restore) return
54
76
  await nextTick()
55
77
  if (model.value.length) focusRow(Math.min(index, model.value.length - 1))
56
- else (addButton.value?.$el as HTMLElement | undefined)?.focus()
78
+ else (addButton.value?.$el as HTMLElement | undefined)?.focus({ preventScroll: true })
57
79
  }
58
80
  defineExpose({ add, remove, update: list.update })
59
81
  const { class: rootClass, attrs: rest } = useMergedAttrs("flex min-w-0 flex-col gap-2")
60
82
  </script>
61
83
 
62
84
  <template>
63
- <div ref="root" v-bind="rest" :class="rootClass">
85
+ <div v-bind="rest" :class="rootClass">
64
86
  <div class="flex items-center justify-between gap-2">
65
87
  <span v-if="label" class="text-sm font-medium text-ink">{{ label }}</span>
66
88
  <LpButton ref="addButton" size="xs" variant="ghost" :disabled="field.disabled" @click="add">
67
89
  <LpIcon name="lucide:plus" :size="14" />{{ text.add }}
68
90
  </LpButton>
69
91
  </div>
70
- <div v-for="({ item, key }, index) in rows" :key="key" data-lp-repeater-row class="flex min-w-0 items-start gap-2">
71
- <div class="min-w-0 flex-1">
72
- <slot :item="item" :index="index" :disabled="field.disabled" :update="(next: T) => list.update(index, next)" :remove="() => remove(index)" />
92
+ <LpShift axis="height">
93
+ <div ref="rowsHost" class="relative flex min-w-0 flex-col gap-2">
94
+ <TransitionGroup name="lp-repeater" :css="!reducedMotion" @before-enter="beforeEnter" @before-leave="beforeLeave">
95
+ <div v-for="({ item, key }, index) in rows" :key="key" data-lp-repeater-row class="flex min-w-0 items-start gap-2">
96
+ <div class="min-w-0 flex-1">
97
+ <slot :item="item" :index="index" :disabled="field.disabled" :update="(next: T) => list.update(index, next)" :remove="() => remove(index)" />
98
+ </div>
99
+ <LpButton variant="ghost" size="sm" square :disabled="field.disabled" :aria-label="text.remove.replace('{index}', String(index + 1))" @click="remove(index)">
100
+ <LpIcon name="lucide:x" :size="14" />
101
+ </LpButton>
102
+ </div>
103
+ <div v-if="!rows.length" key="empty" class="text-xs text-muted"><slot name="empty">{{ text.empty }}</slot></div>
104
+ </TransitionGroup>
73
105
  </div>
74
- <LpButton variant="ghost" size="sm" square :disabled="field.disabled" :aria-label="text.remove.replace('{index}', String(index + 1))" @click="remove(index)">
75
- <LpIcon name="lucide:x" :size="14" />
76
- </LpButton>
77
- </div>
78
- <div v-if="!rows.length" class="text-xs text-muted"><slot name="empty">{{ text.empty }}</slot></div>
106
+ </LpShift>
79
107
  </div>
80
108
  </template>
109
+
110
+ <style>
111
+ .lp-repeater-move,
112
+ .lp-repeater-enter-active,
113
+ .lp-repeater-leave-active {
114
+ transition: transform var(--duration-fast) var(--ease-emphasized), opacity var(--duration-fast) var(--ease-emphasized);
115
+ }
116
+ .lp-repeater-leave-active {
117
+ position: absolute;
118
+ }
119
+ .lp-repeater-enter-from,
120
+ .lp-repeater-leave-to {
121
+ opacity: 0;
122
+ transform: translateY(-0.5rem);
123
+ }
124
+ @media (prefers-reduced-motion: reduce) {
125
+ .lp-repeater-move,
126
+ .lp-repeater-enter-active,
127
+ .lp-repeater-leave-active {
128
+ transition: none;
129
+ }
130
+ }
131
+ </style>
@@ -35,6 +35,8 @@ const props = withDefaults(
35
35
  * width — a value that grows shouldn't shove its neighbours.
36
36
  */
37
37
  align?: "start" | "center" | "end"
38
+ /** Wrap to the available width instead of keeping a single line. */
39
+ wrap?: boolean
38
40
  }>(),
39
41
  { mode: "slide", direction: "up", duration: 320, align: "center" },
40
42
  )
@@ -63,7 +65,7 @@ const style = computed(() => ({
63
65
  <template>
64
66
  <span
65
67
  class="lp-roll relative inline-flex overflow-hidden align-bottom"
66
- :class="justify"
68
+ :class="[justify, wrap && 'lp-roll--wrap']"
67
69
  :style="style"
68
70
  >
69
71
  <Transition :name="name">
@@ -93,6 +95,15 @@ const style = computed(() => ({
93
95
  backface-visibility: hidden;
94
96
  }
95
97
 
98
+ .lp-roll--wrap {
99
+ max-width: 100%;
100
+ }
101
+
102
+ .lp-roll--wrap .lp-roll__item {
103
+ white-space: normal;
104
+ overflow-wrap: anywhere;
105
+ }
106
+
96
107
  /* The outgoing copy leaves the flow so the incoming one keeps the baseline. */
97
108
  .lp-roll-up-leave-active,
98
109
  .lp-roll-down-leave-active,
@@ -37,6 +37,7 @@ import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
37
37
  import { prefersReducedMotion } from "../composables/easing"
38
38
  import { useScramble } from "../composables/useScramble"
39
39
  import { useLabels } from "../composables/useLpConfig"
40
+ import { useFieldControl } from "../composables/useField"
40
41
  import { mergeClass } from "../composables/useMergedClass"
41
42
  import FieldWrapper from "./FieldWrapper.vue"
42
43
  import LpButton from "./LpButton.vue"
@@ -91,7 +92,11 @@ const emit = defineEmits<{
91
92
  }>()
92
93
 
93
94
  /** Shown or hidden; bind it to drive the reveal from outside. */
94
- const revealed = defineModel<boolean>("revealed", { default: false })
95
+ const revealedModel = defineModel<boolean>("revealed", { default: false })
96
+ const revealed = computed({
97
+ get: () => props.revealable && revealedModel.value,
98
+ set: (value: boolean) => { revealedModel.value = value },
99
+ })
95
100
 
96
101
  const text = useLabels<SecretFieldLabels>(
97
102
  "secretField",
@@ -107,6 +112,7 @@ const text = useLabels<SecretFieldLabels>(
107
112
  )
108
113
 
109
114
  const secret = computed(() => (props.input ? props.modelValue : props.value) ?? "")
115
+ const control = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }))
110
116
 
111
117
  // Revealing decrypts the value into place, and a new value (a regenerated key)
112
118
  // decrypts over the old one: the change is seen happening, not just swapped.
@@ -167,7 +173,7 @@ const shell = computed(() =>
167
173
  mergeClass(
168
174
  "relative flex w-full items-center gap-1 overflow-hidden rounded-control border bg-surface-soft text-ink transition-colors duration-(--duration-fast)",
169
175
  props.error ? "border-danger" : "border-line",
170
- props.disabled ? "opacity-55" : "",
176
+ control.value.disabled ? "opacity-55" : "",
171
177
  props.wrap ? "py-1.5" : "",
172
178
  { sm: "min-h-(--size-control-sm) pl-2.5 pr-1 text-xs", md: "min-h-(--size-control-md) pl-3 pr-1.5 text-sm", lg: "min-h-(--size-control-lg) pl-3.5 pr-1.5 text-sm" }[props.size],
173
179
  ),
@@ -184,7 +190,8 @@ const shell = computed(() =>
184
190
  class="[&_input]:font-mono"
185
191
  :model-value="modelValue"
186
192
  :placeholder="placeholder"
187
- :disabled="disabled"
193
+ :disabled="control.disabled"
194
+ :revealable="revealable"
188
195
  :label="label"
189
196
  :hint="hint"
190
197
  :error="error"
@@ -199,7 +206,7 @@ const shell = computed(() =>
199
206
  :value="secret"
200
207
  size="xs"
201
208
  class="size-7"
202
- :disabled="disabled"
209
+ :disabled="control.disabled"
203
210
  @copied="(t: string) => emit('copied', t)"
204
211
  />
205
212
  <slot name="actions" />
@@ -212,7 +219,7 @@ const shell = computed(() =>
212
219
  :aria-label="text.regenerate"
213
220
  :title="text.regenerate"
214
221
  :action="regenerate"
215
- :disabled="disabled"
222
+ :disabled="control.disabled"
216
223
  >
217
224
  <LpIcon name="lucide:refresh-cw" :size="iconSize" />
218
225
  </LpButton>
@@ -264,7 +271,7 @@ const shell = computed(() =>
264
271
  :icon-size="iconSize"
265
272
  :show-label="text.show"
266
273
  :hide-label="text.hide"
267
- :disabled="disabled"
274
+ :disabled="control.disabled"
268
275
  :controls="id"
269
276
  class="size-7"
270
277
  />
@@ -273,7 +280,7 @@ const shell = computed(() =>
273
280
  :value="secret"
274
281
  :size="copySize"
275
282
  class="size-7 text-muted hover:text-ink"
276
- :disabled="disabled"
283
+ :disabled="control.disabled"
277
284
  @copied="(t: string) => emit('copied', t)"
278
285
  />
279
286
  <slot name="actions" />
@@ -284,7 +291,7 @@ const shell = computed(() =>
284
291
  square
285
292
  class="size-7 text-muted hover:text-ink"
286
293
  :action="regenerate"
287
- :disabled="disabled"
294
+ :disabled="control.disabled"
288
295
  >
289
296
  <LpIcon name="lucide:refresh-cw" :size="iconSize" />
290
297
  </LpButton>
@@ -36,7 +36,7 @@ import {
36
36
  ComboboxViewport,
37
37
  } from "reka-ui"
38
38
  import { computed, nextTick, reactive, ref, useAttrs, useId, watch } from "vue"
39
- import { useFieldControl } from "../composables/useField"
39
+ import { useFieldBlur, useFieldControl } from "../composables/useField"
40
40
  import { useLabels } from "../composables/useLpConfig"
41
41
  import { useMergedAttrs } from "../composables/useMergedClass"
42
42
  import { OPTION_ITEM, optionStagger, POPOVER_PANEL } from "./dropdown"
@@ -178,12 +178,7 @@ function onOpenChange(open: boolean) {
178
178
  // are marked with one id, because the list is portalled away from the trigger
179
179
  // and a check on the trigger alone either missed the search box or took a
180
180
  // step into it for leaving.
181
- const partOf = `lp-select-${useId()}`
182
- function onFocusOut(event: FocusEvent) {
183
- const next = event.relatedTarget as Element | null
184
- const inside = !!next && typeof next.closest === "function" && !!next.closest(`[data-lp-select-part="${partOf}"]`)
185
- if (!inside) emit("blur", event)
186
- }
181
+ const { partId, onFocusOut } = useFieldBlur((event) => emit("blur", event))
187
182
 
188
183
  /*
189
184
  * The trigger's name. reka hard-codes aria-label="Show popup" on it, which hid
@@ -396,7 +391,7 @@ const guardOutside = useOutsideGuard()
396
391
  @update:open="onOpenChange"
397
392
  >
398
393
  <ComboboxAnchor
399
- :data-lp-select-part="partOf"
394
+ :data-lp-field-part="partId"
400
395
  @focusout="onFocusOut"
401
396
  :class="[
402
397
  anchorHeight,
@@ -528,7 +523,7 @@ const guardOutside = useOutsideGuard()
528
523
 
529
524
  <ComboboxPortal :to="portalTo">
530
525
  <ComboboxContent
531
- :data-lp-select-part="partOf"
526
+ :data-lp-field-part="partId"
532
527
  @focusout="onFocusOut"
533
528
  @interact-outside="guardOutside"
534
529
  @escape-key-down="guardOutside"
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { useAttrs } from "vue"
3
- import type { useFieldControl } from "../composables/useField"
3
+ import { useFieldBlur, type useFieldControl } from "../composables/useField"
4
4
  import { mergeClass } from "../composables/useMergedClass"
5
5
  import { PICKER_CLEAR, PICKER_PRESET, PICKER_PRESETS, PICKER_SIZE, PICKER_TRIGGER, pickerTone } from "./calendar"
6
6
  import LpIcon from "./LpIcon.vue"
@@ -21,13 +21,14 @@ withDefaults(defineProps<{
21
21
  presetsClass: string
22
22
  }>(), { size: "md" })
23
23
  const open = defineModel<boolean>("open")
24
- defineEmits<{ clear: [] }>()
24
+ const emit = defineEmits<{ clear: []; blur: [event: FocusEvent] }>()
25
+ const { partId, onFocusOut } = useFieldBlur((event) => emit("blur", event))
25
26
  const attrs = useAttrs()
26
27
  </script>
27
28
 
28
29
  <template>
29
- <div :class="mergeClass('relative w-full', attrs.class as string)" :style="attrs.style as never">
30
- <LpPopover v-model:open="open" align="start" panel-class="flex w-auto max-w-[calc(100vw-1rem)] flex-col rounded-card p-0 sm:flex-row">
30
+ <div :data-lp-field-part="partId" @focusout="onFocusOut" :class="mergeClass('relative w-full', attrs.class as string)" :style="attrs.style as never">
31
+ <LpPopover v-model:open="open" :data-lp-field-part="partId" @focusout="onFocusOut" align="start" panel-class="flex w-auto max-w-[calc(100vw-1rem)] flex-col rounded-card p-0 sm:flex-row">
31
32
  <template #trigger>
32
33
  <button
33
34
  v-bind="{ ...attrs, class: undefined, style: undefined }"
@@ -1,4 +1,4 @@
1
- import { computed, inject, provide, useId, type ComputedRef, type InjectionKey } from "vue"
1
+ import { computed, inject, onScopeDispose, provide, useId, type ComputedRef, type InjectionKey } from "vue"
2
2
  import type { FieldNoticeOptions } from "./useFieldNotice"
3
3
 
4
4
  /*
@@ -93,3 +93,28 @@ export function focusLeft(event: FocusEvent): boolean {
93
93
  const next = event.relatedTarget
94
94
  return !(next instanceof Node && (event.currentTarget as Node | null)?.contains(next))
95
95
  }
96
+
97
+ /** Trigger and portalled content share a boundary for the control's public blur. */
98
+ export function useFieldBlur(blur: (event: FocusEvent) => void) {
99
+ const partId = `lp-field-${useId()}`
100
+ let cancelPending: (() => void) | undefined
101
+ const inside = (target: EventTarget | null) =>
102
+ !!(target as Element | null)?.closest?.(`[data-lp-field-part="${partId}"]`)
103
+
104
+ function onFocusOut(event: FocusEvent) {
105
+ cancelPending?.()
106
+ if (inside(event.relatedTarget)) return
107
+ if (event.relatedTarget) return blur(event)
108
+ // A removed calendar cell can lose focus before its trigger gets it back.
109
+ const doc = (event.target as Node).ownerDocument!
110
+ const view = doc.defaultView!
111
+ const frame = view.requestAnimationFrame(() => {
112
+ cancelPending = undefined
113
+ if (!inside(doc.activeElement)) blur(event)
114
+ })
115
+ cancelPending = () => view.cancelAnimationFrame(frame)
116
+ }
117
+
118
+ onScopeDispose(() => cancelPending?.())
119
+ return { partId, onFocusOut }
120
+ }
@@ -119,7 +119,8 @@ export function useForm<T extends object>(options: UseFormOptions<T>): UseForm<T
119
119
  const validating = ref(false)
120
120
  const submitting = ref(false)
121
121
  const submitCount = ref(0)
122
- const runs = new Map<keyof T | "form", number>()
122
+ const runs = new Map<keyof T | "form", symbol>()
123
+ let validationEpoch = 0
123
124
  // What the form-level check set last, so its next run can take back what no
124
125
  // longer holds without touching an error a field rule set on the same key.
125
126
  const fromForm = new Map<keyof T | "form", string>()
@@ -148,7 +149,7 @@ export function useForm<T extends object>(options: UseFormOptions<T>): UseForm<T
148
149
  }
149
150
 
150
151
  async function runField(name: keyof T): Promise<boolean> {
151
- const run = (runs.get(name) ?? 0) + 1
152
+ const run = Symbol()
152
153
  runs.set(name, run)
153
154
  const found = await checkField(name)
154
155
  if (runs.get(name) !== run) return !errors[name]
@@ -159,7 +160,7 @@ export function useForm<T extends object>(options: UseFormOptions<T>): UseForm<T
159
160
 
160
161
  async function runForm(): Promise<boolean> {
161
162
  if (!options.validate) return true
162
- const run = (runs.get("form") ?? 0) + 1
163
+ const run = Symbol()
163
164
  runs.set("form", run)
164
165
  const found = (await options.validate(plain())) ?? {}
165
166
  if (runs.get("form") !== run) return true
@@ -177,12 +178,17 @@ export function useForm<T extends object>(options: UseFormOptions<T>): UseForm<T
177
178
  }
178
179
 
179
180
  async function validate(name?: keyof T): Promise<boolean> {
181
+ const epoch = validationEpoch
180
182
  validating.value = true
181
183
  try {
182
- if (name !== undefined) return await runField(name)
184
+ if (name !== undefined) {
185
+ const valid = await runField(name)
186
+ return epoch === validationEpoch && valid
187
+ }
183
188
  const fields = await Promise.all(keys().map(runField))
189
+ if (epoch !== validationEpoch) return false
184
190
  const form = await runForm()
185
- return fields.every(Boolean) && form && !Object.values(external).some(Boolean)
191
+ return epoch === validationEpoch && fields.every(Boolean) && form && !Object.values(external).some(Boolean)
186
192
  } finally {
187
193
  validating.value = false
188
194
  }
@@ -202,6 +208,7 @@ export function useForm<T extends object>(options: UseFormOptions<T>): UseForm<T
202
208
  }
203
209
 
204
210
  function clearState(): void {
211
+ validationEpoch++
205
212
  for (const k of Object.keys(errors)) delete errors[k as keyof T]
206
213
  for (const k of Object.keys(external)) delete external[k as keyof T]
207
214
  for (const k of Object.keys(touched)) delete touched[k as keyof T]
@@ -242,7 +249,9 @@ export function useForm<T extends object>(options: UseFormOptions<T>): UseForm<T
242
249
  // and runs the form-level check again once it has run: a cross-field error
243
250
  // ("passwords differ") must clear when either field is fixed.
244
251
  async function recheck(names: (keyof T)[]): Promise<void> {
252
+ const epoch = validationEpoch
245
253
  await Promise.all(names.map((name) => validate(name)))
254
+ if (epoch !== validationEpoch) return
246
255
  if (fromForm.size || submitCount.value) await runForm()
247
256
  }
248
257
 
@@ -64,6 +64,9 @@ export function forwardViewEvents(from: Document, to: Document): () => void {
64
64
  const Copy = event.constructor as new (type: string, init: Event) => Event
65
65
  const copy = new Copy(event.type, event) as Marked
66
66
  copy[ORIGIN] = from
67
+ const path = event.composedPath()
68
+ Object.defineProperty(copy, "target", { value: event.target })
69
+ copy.composedPath = () => path
67
70
  to.dispatchEvent(copy)
68
71
  }
69
72
  for (const type of FORWARDED) from.addEventListener(type, forward)
@@ -1,140 +0,0 @@
1
- import { defineComponent, useModel, ref, openBlock, createElementBlock, mergeProps, unref, createElementVNode, toDisplayString, createCommentVNode, createVNode, withCtx, createTextVNode, Fragment, renderList, renderSlot, mergeModels, nextTick } from "vue";
2
- import { useFieldControl } from "../composables/useField.js";
3
- import { useLabels } from "../composables/useLpConfig.js";
4
- import { useMergedAttrs } from "../composables/useMergedClass.js";
5
- import { useRepeater } from "../composables/useRepeater.js";
6
- import { _ as _sfc_main$1 } from "./LpButton-CVxBfGjd.js";
7
- import { _ as _sfc_main$2 } from "./LpIcon-HJP5xMz4.js";
8
- const _hoisted_1 = { class: "flex items-center justify-between gap-2" };
9
- const _hoisted_2 = {
10
- key: 0,
11
- class: "text-sm font-medium text-ink"
12
- };
13
- const _hoisted_3 = { class: "min-w-0 flex-1" };
14
- const _hoisted_4 = {
15
- key: 0,
16
- class: "text-xs text-muted"
17
- };
18
- const focusable = 'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex="0"], [contenteditable="true"]';
19
- const _sfc_main = /* @__PURE__ */ defineComponent({
20
- ...{ inheritAttrs: false },
21
- __name: "LpRepeater",
22
- props: /* @__PURE__ */ mergeModels({
23
- createItem: { type: Function },
24
- itemKey: { type: Function },
25
- label: {},
26
- disabled: { type: Boolean },
27
- labels: {}
28
- }, {
29
- "modelValue": { default: () => [] },
30
- "modelModifiers": {}
31
- }),
32
- emits: ["update:modelValue"],
33
- setup(__props, { expose: __expose }) {
34
- const props = __props;
35
- const model = useModel(__props, "modelValue");
36
- const field = useFieldControl(() => ({ wrapped: false, disabled: props.disabled }));
37
- const text = useLabels("repeater", { add: "Add item", remove: "Remove item {index}", empty: "No items" }, () => props.labels);
38
- const list = useRepeater({
39
- value: () => model.value,
40
- set: (items) => {
41
- model.value = items;
42
- },
43
- createItem: () => props.createItem(),
44
- itemKey: () => props.itemKey,
45
- disabled: () => field.value.disabled
46
- });
47
- const { rows } = list;
48
- const root = ref(null);
49
- const addButton = ref(null);
50
- function focusRow(index) {
51
- root.value?.querySelectorAll("[data-lp-repeater-row]")[index]?.querySelector(focusable)?.focus();
52
- }
53
- async function add() {
54
- if (field.value.disabled) return;
55
- list.add();
56
- await nextTick();
57
- focusRow(model.value.length - 1);
58
- }
59
- async function remove(index) {
60
- if (field.value.disabled) return;
61
- const row = root.value?.querySelectorAll("[data-lp-repeater-row]")[index];
62
- const restore = row?.contains(row.ownerDocument.activeElement);
63
- list.remove(index);
64
- if (!restore) return;
65
- await nextTick();
66
- if (model.value.length) focusRow(Math.min(index, model.value.length - 1));
67
- else addButton.value?.$el?.focus();
68
- }
69
- __expose({ add, remove, update: list.update });
70
- const { class: rootClass, attrs: rest } = useMergedAttrs("flex min-w-0 flex-col gap-2");
71
- return (_ctx, _cache) => {
72
- return openBlock(), createElementBlock("div", mergeProps({
73
- ref_key: "root",
74
- ref: root
75
- }, unref(rest), { class: unref(rootClass) }), [
76
- createElementVNode("div", _hoisted_1, [
77
- __props.label ? (openBlock(), createElementBlock("span", _hoisted_2, toDisplayString(__props.label), 1)) : createCommentVNode("", true),
78
- createVNode(_sfc_main$1, {
79
- ref_key: "addButton",
80
- ref: addButton,
81
- size: "xs",
82
- variant: "ghost",
83
- disabled: unref(field).disabled,
84
- onClick: add
85
- }, {
86
- default: withCtx(() => [
87
- createVNode(_sfc_main$2, {
88
- name: "lucide:plus",
89
- size: 14
90
- }),
91
- createTextVNode(toDisplayString(unref(text).add), 1)
92
- ]),
93
- _: 1
94
- }, 8, ["disabled"])
95
- ]),
96
- (openBlock(true), createElementBlock(Fragment, null, renderList(unref(rows), ({ item, key }, index) => {
97
- return openBlock(), createElementBlock("div", {
98
- key,
99
- "data-lp-repeater-row": "",
100
- class: "flex min-w-0 items-start gap-2"
101
- }, [
102
- createElementVNode("div", _hoisted_3, [
103
- renderSlot(_ctx.$slots, "default", {
104
- item,
105
- index,
106
- disabled: unref(field).disabled,
107
- update: (next) => unref(list).update(index, next),
108
- remove: () => remove(index)
109
- })
110
- ]),
111
- createVNode(_sfc_main$1, {
112
- variant: "ghost",
113
- size: "sm",
114
- square: "",
115
- disabled: unref(field).disabled,
116
- "aria-label": unref(text).remove.replace("{index}", String(index + 1)),
117
- onClick: ($event) => remove(index)
118
- }, {
119
- default: withCtx(() => [
120
- createVNode(_sfc_main$2, {
121
- name: "lucide:x",
122
- size: 14
123
- })
124
- ]),
125
- _: 1
126
- }, 8, ["disabled", "aria-label", "onClick"])
127
- ]);
128
- }), 128)),
129
- !unref(rows).length ? (openBlock(), createElementBlock("div", _hoisted_4, [
130
- renderSlot(_ctx.$slots, "empty", {}, () => [
131
- createTextVNode(toDisplayString(unref(text).empty), 1)
132
- ])
133
- ])) : createCommentVNode("", true)
134
- ], 16);
135
- };
136
- }
137
- });
138
- export {
139
- _sfc_main as _
140
- };