@redseed/redseed-ui-vue3 8.41.0 → 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.41.0",
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'
2
+ import { ref, computed, onMounted, onUnmounted, watch, useAttrs, useId } from 'vue'
3
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,57 +367,101 @@ onClickOutside(comboboxElement, () => {
326
367
  }
327
368
  })
328
369
 
329
- function calculateDropdownPosition() {
330
- if (!dropdownElement.value || !inputElement.value) return
331
-
332
- const bounding = inputElement.value.getBoundingClientRect()
333
- const viewportHeight = window.innerHeight
334
- const dropdownElementHeight = dropdownElement.value.offsetHeight
335
- const spaceAboveInput = bounding.top
336
- const spaceBelowInput = viewportHeight - bounding.bottom
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
+ }
337
389
 
338
- dropdownElement.value.style.width = `${bounding.width}px`
339
- dropdownElement.value.style.left = `${bounding.left}px`
390
+ function handleNativeInput(event) {
391
+ nativeText.value = event.target.value
392
+ emit('input', event)
393
+ }
340
394
 
341
- if (spaceAboveInput <= dropdownElementHeight && spaceBelowInput <= dropdownElementHeight) {
342
- dropdownElement.value.style.top = '0'
343
- dropdownElement.value.style.bottom = 'auto'
344
- return
345
- } else if (spaceBelowInput > dropdownElementHeight) {
346
- dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
347
- dropdownElement.value.style.bottom = 'auto'
348
- return
349
- } else if (spaceAboveInput > dropdownElementHeight) {
350
- dropdownElement.value.style.top = 'auto'
351
- dropdownElement.value.style.bottom = `${spaceBelowInput + bounding.height + 8 - window.scrollY}px`
352
- return
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()
353
411
  }
354
412
  }
355
413
 
356
- function handleScroll(event) {
357
- if (dropdownElement.value?.contains(event.target)) return
358
- calculateDropdownPosition()
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
359
428
  }
360
429
 
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 })
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)
366
439
  }
367
- })
440
+ }
441
+ function unsubscribePointerQuery() {
442
+ if (coarsePointerQuery?.removeEventListener) {
443
+ coarsePointerQuery.removeEventListener('change', updateIsMobileDevice)
444
+ } else if (coarsePointerQuery?.removeListener) {
445
+ coarsePointerQuery.removeListener(updateIsMobileDevice)
446
+ }
447
+ }
368
448
 
369
449
  onMounted(() => {
370
- window.addEventListener('resize', calculateDropdownPosition)
450
+ coarsePointerQuery = window.matchMedia?.('(pointer: coarse)') ?? null
451
+ updateIsMobileDevice()
452
+ subscribePointerQuery()
371
453
  })
372
454
 
373
455
  onUnmounted(() => {
374
- window.removeEventListener('resize', calculateDropdownPosition)
375
- window.removeEventListener('scroll', handleScroll, { capture: true })
456
+ unsubscribePointerQuery()
376
457
  })
377
458
 
378
459
  defineExpose({
379
460
  focus() {
461
+ if (useNativeMobile.value) {
462
+ nativeInputElement.value?.focus()
463
+ return
464
+ }
380
465
  inputElement.value?.focus()
381
466
  },
382
467
  open,
@@ -405,7 +490,9 @@ defineExpose({
405
490
  <slot name="prefix"></slot>
406
491
  </div>
407
492
 
493
+ <!-- Desktop / non-native: styled input driving the custom floating dropdown -->
408
494
  <input
495
+ v-if="!useNativeMobile"
409
496
  ref="inputElement"
410
497
  role="combobox"
411
498
  :aria-activedescendant="isOpen && dropdownContent === 'options' && highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
@@ -432,9 +519,39 @@ defineExpose({
432
519
  @click="!isOpen && open()"
433
520
  >
434
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
+
435
552
  <!-- Placeholder slot for custom placeholder rendering -->
436
553
  <div
437
- v-if="$slots.placeholder && !displayText && !isOpen"
554
+ v-if="$slots.placeholder && !displayText && !isOpen && !useNativeMobile"
438
555
  class="rsui-form-field-combobox__placeholder"
439
556
  aria-hidden="true"
440
557
  @click="open()"
@@ -442,7 +559,7 @@ defineExpose({
442
559
  <slot name="placeholder"></slot>
443
560
  </div>
444
561
 
445
- <Teleport to="body">
562
+ <Teleport to="body" v-if="!useNativeMobile">
446
563
  <transition
447
564
  enter-active-class="enter-active-class"
448
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'
2
+ import { ref, computed, watch, useAttrs } from 'vue'
3
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,56 +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
- function calculateDropdownPosition() {
249
- if (!dropdownElement.value || !fieldElement.value) return
250
-
251
- const bounding = fieldElement.value.getBoundingClientRect()
252
- const viewportHeight = window.innerHeight
253
- const dropdownHeight = dropdownElement.value.offsetHeight
254
- const spaceAbove = bounding.top
255
- const spaceBelow = viewportHeight - bounding.bottom
256
-
257
- dropdownElement.value.style.width = props.dropdownWidth != null
258
- ? (typeof props.dropdownWidth === 'number' ? `${props.dropdownWidth}px` : props.dropdownWidth)
259
- : `${bounding.width}px`
260
- dropdownElement.value.style.left = `${bounding.left}px`
261
-
262
- if (spaceAbove <= dropdownHeight && spaceBelow <= dropdownHeight) {
263
- dropdownElement.value.style.top = '0'
264
- dropdownElement.value.style.bottom = 'auto'
265
- } else if (spaceBelow > dropdownHeight) {
266
- dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
267
- dropdownElement.value.style.bottom = 'auto'
268
- } else if (spaceAbove > dropdownHeight) {
269
- dropdownElement.value.style.top = 'auto'
270
- dropdownElement.value.style.bottom = `${spaceBelow + bounding.height + 8 - window.scrollY}px`
271
- }
272
- }
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
-
287
- onMounted(() => {
288
- window.addEventListener('resize', calculateDropdownPosition)
289
- })
290
-
291
- onUnmounted(() => {
292
- window.removeEventListener('resize', calculateDropdownPosition)
293
- window.removeEventListener('scroll', handleScroll, { capture: true })
294
- })
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
+ )
295
263
 
296
264
  defineExpose({
297
265
  focus() {
@@ -4,6 +4,7 @@ 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,
@@ -145,103 +146,57 @@ onClickOutside(formFieldSelectElement, () => close())
145
146
  // Anchor dropdown to the visible trigger (button on desktop, select on mobile)
146
147
  const anchorElement = computed(() => triggerElement.value || selectElement.value)
147
148
 
148
- function calculateDropdownPosition() {
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
155
-
156
- // Reset inline positioning before measuring so a previously-applied
157
- // max-height (from an earlier constrained open) doesn't pollute the
158
- // offsetHeight read below. Without this, a subsequent re-open after
159
- // window resize would see the *capped* height and pick the wrong branch.
160
- dropdownElement.value.style.maxHeight = ''
161
- dropdownElement.value.style.top = ''
162
- dropdownElement.value.style.bottom = ''
163
-
164
- const bounding = anchorElement.value.getBoundingClientRect()
165
- const dropdownElementHeight = dropdownElement.value.offsetHeight
166
- const viewportHeight = window.innerHeight
167
- const spaceAbove = bounding.top
168
- const spaceBelow = viewportHeight - bounding.bottom
169
-
170
- dropdownElement.value.style.minWidth = `${bounding.width}px`
171
-
172
- /**
173
- * Clamp the dropdown to the viewport horizontally.
174
- * First cap maxWidth to the full viewport minus margins, then shift
175
- * left if the dropdown's natural width would overflow the right edge.
176
- * This keeps the popover fully visible for triggers near the right
177
- * edge (e.g. fixed-width filter selects in ListControl).
178
- */
179
- const viewportWidth = window.innerWidth
180
- const safeMargin = 16
181
- dropdownElement.value.style.maxWidth = `${viewportWidth - safeMargin * 2}px`
182
-
183
- // Temporarily position at trigger left so we can measure actual width
184
- dropdownElement.value.style.left = `${bounding.left}px`
185
- const dropdownWidth = dropdownElement.value.offsetWidth
186
- const rightOverflow = (bounding.left + dropdownWidth + safeMargin) - viewportWidth
187
-
188
- // Shift left if the dropdown overflows the right edge
189
- if (rightOverflow > 0) {
190
- const clampedLeft = Math.max(safeMargin, bounding.left - rightOverflow)
191
- dropdownElement.value.style.left = `${clampedLeft}px`
192
- }
193
-
194
- // The dropdown has `mt-2` (8px) in CSS, so 8px gets consumed by the
195
- // visual gap to the trigger; subtract another 8px for breathing room
196
- // from the viewport edge (sub-pixel rounding + box-shadow ink).
197
- const verticalOffset = 16
198
-
199
- if (spaceBelow > dropdownElementHeight) {
200
- dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
201
- dropdownElement.value.style.bottom = 'auto'
202
- dropdownElement.value.scrollTop = savedScrollTop
203
- return
204
- } else if (spaceAbove > spaceBelow) {
205
- dropdownElement.value.style.top = 'auto'
206
- dropdownElement.value.style.bottom = `${spaceBelow + bounding.height + 8 - window.scrollY}px`
207
- dropdownElement.value.style.maxHeight = `${spaceAbove - verticalOffset}px`
208
- dropdownElement.value.scrollTop = savedScrollTop
209
- return
210
- } else {
211
- dropdownElement.value.style.top = `${bounding.bottom + window.scrollY}px`
212
- dropdownElement.value.style.bottom = 'auto'
213
- dropdownElement.value.style.maxHeight = `${spaceBelow - verticalOffset}px`
214
- dropdownElement.value.scrollTop = savedScrollTop
215
- return
216
- }
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
217
171
  }
218
172
 
219
- function handleResize() {
220
- calculateDropdownPosition()
221
- }
222
-
223
- function handleScroll(event) {
224
- if (dropdownElement.value?.contains(event.target)) return
225
- calculateDropdownPosition()
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)
182
+ }
226
183
  }
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 })
184
+ function unsubscribePointerQuery() {
185
+ if (coarsePointerQuery?.removeEventListener) {
186
+ coarsePointerQuery.removeEventListener('change', updateIsMobileDevice)
187
+ } else if (coarsePointerQuery?.removeListener) {
188
+ coarsePointerQuery.removeListener(updateIsMobileDevice)
233
189
  }
234
- })
190
+ }
235
191
 
236
192
  onMounted(() => {
237
- isMobileDevice.value = 'ontouchstart' in window
238
- || (navigator.maxTouchPoints && navigator.maxTouchPoints > 0)
239
- window.addEventListener('resize', handleResize)
193
+ coarsePointerQuery = window.matchMedia?.('(pointer: coarse)') ?? null
194
+ updateIsMobileDevice()
195
+ subscribePointerQuery()
240
196
  })
241
197
 
242
198
  onUnmounted(() => {
243
- window.removeEventListener('resize', handleResize)
244
- window.removeEventListener('scroll', handleScroll, { capture: true })
199
+ unsubscribePointerQuery()
245
200
  })
246
201
 
247
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
+ }