@redseed/redseed-ui-vue3 8.40.1 → 8.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.40.1",
3
+ "version": "8.41.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, computed, onMounted, onUnmounted, watch, useAttrs } from 'vue'
3
- import { onClickOutside, useElementBounding } from '@vueuse/core'
3
+ import { onClickOutside } from '@vueuse/core'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
6
6
  import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
@@ -326,40 +326,53 @@ onClickOutside(comboboxElement, () => {
326
326
  }
327
327
  })
328
328
 
329
- const inputElementBounding = useElementBounding(inputElement)
330
-
331
329
  function calculateDropdownPosition() {
332
- if (!dropdownElement.value) return
330
+ if (!dropdownElement.value || !inputElement.value) return
333
331
 
332
+ const bounding = inputElement.value.getBoundingClientRect()
334
333
  const viewportHeight = window.innerHeight
335
334
  const dropdownElementHeight = dropdownElement.value.offsetHeight
336
- const spaceAboveInput = inputElementBounding.top.value
337
- const spaceBelowInput = viewportHeight - inputElementBounding.bottom.value
335
+ const spaceAboveInput = bounding.top
336
+ const spaceBelowInput = viewportHeight - bounding.bottom
338
337
 
339
- dropdownElement.value.style.width = `${inputElementBounding.width.value}px`
340
- dropdownElement.value.style.left = `${inputElementBounding.left.value}px`
338
+ dropdownElement.value.style.width = `${bounding.width}px`
339
+ dropdownElement.value.style.left = `${bounding.left}px`
341
340
 
342
341
  if (spaceAboveInput <= dropdownElementHeight && spaceBelowInput <= dropdownElementHeight) {
343
342
  dropdownElement.value.style.top = '0'
344
343
  dropdownElement.value.style.bottom = 'auto'
345
344
  return
346
345
  } else if (spaceBelowInput > dropdownElementHeight) {
347
- dropdownElement.value.style.top = `${inputElementBounding.bottom.value + window.scrollY}px`
346
+ dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
348
347
  dropdownElement.value.style.bottom = 'auto'
349
348
  return
350
349
  } else if (spaceAboveInput > dropdownElementHeight) {
351
350
  dropdownElement.value.style.top = 'auto'
352
- dropdownElement.value.style.bottom = `${spaceBelowInput + inputElementBounding.height.value + 8 - window.scrollY}px`
351
+ dropdownElement.value.style.bottom = `${spaceBelowInput + bounding.height + 8 - window.scrollY}px`
353
352
  return
354
353
  }
355
354
  }
356
355
 
356
+ function handleScroll(event) {
357
+ if (dropdownElement.value?.contains(event.target)) return
358
+ calculateDropdownPosition()
359
+ }
360
+
361
+ watch(isOpen, (nowOpen) => {
362
+ if (nowOpen) {
363
+ window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
364
+ } else {
365
+ window.removeEventListener('scroll', handleScroll, { capture: true })
366
+ }
367
+ })
368
+
357
369
  onMounted(() => {
358
370
  window.addEventListener('resize', calculateDropdownPosition)
359
371
  })
360
372
 
361
373
  onUnmounted(() => {
362
374
  window.removeEventListener('resize', calculateDropdownPosition)
375
+ window.removeEventListener('scroll', handleScroll, { capture: true })
363
376
  })
364
377
 
365
378
  defineExpose({
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, computed, onMounted, onUnmounted, watch, useAttrs } from 'vue'
3
- import { onClickOutside, useElementBounding } from '@vueuse/core'
3
+ import { onClickOutside } from '@vueuse/core'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
6
6
  import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
@@ -245,39 +245,52 @@ onClickOutside(rootElement, () => {
245
245
  // Measure the whole field box (icon prefix + input), not the inner <input> —
246
246
  // the input sits to the right of the prefix and is narrower, so anchoring to
247
247
  // it would leave the dropdown shifted right and narrower than the field.
248
- const fieldElementBounding = useElementBounding(fieldElement)
249
-
250
248
  function calculateDropdownPosition() {
251
- if (!dropdownElement.value) return
249
+ if (!dropdownElement.value || !fieldElement.value) return
252
250
 
251
+ const bounding = fieldElement.value.getBoundingClientRect()
253
252
  const viewportHeight = window.innerHeight
254
253
  const dropdownHeight = dropdownElement.value.offsetHeight
255
- const spaceAbove = fieldElementBounding.top.value
256
- const spaceBelow = viewportHeight - fieldElementBounding.bottom.value
254
+ const spaceAbove = bounding.top
255
+ const spaceBelow = viewportHeight - bounding.bottom
257
256
 
258
257
  dropdownElement.value.style.width = props.dropdownWidth != null
259
258
  ? (typeof props.dropdownWidth === 'number' ? `${props.dropdownWidth}px` : props.dropdownWidth)
260
- : `${fieldElementBounding.width.value}px`
261
- dropdownElement.value.style.left = `${fieldElementBounding.left.value}px`
259
+ : `${bounding.width}px`
260
+ dropdownElement.value.style.left = `${bounding.left}px`
262
261
 
263
262
  if (spaceAbove <= dropdownHeight && spaceBelow <= dropdownHeight) {
264
263
  dropdownElement.value.style.top = '0'
265
264
  dropdownElement.value.style.bottom = 'auto'
266
265
  } else if (spaceBelow > dropdownHeight) {
267
- dropdownElement.value.style.top = `${fieldElementBounding.bottom.value + window.scrollY}px`
266
+ dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
268
267
  dropdownElement.value.style.bottom = 'auto'
269
268
  } else if (spaceAbove > dropdownHeight) {
270
269
  dropdownElement.value.style.top = 'auto'
271
- dropdownElement.value.style.bottom = `${spaceBelow + fieldElementBounding.height.value + 8 - window.scrollY}px`
270
+ dropdownElement.value.style.bottom = `${spaceBelow + bounding.height + 8 - window.scrollY}px`
272
271
  }
273
272
  }
274
273
 
274
+ function handleScroll(event) {
275
+ if (dropdownElement.value?.contains(event.target)) return
276
+ calculateDropdownPosition()
277
+ }
278
+
279
+ watch(isOpen, (nowOpen) => {
280
+ if (nowOpen) {
281
+ window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
282
+ } else {
283
+ window.removeEventListener('scroll', handleScroll, { capture: true })
284
+ }
285
+ })
286
+
275
287
  onMounted(() => {
276
288
  window.addEventListener('resize', calculateDropdownPosition)
277
289
  })
278
290
 
279
291
  onUnmounted(() => {
280
292
  window.removeEventListener('resize', calculateDropdownPosition)
293
+ window.removeEventListener('scroll', handleScroll, { capture: true })
281
294
  })
282
295
 
283
296
  defineExpose({
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, computed, watch, onMounted, onUnmounted, useAttrs } from 'vue'
3
- import { onClickOutside, useElementBounding } from '@vueuse/core'
3
+ import { onClickOutside } from '@vueuse/core'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
6
6
  import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
@@ -144,10 +144,14 @@ onClickOutside(formFieldSelectElement, () => close())
144
144
 
145
145
  // Anchor dropdown to the visible trigger (button on desktop, select on mobile)
146
146
  const anchorElement = computed(() => triggerElement.value || selectElement.value)
147
- const anchorBounding = useElementBounding(anchorElement)
148
147
 
149
148
  function calculateDropdownPosition() {
150
- if (!dropdownElement.value) return
149
+ if (!dropdownElement.value || !anchorElement.value) return
150
+
151
+ // Preserve scrollTop before the reset so scroll-chaining (wheel past the
152
+ // list's boundary chains to the page, triggering this handler) cannot snap
153
+ // the list back to the top when maxHeight is cleared and a reflow occurs.
154
+ const savedScrollTop = dropdownElement.value.scrollTop
151
155
 
152
156
  // Reset inline positioning before measuring so a previously-applied
153
157
  // max-height (from an earlier constrained open) doesn't pollute the
@@ -157,12 +161,13 @@ function calculateDropdownPosition() {
157
161
  dropdownElement.value.style.top = ''
158
162
  dropdownElement.value.style.bottom = ''
159
163
 
164
+ const bounding = anchorElement.value.getBoundingClientRect()
160
165
  const dropdownElementHeight = dropdownElement.value.offsetHeight
161
166
  const viewportHeight = window.innerHeight
162
- const spaceAbove = anchorBounding.top.value
163
- const spaceBelow = viewportHeight - anchorBounding.bottom.value
167
+ const spaceAbove = bounding.top
168
+ const spaceBelow = viewportHeight - bounding.bottom
164
169
 
165
- dropdownElement.value.style.minWidth = `${anchorBounding.width.value}px`
170
+ dropdownElement.value.style.minWidth = `${bounding.width}px`
166
171
 
167
172
  /**
168
173
  * Clamp the dropdown to the viewport horizontally.
@@ -176,13 +181,13 @@ function calculateDropdownPosition() {
176
181
  dropdownElement.value.style.maxWidth = `${viewportWidth - safeMargin * 2}px`
177
182
 
178
183
  // Temporarily position at trigger left so we can measure actual width
179
- dropdownElement.value.style.left = `${anchorBounding.left.value}px`
184
+ dropdownElement.value.style.left = `${bounding.left}px`
180
185
  const dropdownWidth = dropdownElement.value.offsetWidth
181
- const rightOverflow = (anchorBounding.left.value + dropdownWidth + safeMargin) - viewportWidth
186
+ const rightOverflow = (bounding.left + dropdownWidth + safeMargin) - viewportWidth
182
187
 
183
188
  // Shift left if the dropdown overflows the right edge
184
189
  if (rightOverflow > 0) {
185
- const clampedLeft = Math.max(safeMargin, anchorBounding.left.value - rightOverflow)
190
+ const clampedLeft = Math.max(safeMargin, bounding.left - rightOverflow)
186
191
  dropdownElement.value.style.left = `${clampedLeft}px`
187
192
  }
188
193
 
@@ -192,18 +197,21 @@ function calculateDropdownPosition() {
192
197
  const verticalOffset = 16
193
198
 
194
199
  if (spaceBelow > dropdownElementHeight) {
195
- dropdownElement.value.style.top = `${anchorBounding.bottom.value + window.scrollY}px`
200
+ dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
196
201
  dropdownElement.value.style.bottom = 'auto'
202
+ dropdownElement.value.scrollTop = savedScrollTop
197
203
  return
198
204
  } else if (spaceAbove > spaceBelow) {
199
205
  dropdownElement.value.style.top = 'auto'
200
- dropdownElement.value.style.bottom = `${spaceBelow + anchorBounding.height.value + 8 - window.scrollY}px`
206
+ dropdownElement.value.style.bottom = `${spaceBelow + bounding.height + 8 - window.scrollY}px`
201
207
  dropdownElement.value.style.maxHeight = `${spaceAbove - verticalOffset}px`
208
+ dropdownElement.value.scrollTop = savedScrollTop
202
209
  return
203
210
  } else {
204
- dropdownElement.value.style.top = `${anchorBounding.bottom.value + window.scrollY}px`
211
+ dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
205
212
  dropdownElement.value.style.bottom = 'auto'
206
213
  dropdownElement.value.style.maxHeight = `${spaceBelow - verticalOffset}px`
214
+ dropdownElement.value.scrollTop = savedScrollTop
207
215
  return
208
216
  }
209
217
  }
@@ -212,6 +220,19 @@ function handleResize() {
212
220
  calculateDropdownPosition()
213
221
  }
214
222
 
223
+ function handleScroll(event) {
224
+ if (dropdownElement.value?.contains(event.target)) return
225
+ calculateDropdownPosition()
226
+ }
227
+
228
+ watch(isOpen, (nowOpen) => {
229
+ if (nowOpen) {
230
+ window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
231
+ } else {
232
+ window.removeEventListener('scroll', handleScroll, { capture: true })
233
+ }
234
+ })
235
+
215
236
  onMounted(() => {
216
237
  isMobileDevice.value = 'ontouchstart' in window
217
238
  || (navigator.maxTouchPoints && navigator.maxTouchPoints > 0)
@@ -220,6 +241,7 @@ onMounted(() => {
220
241
 
221
242
  onUnmounted(() => {
222
243
  window.removeEventListener('resize', handleResize)
244
+ window.removeEventListener('scroll', handleScroll, { capture: true })
223
245
  })
224
246
 
225
247
  defineExpose({