feat(autocomplete) : anneau focus clavier + navigation clavier APG
- Anneau de focus clavier (clavier-only) ; quand la liste est ouverte, l'anneau entoure tout le bloc input + liste d'un seul tenant (utilitaires CSS .m-combo-ring-top / .m-combo-ring-bottom) - Navigation clavier aux normes WAI-ARIA APG : scroll auto de l'option active dans la vue, ArrowUp ouvre sur la dernière option, Home/End, Tab ferme la liste Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,23 @@
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
|
||||
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
|
||||
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
|
||||
reste sans contour pour un raccord sans couture. */
|
||||
.m-combo-ring-top {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 -2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
.m-combo-ring-bottom {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onKbdBlur"
|
||||
@click="onInputClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
@@ -90,6 +91,7 @@
|
||||
: hasSuccess
|
||||
? 'border-m-success select-scrollbar-success'
|
||||
: 'border-m-primary select-scrollbar-primary',
|
||||
keyboardFocused ? 'm-combo-ring-bottom' : '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -150,13 +152,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, 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: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: string | number
|
||||
@@ -321,6 +326,7 @@ const labelPositionClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -400,6 +406,7 @@ const onInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
onKbdFocus()
|
||||
if (props.disabled || props.readonly) return
|
||||
isFocused.value = true
|
||||
isOpen.value = true
|
||||
@@ -446,7 +453,20 @@ const closeAndRevert = () => {
|
||||
isFocused.value = false
|
||||
}
|
||||
|
||||
// Garde l'option active visible dans la liste défilante quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
// Tab : laisse le focus partir mais ferme la liste (et valide la saisie courante)
|
||||
if (event.key === 'Tab') {
|
||||
if (isOpen.value) closeAndCommit()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeAndRevert()
|
||||
@@ -479,7 +499,25 @@ const onKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
// Liste fermée : ouvre et place sur la dernière option (APG)
|
||||
if (!isOpen.value) {
|
||||
isOpen.value = true
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
return
|
||||
}
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// Home / End : première / dernière option quand la liste est ouverte
|
||||
if (isOpen.value && event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (isOpen.value && event.key === 'End') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user