@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 +1 -1
- package/src/components/FormField/FormFieldCombobox.vue +156 -39
- package/src/components/FormField/FormFieldSearchAsync.vue +19 -51
- package/src/components/FormField/FormFieldSelect.vue +43 -88
- package/src/components/Tooltip/Tooltip.vue +10 -0
- package/src/composables/useDropdownPosition.js +154 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
390
|
+
function handleNativeInput(event) {
|
|
391
|
+
nativeText.value = event.target.value
|
|
392
|
+
emit('input', event)
|
|
393
|
+
}
|
|
340
394
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
450
|
+
coarsePointerQuery = window.matchMedia?.('(pointer: coarse)') ?? null
|
|
451
|
+
updateIsMobileDevice()
|
|
452
|
+
subscribePointerQuery()
|
|
371
453
|
})
|
|
372
454
|
|
|
373
455
|
onUnmounted(() => {
|
|
374
|
-
|
|
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,
|
|
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
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
function
|
|
224
|
-
if (
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
193
|
+
coarsePointerQuery = window.matchMedia?.('(pointer: coarse)') ?? null
|
|
194
|
+
updateIsMobileDevice()
|
|
195
|
+
subscribePointerQuery()
|
|
240
196
|
})
|
|
241
197
|
|
|
242
198
|
onUnmounted(() => {
|
|
243
|
-
|
|
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
|
+
}
|