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: 2px solid rgb(var(--m-primary) / 1);
|
||||||
outline-offset: 2px;
|
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 {
|
@layer base {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
|
@blur="onKbdBlur"
|
||||||
@click="onInputClick"
|
@click="onInputClick"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
@@ -90,6 +91,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'border-m-success select-scrollbar-success'
|
? 'border-m-success select-scrollbar-success'
|
||||||
: 'border-m-primary select-scrollbar-primary',
|
: 'border-m-primary select-scrollbar-primary',
|
||||||
|
keyboardFocused ? 'm-combo-ring-bottom' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
@@ -150,13 +152,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 {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: 'MalioInputAutocomplete', inheritAttrs: false})
|
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
@@ -321,6 +326,7 @@ const labelPositionClass = computed(() =>
|
|||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
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',
|
'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 ? '' : 'grow-height',
|
||||||
isReadonly.value
|
isReadonly.value
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
@@ -400,6 +406,7 @@ const onInput = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
|
onKbdFocus()
|
||||||
if (props.disabled || props.readonly) return
|
if (props.disabled || props.readonly) return
|
||||||
isFocused.value = true
|
isFocused.value = true
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
@@ -446,7 +453,20 @@ const closeAndRevert = () => {
|
|||||||
isFocused.value = false
|
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) => {
|
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') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
closeAndRevert()
|
closeAndRevert()
|
||||||
@@ -479,7 +499,25 @@ const onKeydown = (event: KeyboardEvent) => {
|
|||||||
|
|
||||||
if (event.key === 'ArrowUp') {
|
if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault()
|
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)
|
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