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:
@@ -37,15 +37,24 @@
|
|||||||
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
||||||
rounded,
|
rounded,
|
||||||
textField,
|
textField,
|
||||||
|
keyboardFocused
|
||||||
|
? (isOpen
|
||||||
|
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||||
|
: 'm-focus-ring-kbd')
|
||||||
|
: '',
|
||||||
]"
|
]"
|
||||||
:aria-expanded="isOpen"
|
:aria-expanded="isOpen"
|
||||||
:aria-controls="listboxId"
|
:aria-controls="listboxId"
|
||||||
|
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||||
:aria-invalid="hasError"
|
:aria-invalid="hasError"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
:aria-required="required || undefined"
|
:aria-required="required || undefined"
|
||||||
:aria-readonly="isReadonly || undefined"
|
:aria-readonly="isReadonly || undefined"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@focus="onKbdFocus"
|
||||||
|
@blur="onKbdBlur"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
@@ -134,7 +143,10 @@
|
|||||||
? 'border-m-danger'
|
? 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'border-m-success'
|
? 'border-m-success'
|
||||||
: 'border-m-primary'
|
: 'border-m-primary',
|
||||||
|
keyboardFocused
|
||||||
|
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||||
|
: '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
@@ -184,13 +196,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number | null
|
value: string | number | null
|
||||||
@@ -344,7 +359,68 @@ function toggle() {
|
|||||||
function select(value: string | number | null) {
|
function select(value: string | number | null) {
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
close()
|
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) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user