@redseed/redseed-ui-vue3 8.40.0 → 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.0",
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'
@@ -196,6 +196,20 @@ function handleKeyup(event) {
196
196
  }
197
197
 
198
198
  function handleKeydown(event) {
199
+ // Let selection modifiers pass through to the native input so keyboard
200
+ // text-selection shortcuts work while typing (e.g. Shift+Home, Ctrl+Shift+
201
+ // ArrowLeft on Windows; Cmd+Shift+ArrowLeft, Option+Shift+ArrowLeft on
202
+ // macOS). Without this, the preventDefault() calls below would intercept
203
+ // Arrow/Home/End and kill the browser's native text selection.
204
+ if (event.shiftKey || event.ctrlKey || event.metaKey) {
205
+ return
206
+ }
207
+ // Alt+ArrowDown is the standard ARIA combobox open shortcut — let it through.
208
+ // Other Alt combos (e.g. Option+Arrow on macOS) pass through to native.
209
+ if (event.altKey && event.key !== 'ArrowDown') {
210
+ return
211
+ }
212
+
199
213
  if (event.key === 'ArrowDown') {
200
214
  event.preventDefault()
201
215
  if (!isOpen.value) {
@@ -231,39 +245,52 @@ onClickOutside(rootElement, () => {
231
245
  // Measure the whole field box (icon prefix + input), not the inner <input> —
232
246
  // the input sits to the right of the prefix and is narrower, so anchoring to
233
247
  // it would leave the dropdown shifted right and narrower than the field.
234
- const fieldElementBounding = useElementBounding(fieldElement)
235
-
236
248
  function calculateDropdownPosition() {
237
- if (!dropdownElement.value) return
249
+ if (!dropdownElement.value || !fieldElement.value) return
238
250
 
251
+ const bounding = fieldElement.value.getBoundingClientRect()
239
252
  const viewportHeight = window.innerHeight
240
253
  const dropdownHeight = dropdownElement.value.offsetHeight
241
- const spaceAbove = fieldElementBounding.top.value
242
- const spaceBelow = viewportHeight - fieldElementBounding.bottom.value
254
+ const spaceAbove = bounding.top
255
+ const spaceBelow = viewportHeight - bounding.bottom
243
256
 
244
257
  dropdownElement.value.style.width = props.dropdownWidth != null
245
258
  ? (typeof props.dropdownWidth === 'number' ? `${props.dropdownWidth}px` : props.dropdownWidth)
246
- : `${fieldElementBounding.width.value}px`
247
- dropdownElement.value.style.left = `${fieldElementBounding.left.value}px`
259
+ : `${bounding.width}px`
260
+ dropdownElement.value.style.left = `${bounding.left}px`
248
261
 
249
262
  if (spaceAbove <= dropdownHeight && spaceBelow <= dropdownHeight) {
250
263
  dropdownElement.value.style.top = '0'
251
264
  dropdownElement.value.style.bottom = 'auto'
252
265
  } else if (spaceBelow > dropdownHeight) {
253
- dropdownElement.value.style.top = `${fieldElementBounding.bottom.value + window.scrollY}px`
266
+ dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
254
267
  dropdownElement.value.style.bottom = 'auto'
255
268
  } else if (spaceAbove > dropdownHeight) {
256
269
  dropdownElement.value.style.top = 'auto'
257
- dropdownElement.value.style.bottom = `${spaceBelow + fieldElementBounding.height.value + 8 - window.scrollY}px`
270
+ dropdownElement.value.style.bottom = `${spaceBelow + bounding.height + 8 - window.scrollY}px`
258
271
  }
259
272
  }
260
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
+
261
287
  onMounted(() => {
262
288
  window.addEventListener('resize', calculateDropdownPosition)
263
289
  })
264
290
 
265
291
  onUnmounted(() => {
266
292
  window.removeEventListener('resize', calculateDropdownPosition)
293
+ window.removeEventListener('scroll', handleScroll, { capture: true })
267
294
  })
268
295
 
269
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({