@redseed/redseed-ui-vue3 8.40.1 → 8.42.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.42.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -1,9 +1,10 @@
1
1
  <script setup>
2
- import { ref, computed, onMounted, onUnmounted, watch, useAttrs } from 'vue'
3
- import { onClickOutside, useElementBounding } from '@vueuse/core'
2
+ import { ref, computed, onMounted, onUnmounted, watch, useAttrs, useId } from 'vue'
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'
7
+ import { useDropdownPosition } from '../../composables/useDropdownPosition.js'
7
8
 
8
9
  defineOptions({
9
10
  inheritAttrs: false,
@@ -39,10 +40,23 @@ const props = defineProps({
39
40
  navigable: {
40
41
  type: Boolean,
41
42
  default: false
43
+ },
44
+ // Opt-in (default off): on touch devices, render a native text input backed
45
+ // by a <datalist> instead of the custom floating dropdown. Sidesteps the
46
+ // floating-dropdown positioning entirely on mobile and gives the native
47
+ // picker UX, mirroring FormFieldSelect's native <select> swap. Exploratory:
48
+ // a <datalist> can't express the async/loading/grouped/navigable states, so
49
+ // the native path only engages for the plain synchronous-options case; with
50
+ // searchFunction or navigable set it stays on the (now overlap-safe) custom
51
+ // dropdown even on touch. Default-off keeps every existing consumer's
52
+ // behaviour unchanged. See useNativeMobile below.
53
+ nativeMobile: {
54
+ type: Boolean,
55
+ default: false
42
56
  }
43
57
  })
44
58
 
45
- const emit = defineEmits(['input', 'change', 'keyup-enter', 'navigate'])
59
+ const emit = defineEmits(['input', 'change', 'keyup-enter', 'navigate', 'reject'])
46
60
 
47
61
  const attrs = useAttrs()
48
62
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
@@ -55,13 +69,34 @@ const comboboxElement = ref(null)
55
69
  const dropdownElement = ref(null)
56
70
  const highlightedIndex = ref(-1)
57
71
 
72
+ // Native-on-touch (opt-in via the nativeMobile prop) — see the prop comment.
73
+ const isMobileDevice = ref(false)
74
+ const nativeInputElement = ref(null)
75
+ const nativeText = ref('')
76
+
58
77
  // Async search state
59
78
  const isLoading = ref(false)
60
79
  const searchError = ref(null)
61
80
  const asyncOptions = ref([])
62
81
  const debounceTimeout = ref(null)
63
82
 
64
- const effectiveId = computed(() => inputId.value || attrs.id)
83
+ // Stable fallback so the listbox/datalist/option IDs (and the native <input
84
+ // list>/<datalist id> association) never collapse to `undefined-...` when a
85
+ // consumer supplies neither an injected inputId nor an explicit id attr.
86
+ const fallbackId = useId()
87
+ const effectiveId = computed(() => inputId.value || attrs.id || fallbackId)
88
+
89
+ // Engage the native <datalist> path only when opted in, on a touch device, and
90
+ // for the plain synchronous-options case a <datalist> can actually represent
91
+ // (no async search, no navigate-on-click). Otherwise the custom dropdown — now
92
+ // overlap-safe via useDropdownPosition — renders on every viewport as before.
93
+ const useNativeMobile = computed(() =>
94
+ props.nativeMobile
95
+ && isMobileDevice.value
96
+ && !props.searchFunction
97
+ && !props.navigable
98
+ )
99
+ const datalistId = computed(() => `${effectiveId.value}-datalist`)
65
100
 
66
101
  // Live region announcement for screen readers
67
102
  const liveAnnouncement = computed(() => {
@@ -166,6 +201,12 @@ function toggleDropdown() {
166
201
  }
167
202
 
168
203
  function open() {
204
+ // In native mode there is no floating dropdown — the browser owns the
205
+ // <datalist> suggestions, so just focus the native input.
206
+ if (useNativeMobile.value) {
207
+ nativeInputElement.value?.focus()
208
+ return
209
+ }
169
210
  isOpen.value = true
170
211
  searchText.value = ''
171
212
  hasEdited.value = false
@@ -326,44 +367,101 @@ onClickOutside(comboboxElement, () => {
326
367
  }
327
368
  })
328
369
 
329
- const inputElementBounding = useElementBounding(inputElement)
370
+ // Desktop / non-native dropdown positioning — shared with FormFieldSelect and
371
+ // FormFieldSearchAsync via useDropdownPosition (no more top:0 overlap, #220).
372
+ // 'match' locks the listbox to the input width, preserving prior behaviour.
373
+ const { calculateDropdownPosition } = useDropdownPosition(
374
+ dropdownElement,
375
+ inputElement,
376
+ isOpen,
377
+ { widthMode: 'match' },
378
+ )
379
+
380
+ // --- Native (datalist) value handling ----------------------------------------
381
+ // The native <input>'s text is semi-controlled via nativeText so typing isn't
382
+ // clobbered on re-render. We keep it in sync with the model (external changes /
383
+ // initial value) and resolve the typed-or-picked label back to an option value
384
+ // on commit, preserving allowCustomValue semantics.
385
+ function syncNativeText() {
386
+ const selected = props.options.find(option => option.value === model.value)
387
+ nativeText.value = selected ? selected.label : (model.value ?? '')
388
+ }
330
389
 
331
- function calculateDropdownPosition() {
332
- if (!dropdownElement.value) return
390
+ function handleNativeInput(event) {
391
+ nativeText.value = event.target.value
392
+ emit('input', event)
393
+ }
333
394
 
334
- const viewportHeight = window.innerHeight
335
- const dropdownElementHeight = dropdownElement.value.offsetHeight
336
- const spaceAboveInput = inputElementBounding.top.value
337
- const spaceBelowInput = viewportHeight - inputElementBounding.bottom.value
395
+ function handleNativeChange(event) {
396
+ const text = event.target.value
397
+ const matched = props.options.find(option => option.label === text)
398
+ if (matched) {
399
+ model.value = matched.value
400
+ emit('change', matched.value)
401
+ } else if (props.allowCustomValue) {
402
+ model.value = text
403
+ emit('change', text)
404
+ } else {
405
+ // Unknown option and custom values disallowed — revert to last valid.
406
+ // The native datalist gives no "no results" affordance of its own, so
407
+ // emit `reject` (with the discarded text) to let the consumer surface
408
+ // feedback rather than the field silently snapping back.
409
+ emit('reject', text)
410
+ syncNativeText()
411
+ }
412
+ }
338
413
 
339
- dropdownElement.value.style.width = `${inputElementBounding.width.value}px`
340
- dropdownElement.value.style.left = `${inputElementBounding.left.value}px`
414
+ watch(model, syncNativeText, { immediate: true })
415
+
416
+ // Touch detection drives the native path. Decide it *reactively* rather than
417
+ // with a once-at-mount read (#176): the old approach rendered the wrong control
418
+ // on initial mobile / emulated load and never corrected when the pointer type
419
+ // changed (e.g. Chrome device-emulation toggled on/off). We listen to
420
+ // matchMedia('(pointer: coarse)') so the decision updates live, OR'd with the
421
+ // touch-capability flags as a fallback where pointer media queries are absent.
422
+ let coarsePointerQuery = null
423
+ function updateIsMobileDevice() {
424
+ const coarsePointer = coarsePointerQuery?.matches ?? false
425
+ isMobileDevice.value = coarsePointer
426
+ || 'ontouchstart' in window
427
+ || navigator.maxTouchPoints > 0
428
+ }
341
429
 
342
- if (spaceAboveInput <= dropdownElementHeight && spaceBelowInput <= dropdownElementHeight) {
343
- dropdownElement.value.style.top = '0'
344
- dropdownElement.value.style.bottom = 'auto'
345
- return
346
- } else if (spaceBelowInput > dropdownElementHeight) {
347
- dropdownElement.value.style.top = `${inputElementBounding.bottom.value + window.scrollY}px`
348
- dropdownElement.value.style.bottom = 'auto'
349
- return
350
- } else if (spaceAboveInput > dropdownElementHeight) {
351
- dropdownElement.value.style.top = 'auto'
352
- dropdownElement.value.style.bottom = `${spaceBelowInput + inputElementBounding.height.value + 8 - window.scrollY}px`
353
- return
430
+ // Subscribe to live pointer-type changes. Prefer the modern addEventListener,
431
+ // but fall back to the deprecated addListener/removeListener so legacy Safari /
432
+ // WebViews — the very browsers this reactive path exists to support — still
433
+ // update instead of silently no-op'ing under an `addEventListener?.` optional chain.
434
+ function subscribePointerQuery() {
435
+ if (coarsePointerQuery?.addEventListener) {
436
+ coarsePointerQuery.addEventListener('change', updateIsMobileDevice)
437
+ } else if (coarsePointerQuery?.addListener) {
438
+ coarsePointerQuery.addListener(updateIsMobileDevice)
439
+ }
440
+ }
441
+ function unsubscribePointerQuery() {
442
+ if (coarsePointerQuery?.removeEventListener) {
443
+ coarsePointerQuery.removeEventListener('change', updateIsMobileDevice)
444
+ } else if (coarsePointerQuery?.removeListener) {
445
+ coarsePointerQuery.removeListener(updateIsMobileDevice)
354
446
  }
355
447
  }
356
448
 
357
449
  onMounted(() => {
358
- window.addEventListener('resize', calculateDropdownPosition)
450
+ coarsePointerQuery = window.matchMedia?.('(pointer: coarse)') ?? null
451
+ updateIsMobileDevice()
452
+ subscribePointerQuery()
359
453
  })
360
454
 
361
455
  onUnmounted(() => {
362
- window.removeEventListener('resize', calculateDropdownPosition)
456
+ unsubscribePointerQuery()
363
457
  })
364
458
 
365
459
  defineExpose({
366
460
  focus() {
461
+ if (useNativeMobile.value) {
462
+ nativeInputElement.value?.focus()
463
+ return
464
+ }
367
465
  inputElement.value?.focus()
368
466
  },
369
467
  open,
@@ -392,7 +490,9 @@ defineExpose({
392
490
  <slot name="prefix"></slot>
393
491
  </div>
394
492
 
493
+ <!-- Desktop / non-native: styled input driving the custom floating dropdown -->
395
494
  <input
495
+ v-if="!useNativeMobile"
396
496
  ref="inputElement"
397
497
  role="combobox"
398
498
  :aria-activedescendant="isOpen && dropdownContent === 'options' && highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
@@ -419,9 +519,39 @@ defineExpose({
419
519
  @click="!isOpen && open()"
420
520
  >
421
521
 
522
+ <!-- Native (touch, opt-in): plain text input backed by a <datalist>.
523
+ The browser owns suggestion display, so there's no floating
524
+ dropdown to overlap the input. -->
525
+ <input
526
+ v-if="useNativeMobile"
527
+ ref="nativeInputElement"
528
+ :list="datalistId"
529
+ :aria-describedby="ariaDescribedby"
530
+ :aria-invalid="ariaInvalid"
531
+ :aria-required="$attrs.required || undefined"
532
+ :value="nativeText"
533
+ :autocomplete="$attrs.autocomplete"
534
+ :autofocus="$attrs.autofocus"
535
+ :disabled="$attrs.disabled"
536
+ :id="effectiveId"
537
+ :name="$attrs.name"
538
+ :placeholder="$slots.placeholder ? '' : 'Type to search or select...'"
539
+ :required="$attrs.required"
540
+ type="text"
541
+ @input="handleNativeInput"
542
+ @change="handleNativeChange"
543
+ >
544
+ <datalist v-if="useNativeMobile" :id="datalistId">
545
+ <option
546
+ v-for="option in options"
547
+ :key="option.value"
548
+ :value="option.label"
549
+ ></option>
550
+ </datalist>
551
+
422
552
  <!-- Placeholder slot for custom placeholder rendering -->
423
553
  <div
424
- v-if="$slots.placeholder && !displayText && !isOpen"
554
+ v-if="$slots.placeholder && !displayText && !isOpen && !useNativeMobile"
425
555
  class="rsui-form-field-combobox__placeholder"
426
556
  aria-hidden="true"
427
557
  @click="open()"
@@ -429,7 +559,7 @@ defineExpose({
429
559
  <slot name="placeholder"></slot>
430
560
  </div>
431
561
 
432
- <Teleport to="body">
562
+ <Teleport to="body" v-if="!useNativeMobile">
433
563
  <transition
434
564
  enter-active-class="enter-active-class"
435
565
  enter-from-class="enter-from-class"
@@ -1,9 +1,10 @@
1
1
  <script setup>
2
- import { ref, computed, onMounted, onUnmounted, watch, useAttrs } from 'vue'
3
- import { onClickOutside, useElementBounding } from '@vueuse/core'
2
+ import { ref, computed, watch, useAttrs } from 'vue'
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'
7
+ import { useDropdownPosition } from '../../composables/useDropdownPosition.js'
7
8
 
8
9
  defineOptions({
9
10
  inheritAttrs: false,
@@ -242,43 +243,23 @@ onClickOutside(rootElement, () => {
242
243
  if (isOpen.value) close()
243
244
  })
244
245
 
245
- // Measure the whole field box (icon prefix + input), not the inner <input> —
246
- // the input sits to the right of the prefix and is narrower, so anchoring to
247
- // it would leave the dropdown shifted right and narrower than the field.
248
- const fieldElementBounding = useElementBounding(fieldElement)
249
-
250
- function calculateDropdownPosition() {
251
- if (!dropdownElement.value) return
252
-
253
- const viewportHeight = window.innerHeight
254
- const dropdownHeight = dropdownElement.value.offsetHeight
255
- const spaceAbove = fieldElementBounding.top.value
256
- const spaceBelow = viewportHeight - fieldElementBounding.bottom.value
257
-
258
- dropdownElement.value.style.width = props.dropdownWidth != null
259
- ? (typeof props.dropdownWidth === 'number' ? `${props.dropdownWidth}px` : props.dropdownWidth)
260
- : `${fieldElementBounding.width.value}px`
261
- dropdownElement.value.style.left = `${fieldElementBounding.left.value}px`
262
-
263
- if (spaceAbove <= dropdownHeight && spaceBelow <= dropdownHeight) {
264
- dropdownElement.value.style.top = '0'
265
- dropdownElement.value.style.bottom = 'auto'
266
- } else if (spaceBelow > dropdownHeight) {
267
- dropdownElement.value.style.top = `${fieldElementBounding.bottom.value + window.scrollY}px`
268
- dropdownElement.value.style.bottom = 'auto'
269
- } else if (spaceAbove > dropdownHeight) {
270
- dropdownElement.value.style.top = 'auto'
271
- dropdownElement.value.style.bottom = `${spaceBelow + fieldElementBounding.height.value + 8 - window.scrollY}px`
272
- }
273
- }
274
-
275
- onMounted(() => {
276
- window.addEventListener('resize', calculateDropdownPosition)
277
- })
278
-
279
- onUnmounted(() => {
280
- window.removeEventListener('resize', calculateDropdownPosition)
281
- })
246
+ // Positioning shared with FormFieldSelect / FormFieldCombobox via
247
+ // useDropdownPosition (no more top:0 overlap, #221). Anchor to the whole field
248
+ // box (icon prefix + input), not the inner <input> the input sits to the
249
+ // right of the prefix and is narrower, so anchoring to it would leave the
250
+ // dropdown shifted right and narrower than the field. 'match' locks the listbox
251
+ // to the field width; the dropdownWidth prop (when set) overrides it.
252
+ const { calculateDropdownPosition } = useDropdownPosition(
253
+ dropdownElement,
254
+ fieldElement,
255
+ isOpen,
256
+ {
257
+ widthMode: 'match',
258
+ getWidthOverride: () => props.dropdownWidth != null
259
+ ? (typeof props.dropdownWidth === 'number' ? `${props.dropdownWidth}px` : props.dropdownWidth)
260
+ : null,
261
+ },
262
+ )
282
263
 
283
264
  defineExpose({
284
265
  focus() {
@@ -1,9 +1,10 @@
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'
7
+ import { useDropdownPosition } from '../../composables/useDropdownPosition.js'
7
8
 
8
9
  defineOptions({
9
10
  inheritAttrs: false,
@@ -144,82 +145,58 @@ onClickOutside(formFieldSelectElement, () => close())
144
145
 
145
146
  // Anchor dropdown to the visible trigger (button on desktop, select on mobile)
146
147
  const anchorElement = computed(() => triggerElement.value || selectElement.value)
147
- const anchorBounding = useElementBounding(anchorElement)
148
-
149
- function calculateDropdownPosition() {
150
- if (!dropdownElement.value) return
151
-
152
- // Reset inline positioning before measuring so a previously-applied
153
- // max-height (from an earlier constrained open) doesn't pollute the
154
- // offsetHeight read below. Without this, a subsequent re-open after
155
- // window resize would see the *capped* height and pick the wrong branch.
156
- dropdownElement.value.style.maxHeight = ''
157
- dropdownElement.value.style.top = ''
158
- dropdownElement.value.style.bottom = ''
159
-
160
- const dropdownElementHeight = dropdownElement.value.offsetHeight
161
- const viewportHeight = window.innerHeight
162
- const spaceAbove = anchorBounding.top.value
163
- const spaceBelow = viewportHeight - anchorBounding.bottom.value
164
-
165
- dropdownElement.value.style.minWidth = `${anchorBounding.width.value}px`
166
-
167
- /**
168
- * Clamp the dropdown to the viewport horizontally.
169
- * First cap maxWidth to the full viewport minus margins, then shift
170
- * left if the dropdown's natural width would overflow the right edge.
171
- * This keeps the popover fully visible for triggers near the right
172
- * edge (e.g. fixed-width filter selects in ListControl).
173
- */
174
- const viewportWidth = window.innerWidth
175
- const safeMargin = 16
176
- dropdownElement.value.style.maxWidth = `${viewportWidth - safeMargin * 2}px`
177
-
178
- // Temporarily position at trigger left so we can measure actual width
179
- dropdownElement.value.style.left = `${anchorBounding.left.value}px`
180
- const dropdownWidth = dropdownElement.value.offsetWidth
181
- const rightOverflow = (anchorBounding.left.value + dropdownWidth + safeMargin) - viewportWidth
182
-
183
- // Shift left if the dropdown overflows the right edge
184
- if (rightOverflow > 0) {
185
- const clampedLeft = Math.max(safeMargin, anchorBounding.left.value - rightOverflow)
186
- dropdownElement.value.style.left = `${clampedLeft}px`
187
- }
188
148
 
189
- // The dropdown has `mt-2` (8px) in CSS, so 8px gets consumed by the
190
- // visual gap to the trigger; subtract another 8px for breathing room
191
- // from the viewport edge (sub-pixel rounding + box-shadow ink).
192
- const verticalOffset = 16
149
+ // Shared with FormFieldCombobox / FormFieldSearchAsync see useDropdownPosition.
150
+ // 'min' lets the listbox grow wider than the trigger (long option labels) while
151
+ // never being narrower, and clamps to the viewport.
152
+ const { calculateDropdownPosition } = useDropdownPosition(
153
+ dropdownElement,
154
+ anchorElement,
155
+ isOpen,
156
+ { widthMode: 'min' },
157
+ )
158
+
159
+ // Decide the native-<select> swap *reactively* rather than with a once-at-mount
160
+ // read (#176): the old approach rendered the wrong control on initial mobile /
161
+ // emulated load and never corrected when the pointer type changed (e.g. Chrome
162
+ // device-emulation toggled on/off). We listen to matchMedia('(pointer: coarse)')
163
+ // so the decision updates live, OR'd with the touch-capability flags as a
164
+ // fallback where pointer media queries are absent.
165
+ let coarsePointerQuery = null
166
+ function updateIsMobileDevice() {
167
+ const coarsePointer = coarsePointerQuery?.matches ?? false
168
+ isMobileDevice.value = coarsePointer
169
+ || 'ontouchstart' in window
170
+ || navigator.maxTouchPoints > 0
171
+ }
193
172
 
194
- if (spaceBelow > dropdownElementHeight) {
195
- dropdownElement.value.style.top = `${anchorBounding.bottom.value + window.scrollY}px`
196
- dropdownElement.value.style.bottom = 'auto'
197
- return
198
- } else if (spaceAbove > spaceBelow) {
199
- dropdownElement.value.style.top = 'auto'
200
- dropdownElement.value.style.bottom = `${spaceBelow + anchorBounding.height.value + 8 - window.scrollY}px`
201
- dropdownElement.value.style.maxHeight = `${spaceAbove - verticalOffset}px`
202
- return
203
- } else {
204
- dropdownElement.value.style.top = `${anchorBounding.bottom.value + window.scrollY}px`
205
- dropdownElement.value.style.bottom = 'auto'
206
- dropdownElement.value.style.maxHeight = `${spaceBelow - verticalOffset}px`
207
- return
173
+ // Subscribe to live pointer-type changes. Prefer the modern addEventListener,
174
+ // but fall back to the deprecated addListener/removeListener so legacy Safari /
175
+ // WebViews — the very browsers this reactive path exists to support — still
176
+ // update instead of silently no-op'ing under an `addEventListener?.` optional chain.
177
+ function subscribePointerQuery() {
178
+ if (coarsePointerQuery?.addEventListener) {
179
+ coarsePointerQuery.addEventListener('change', updateIsMobileDevice)
180
+ } else if (coarsePointerQuery?.addListener) {
181
+ coarsePointerQuery.addListener(updateIsMobileDevice)
208
182
  }
209
183
  }
210
-
211
- function handleResize() {
212
- calculateDropdownPosition()
184
+ function unsubscribePointerQuery() {
185
+ if (coarsePointerQuery?.removeEventListener) {
186
+ coarsePointerQuery.removeEventListener('change', updateIsMobileDevice)
187
+ } else if (coarsePointerQuery?.removeListener) {
188
+ coarsePointerQuery.removeListener(updateIsMobileDevice)
189
+ }
213
190
  }
214
191
 
215
192
  onMounted(() => {
216
- isMobileDevice.value = 'ontouchstart' in window
217
- || (navigator.maxTouchPoints && navigator.maxTouchPoints > 0)
218
- window.addEventListener('resize', handleResize)
193
+ coarsePointerQuery = window.matchMedia?.('(pointer: coarse)') ?? null
194
+ updateIsMobileDevice()
195
+ subscribePointerQuery()
219
196
  })
220
197
 
221
198
  onUnmounted(() => {
222
- window.removeEventListener('resize', handleResize)
199
+ unsubscribePointerQuery()
223
200
  })
224
201
 
225
202
  defineExpose({
@@ -164,13 +164,23 @@ function closeOnEscape(e) {
164
164
  }
165
165
  }
166
166
 
167
+ // Close when the page scrolls — the trigger moves but the fixed-position popup
168
+ // does not recalculate, causing visual detachment.
169
+ function closeOnScroll() {
170
+ if (isOpen.value) {
171
+ close()
172
+ }
173
+ }
174
+
167
175
  onMounted(() => {
168
176
  document.addEventListener('keydown', closeOnEscape)
169
177
  document.addEventListener('click', closeOnClickOutside)
178
+ window.addEventListener('scroll', closeOnScroll, { passive: true, capture: true })
170
179
  })
171
180
  onUnmounted(() => {
172
181
  document.removeEventListener('keydown', closeOnEscape)
173
182
  document.removeEventListener('click', closeOnClickOutside)
183
+ window.removeEventListener('scroll', closeOnScroll, { capture: true })
174
184
  clearTimeout(hoverTimeout)
175
185
  })
176
186
  </script>
@@ -0,0 +1,154 @@
1
+ import { watch, onUnmounted, unref } from 'vue'
2
+
3
+ /**
4
+ * Shared positioning logic for the teleported dropdowns used by
5
+ * FormFieldSelect, FormFieldCombobox and FormFieldSearchAsync.
6
+ *
7
+ * All three teleport their option list to <body> and position it manually
8
+ * against their trigger. The collision maths was originally hand-copied into
9
+ * each component and then only FormFieldSelect was fixed (PR #212), so the
10
+ * three diverged — FormFieldCombobox (#220) and FormFieldSearchAsync (#221)
11
+ * kept a `top: 0` fallback that pins the dropdown over its own input when the
12
+ * list is too tall to fit above or below the trigger (most visibly inside a
13
+ * vertically-centred Modal). This composable is FormFieldSelect's proven
14
+ * approach lifted out so a single fix lands everywhere at once.
15
+ *
16
+ * What it does on each (re)measure:
17
+ * - Resets inline maxHeight/top/bottom before reading offsetHeight, so a cap
18
+ * applied during an earlier constrained open can't poison the branch
19
+ * decision on re-open.
20
+ * - Preserves scrollTop across the reflow (scroll-chaining past the list's
21
+ * edge re-fires this handler and would otherwise snap the list to the top).
22
+ * - Clamps horizontally to the viewport, shifting left if the natural width
23
+ * would overflow the right edge.
24
+ * - Picks the side with the most room when the list fits neither gap, clamps
25
+ * max-height to that gap, anchors to the matching trigger edge, and lets
26
+ * the existing CSS `overflow` scroll the list. There is no `top: 0` branch.
27
+ *
28
+ * It also owns the scroll (capture) + resize listeners that keep the open
29
+ * dropdown anchored, wiring them off the shared `isOpen` ref.
30
+ *
31
+ * @param {import('vue').Ref<HTMLElement|null>} dropdownRef the teleported list element
32
+ * @param {import('vue').Ref<HTMLElement|null>} anchorRef the element to anchor to (trigger/input/field)
33
+ * @param {import('vue').Ref<boolean>} isOpenRef reactive open state
34
+ * @param {object} [options]
35
+ * @param {'min'|'match'} [options.widthMode='min'] 'min' sets min-width to the anchor width
36
+ * (dropdown may grow wider, e.g. Select); 'match' locks width to the anchor width
37
+ * (Combobox/SearchAsync).
38
+ * @param {() => (string|null)} [options.getWidthOverride] returns an explicit CSS width that
39
+ * wins over widthMode (e.g. SearchAsync's `dropdownWidth` prop), or null to defer.
40
+ * @param {number} [options.verticalOffset=16] px subtracted from the available gap when clamping
41
+ * max-height (visual gap to trigger + breathing room from the viewport edge).
42
+ * @returns {{ calculateDropdownPosition: () => void }}
43
+ */
44
+ export function useDropdownPosition(dropdownRef, anchorRef, isOpenRef, options = {}) {
45
+ const {
46
+ widthMode = 'min',
47
+ getWidthOverride = () => null,
48
+ verticalOffset = 16,
49
+ } = options
50
+
51
+ function calculateDropdownPosition() {
52
+ const dropdown = dropdownRef.value
53
+ const anchor = unref(anchorRef)
54
+ if (!dropdown || !anchor) return
55
+
56
+ // Preserve scrollTop before the reset so scroll-chaining (wheel past the
57
+ // list's boundary chains to the page, triggering this handler) cannot snap
58
+ // the list back to the top when maxHeight is cleared and a reflow occurs.
59
+ const savedScrollTop = dropdown.scrollTop
60
+
61
+ // Reset inline positioning before measuring so a previously-applied
62
+ // max-height (from an earlier constrained open) doesn't pollute the
63
+ // offsetHeight read below. Without this, a subsequent re-open after
64
+ // window resize would see the *capped* height and pick the wrong branch.
65
+ dropdown.style.maxHeight = ''
66
+ dropdown.style.top = ''
67
+ dropdown.style.bottom = ''
68
+
69
+ const bounding = anchor.getBoundingClientRect()
70
+ const dropdownHeight = dropdown.offsetHeight
71
+ const viewportHeight = window.innerHeight
72
+ const spaceAbove = bounding.top
73
+ const spaceBelow = viewportHeight - bounding.bottom
74
+
75
+ // Width: lock to the anchor ('match'), float wider but no narrower
76
+ // ('min'), or honour an explicit override (e.g. SearchAsync dropdownWidth).
77
+ const widthOverride = getWidthOverride()
78
+ if (widthOverride != null) {
79
+ dropdown.style.width = widthOverride
80
+ } else if (widthMode === 'match') {
81
+ dropdown.style.width = `${bounding.width}px`
82
+ } else {
83
+ dropdown.style.minWidth = `${bounding.width}px`
84
+ }
85
+
86
+ /**
87
+ * Clamp the dropdown to the viewport horizontally.
88
+ * First cap maxWidth to the full viewport minus margins, then shift
89
+ * left if the dropdown's natural width would overflow the right edge.
90
+ * This keeps the popover fully visible for triggers near the right
91
+ * edge (e.g. fixed-width filter selects in ListControl).
92
+ */
93
+ const viewportWidth = window.innerWidth
94
+ const safeMargin = 16
95
+ dropdown.style.maxWidth = `${viewportWidth - safeMargin * 2}px`
96
+
97
+ // Position at the anchor's left so we can measure actual width, then
98
+ // shift left if it overflows the right edge.
99
+ dropdown.style.left = `${bounding.left}px`
100
+ const dropdownWidth = dropdown.offsetWidth
101
+ const rightOverflow = (bounding.left + dropdownWidth + safeMargin) - viewportWidth
102
+ if (rightOverflow > 0) {
103
+ const clampedLeft = Math.max(safeMargin, bounding.left - rightOverflow)
104
+ dropdown.style.left = `${clampedLeft}px`
105
+ }
106
+
107
+ if (spaceBelow > dropdownHeight) {
108
+ // Fits below: anchor under the trigger.
109
+ dropdown.style.top = `${bounding.bottom + window.scrollY}px`
110
+ dropdown.style.bottom = 'auto'
111
+ } else if (spaceAbove > spaceBelow) {
112
+ // More room above: anchor above the trigger, clamp to the gap.
113
+ dropdown.style.top = 'auto'
114
+ dropdown.style.bottom = `${spaceBelow + bounding.height + 8 - window.scrollY}px`
115
+ dropdown.style.maxHeight = `${spaceAbove - verticalOffset}px`
116
+ } else {
117
+ // More room below (or equal): anchor under the trigger, clamp to the gap.
118
+ // Never pin to top:0 over the trigger — let CSS overflow scroll the list.
119
+ dropdown.style.top = `${bounding.bottom + window.scrollY}px`
120
+ dropdown.style.bottom = 'auto'
121
+ dropdown.style.maxHeight = `${spaceBelow - verticalOffset}px`
122
+ }
123
+
124
+ dropdown.scrollTop = savedScrollTop
125
+ }
126
+
127
+ function handleScroll(event) {
128
+ // Ignore scrolls originating inside the dropdown's own list — only
129
+ // reposition when the page/ancestors scroll the trigger.
130
+ if (dropdownRef.value?.contains(event.target)) return
131
+ calculateDropdownPosition()
132
+ }
133
+
134
+ // Scroll AND resize are wired off `isOpen`: a closed dropdown has nothing to
135
+ // reposition, and leaving a resize listener registered for the component's
136
+ // whole lifetime forces an offsetHeight reflow on every (hidden) dropdown on
137
+ // every resize tick — needless layout thrash that scales with field count.
138
+ watch(isOpenRef, (nowOpen) => {
139
+ if (nowOpen) {
140
+ window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
141
+ window.addEventListener('resize', calculateDropdownPosition)
142
+ } else {
143
+ window.removeEventListener('scroll', handleScroll, { capture: true })
144
+ window.removeEventListener('resize', calculateDropdownPosition)
145
+ }
146
+ })
147
+
148
+ onUnmounted(() => {
149
+ window.removeEventListener('resize', calculateDropdownPosition)
150
+ window.removeEventListener('scroll', handleScroll, { capture: true })
151
+ })
152
+
153
+ return { calculateDropdownPosition }
154
+ }