feat(select) : anneau focus clavier, navigation APG et focus conservé après sélection

- Anneau de focus clavier (clavier-only) ; ouvert, l'anneau entoure
  bouton + liste d'un seul tenant, adapté au sens d'ouverture (haut/bas)
- Navigation clavier WAI-ARIA APG (manquait) : ouverture, flèches avec
  scroll auto de l'option active, Home/End, Entrée/Espace, Échap, Tab
- aria-activedescendant pour les lecteurs d'écran
- Le focus reste sur le bouton après sélection (plus de blur vers le body)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 16:59:25 +02:00
parent 379f4a2d6f
commit 5b25e7c692
+79 -3
View File
@@ -37,15 +37,24 @@
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
rounded,
textField,
keyboardFocused
? (isOpen
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
: 'm-focus-ring-kbd')
: '',
]"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:aria-required="required || undefined"
:aria-readonly="isReadonly || undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
@focus="onKbdFocus"
@blur="onKbdBlur"
>
<label
v-if="label"
@@ -134,7 +143,10 @@
? 'border-m-danger'
: hasSuccess
? 'border-m-success'
: 'border-m-primary'
: 'border-m-primary',
keyboardFocused
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
: '',
]"
>
<li
@@ -184,13 +196,16 @@
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioSelect', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
type Option = {
label: string;
value: string | number | null
@@ -344,7 +359,68 @@ function toggle() {
function select(value: string | number | null) {
emit('update:modelValue', value)
close()
buttonRef.value?.blur()
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
// les options ; au clavier le focus n'a jamais quitté le bouton.
buttonRef.value?.focus()
}
// Garde l'option active visible quand on navigue au clavier
watch(activeIndex, async (index) => {
if (index < 0 || !isOpen.value) return
await nextTick()
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
})
function onKeydown(e: KeyboardEvent) {
if (props.disabled || props.readonly) return
// Tab : laisse le focus partir mais ferme la liste
if (e.key === 'Tab') {
if (isOpen.value) close()
return
}
// Liste fermée : ouverture au clavier
if (!isOpen.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
open()
}
return
}
// Liste ouverte
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const opt = normalizedOptions.value[activeIndex.value]
if (opt) select(opt.value)
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, 0)
return
}
if (e.key === 'Home') {
e.preventDefault()
activeIndex.value = 0
return
}
if (e.key === 'End') {
e.preventDefault()
activeIndex.value = normalizedOptions.value.length - 1
}
}
function onClickOutside(e: MouseEvent) {