fix(web-core): keep an open select's Escape from closing its overlay (#10582)

This commit is contained in:
Thierry Goettelmann
2026-10-15 10:48:37 +02:00
committed by GitHub
parent 025f6eb533
commit 6115c74bbe
7 changed files with 70 additions and 64 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div :class="className" class="vts-select">
<div ref="rootRef" :class="className" class="vts-select">
<UiInput
ref="triggerRef"
:accent
@@ -53,7 +53,7 @@ import {
useFormSelectController,
} from '@core/packages/form-select'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { useCurrentElement, useElementSize } from '@vueuse/core'
import { useElementSize } from '@vueuse/core'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -86,11 +86,13 @@ const {
isLoading,
} = select as FormSelect
const { triggerRef, dropdownRef, searchRef, isOpen, floatingStyles } = useFormSelectController(select as FormSelect)
const { rootRef, triggerRef, dropdownRef, searchRef, isOpen, floatingStyles } = useFormSelectController(
select as FormSelect
)
const className = computed(() => toVariants({ accent }))
const { width } = useElementSize(useCurrentElement())
const { width } = useElementSize(rootRef)
const minWidth = computed(() => `${width.value}px`)
@@ -106,7 +108,6 @@ defineExpose({
}
}
/* Teleported */
.dropdown-list {
min-width: v-bind(minWidth);
max-height: 36.2rem; /* 8 Dropdown items */

View File

@@ -1,12 +1,13 @@
import { ifElse } from '@core/utils/if-else.utils.ts'
import { autoUpdate, flip, type MaybeElement, shift, size, useFloating } from '@floating-ui/vue'
import { clamp, onClickOutside, useEventListener, useFocusWithin, whenever } from '@vueuse/core'
import { logicOr } from '@vueuse/math'
import { clamp, onClickOutside, useEventListener, whenever } from '@vueuse/core'
import { computed, provide, reactive, ref, watch } from 'vue'
import { type FormOptionIndex, type FormSelect, IK_FORM_SELECT_CONTROLLER } from './types.ts'
import { useFormSelectKeyboardNavigation } from './use-form-select-keyboard-navigation.ts'
export function useFormSelectController(select: FormSelect) {
const rootRef = ref<HTMLElement>()
const activeOption = computed(() => select.options.value.find(option => option.flags.active))
const isOpen = ref(false)
@@ -15,10 +16,6 @@ export function useFormSelectController(select: FormSelect) {
const triggerRef = ref<HTMLElement>()
const { focused: isTriggerFocused } = useFocusWithin(triggerRef)
const isActive = logicOr(isTriggerFocused, isOpen)
useEventListener(triggerRef, 'click', () => openDropdown())
function focusTrigger() {
@@ -28,7 +25,7 @@ export function useFormSelectController(select: FormSelect) {
/* KEYBOARD NAVIGATION */
const { isNavigatingWithKeyboard, stopKeyboardNavigation } = useFormSelectKeyboardNavigation({
isActive,
target: rootRef,
isMultiple: select.isMultiple,
isOpen,
onSelect: () => activeOption.value?.toggleFlag('selected', true),
@@ -46,6 +43,14 @@ export function useFormSelectController(select: FormSelect) {
useEventListener(dropdownRef, 'mousemove', () => stopKeyboardNavigation())
useEventListener(dropdownRef, 'mousedown', event => {
if (event.target instanceof HTMLInputElement) {
return
}
event.preventDefault()
})
/* DROPDOWN PLACEMENT */
const { floatingStyles } = useFloating(triggerRef, dropdownRef, {
@@ -80,7 +85,7 @@ export function useFormSelectController(select: FormSelect) {
return true
}
whenever(isOpen, () => focusSearch(), { flush: 'post' })
whenever(isOpen, () => focusSearchOrTrigger(), { flush: 'post' })
const currentIndex = computed(() => select.options.value.findIndex(option => option.flags.active))
@@ -215,6 +220,7 @@ export function useFormSelectController(select: FormSelect) {
)
return {
rootRef,
triggerRef,
dropdownRef,
searchRef,

View File

@@ -1,10 +1,9 @@
import { ifElse } from '@core/utils/if-else.utils.ts'
import { onKeyStroke } from '@vueuse/core'
import { type EffectScope, effectScope, ref, type Ref } from 'vue'
import { ref, type Ref } from 'vue'
import { FORM_SELECT_HANDLED_KEY, type FormOptionIndex } from './types.ts'
export function useFormSelectKeyboardNavigation({
isActive,
target,
isOpen,
isMultiple,
onOpen,
@@ -13,7 +12,7 @@ export function useFormSelectKeyboardNavigation({
onToggle,
onSelect,
}: {
isActive: Ref<boolean>
target: Ref<HTMLElement | undefined>
isOpen: Ref<boolean>
isMultiple: Ref<boolean>
onOpen: () => void
@@ -99,7 +98,7 @@ export function useFormSelectKeyboardNavigation({
event: KeyboardEvent,
key: FORM_SELECT_HANDLED_KEY.TAB | FORM_SELECT_HANDLED_KEY.ESCAPE
) {
if (key === FORM_SELECT_HANDLED_KEY.ESCAPE) {
if (key === FORM_SELECT_HANDLED_KEY.ESCAPE && isOpen.value) {
event.preventDefault()
}
@@ -108,47 +107,36 @@ export function useFormSelectKeyboardNavigation({
const handledKeys = Object.values(FORM_SELECT_HANDLED_KEY)
let scope: EffectScope | undefined
onKeyStroke(
handledKeys,
(event): void => {
const key = event.key as FORM_SELECT_HANDLED_KEY
function registerEvents() {
scope = effectScope()
scope.run(() =>
onKeyStroke(handledKeys, (event): void => {
const key = event.key as FORM_SELECT_HANDLED_KEY
switch (key) {
case FORM_SELECT_HANDLED_KEY.DOWN:
case FORM_SELECT_HANDLED_KEY.UP:
return handleArrowUpDownKeys(event, key)
case FORM_SELECT_HANDLED_KEY.PAGE_DOWN:
case FORM_SELECT_HANDLED_KEY.PAGE_UP:
return handlePageUpDownKeys(event, key)
case FORM_SELECT_HANDLED_KEY.HOME:
case FORM_SELECT_HANDLED_KEY.END:
return handleHomeEndKeys(event, key)
case FORM_SELECT_HANDLED_KEY.ENTER:
case FORM_SELECT_HANDLED_KEY.SPACE:
return handleEnterSpaceKeys(event, key)
case FORM_SELECT_HANDLED_KEY.LEFT:
case FORM_SELECT_HANDLED_KEY.RIGHT:
return handleLeftRightKeys()
case FORM_SELECT_HANDLED_KEY.TAB:
case FORM_SELECT_HANDLED_KEY.ESCAPE:
return handleTabEscapeKeys(event, key)
default:
return key
}
})
)
}
function unregisterEvents() {
scope?.stop()
scope = undefined
}
ifElse(isActive, registerEvents, unregisterEvents)
switch (key) {
case FORM_SELECT_HANDLED_KEY.DOWN:
case FORM_SELECT_HANDLED_KEY.UP:
return handleArrowUpDownKeys(event, key)
case FORM_SELECT_HANDLED_KEY.PAGE_DOWN:
case FORM_SELECT_HANDLED_KEY.PAGE_UP:
return handlePageUpDownKeys(event, key)
case FORM_SELECT_HANDLED_KEY.HOME:
case FORM_SELECT_HANDLED_KEY.END:
return handleHomeEndKeys(event, key)
case FORM_SELECT_HANDLED_KEY.ENTER:
case FORM_SELECT_HANDLED_KEY.SPACE:
return handleEnterSpaceKeys(event, key)
case FORM_SELECT_HANDLED_KEY.LEFT:
case FORM_SELECT_HANDLED_KEY.RIGHT:
return handleLeftRightKeys()
case FORM_SELECT_HANDLED_KEY.TAB:
case FORM_SELECT_HANDLED_KEY.ESCAPE:
return handleTabEscapeKeys(event, key)
default:
return key
}
},
{ target }
)
return {
isNavigatingWithKeyboard,

View File

@@ -378,7 +378,14 @@ export function useFormSelect<
selectedValues,
newValues => {
if (isMultiple.value) {
model.value = newValues as TMultiple extends true ? $TValue[] : $TValue
const modelValues = model.value as $TValue[]
if (
newValues.length !== modelValues.length ||
newValues.some((value, index) => toRaw(value) !== toRaw(modelValues[index]))
) {
model.value = newValues as TMultiple extends true ? $TValue[] : $TValue
}
} else if (newValues.length > 0) {
model.value = newValues[0] as TMultiple extends true ? $TValue[] : $TValue
}

View File

@@ -241,6 +241,8 @@ useOverlayEscape(() => emit('dismiss'))
Like `useOverlayTrigger()`, it works from any component rendered inside an overlay, and does nothing when the component is not rendered in an overlay.
An Escape whose default has been prevented is ignored: a nested component that handles Escape itself calls `event.preventDefault()` to keep the overlay open.
## Composition: building your own composable
`useOverlay` is designed to be wrapped in small, domain-specific composables:

View File

@@ -5,9 +5,10 @@ import { inject } from 'vue'
/**
* Calls the handler when Escape is pressed while the calling component is the
* current (topmost) overlay and is not busy. The handler is expected to emit
* the component's own close event, so that Escape goes through the exact same
* pipeline as a pointer interaction.
* current (topmost) overlay, is not busy and the Escape has not been
* default-prevented. The handler is expected to emit the component's own close
* event, so that Escape goes through the exact same pipeline as a pointer
* interaction.
*/
export function useOverlayEscape(handler: () => void) {
const overlayKey = inject(IK_OVERLAY_KEY, undefined)
@@ -18,8 +19,8 @@ export function useOverlayEscape(handler: () => void) {
const overlayStore = useOverlayStore()
onKeyStroke('Escape', () => {
if (!overlayStore.isCurrent(overlayKey.value)) {
onKeyStroke('Escape', event => {
if (event.defaultPrevented || !overlayStore.isCurrent(overlayKey.value)) {
return
}

View File

@@ -73,6 +73,7 @@
- [xo-cli] fix output failing when outputing a rest api answer (PR [#10541](https://github.com/vatesfr/xen-orchestra/pull/10541))
- [XO5/Self Service] Fix removing a tag from a resource set removing all the other tags instead (PR [#10550](https://github.com/vatesfr/xen-orchestra/pull/10550))
- [Backups/DR] Report the actual XAPI error instead of `(intermediate value) is not iterable` when a VM import fails (PR [#10551](https://github.com/vatesfr/xen-orchestra/pull/10551/))
- [VDI/Export] Fix pressing Escape in the format select closing the whole export drawer (PR [#10582](https://github.com/vatesfr/xen-orchestra/pull/10582))
### Packages to release