feat(select-checkbox) : anneau focus clavier, navigation APG et clic pleine ligne

- Anneau de focus clavier (clavier-only), combo bouton + liste selon le sens
- Navigation clavier WAI-ARIA APG : ouverture, flèches + scroll auto,
  Home/End, Entrée/Espace togglent (liste reste ouverte), Échap/Tab ferment
- Ligne "Tout sélectionner" intégrée à la navigation clavier (index -1)
- aria-activedescendant ; focus conservé sur le bouton
- Clic sur toute la ligne (li) coche/décoche, plus seulement sur le label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:07:44 +02:00
parent 5b25e7c692
commit 3432054a58
+97 -10
View File
@@ -37,15 +37,24 @@
label ? 'min-h-[40px]' : 'h-[40px] py-0',
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 ? undefined : (activeIndex === -1 ? selectAllId : (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"
@@ -162,7 +171,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
@@ -174,18 +186,23 @@
</li>
<li
v-if="displaySelectAll && normalizedOptions.length > 0"
class="border-b border-m-muted/30 px-3 py-2"
:id="selectAllId"
role="option"
:aria-selected="allSelected"
class="cursor-pointer border-b border-m-muted/30 px-3 py-2"
:class="[activeIndex === -1 ? 'bg-m-muted/10' : '']"
@mouseenter="activeIndex = -1"
@mousedown.prevent
@click="toggleAll"
>
<Checkbox
:model-value="allSelected"
:label="selectAllLabel"
:disabled="disabled"
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer font-semibold"
group-class="!mt-0 pointer-events-none"
label-class="option-checkbox w-full font-semibold"
tabindex="-1"
:reserve-message-space="false"
@update:model-value="toggleAll"
/>
</li>
<li
@@ -194,7 +211,7 @@
:key="String(opt.value)"
role="option"
:aria-selected="isChecked(opt.value)"
class="px-3 py-2"
class="cursor-pointer px-3 py-2"
:class="[
index === activeIndex ? 'bg-m-muted/10' : '',
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
@@ -202,16 +219,16 @@
]"
@mouseenter="activeIndex = index"
@mousedown.prevent
@click="toggleOption(opt.value)"
>
<Checkbox
:model-value="isChecked(opt.value)"
:label="opt.label || '\u00A0'"
:disabled="disabled"
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer"
group-class="!mt-0 pointer-events-none"
label-class="option-checkbox w-full"
tabindex="-1"
:reserve-message-space="false"
@update:model-value="toggleOption(opt.value)"
/>
</li>
</ul>
@@ -235,14 +252,17 @@
</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 Checkbox from '../checkbox/Checkbox.vue'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
type Option = {
label: string;
value: string | number
@@ -302,6 +322,9 @@ const openDirection = ref<'down' | 'up'>('down')
const uid = useId()
const buttonId = `custom-select-btn-${uid}`
const listboxId = `custom-select-listbox-${uid}`
const selectAllId = `custom-select-all-${uid}`
// Index actif le plus bas : -1 cible la ligne « tout sélectionner » quand elle est affichée
const lowestIndex = computed(() => (props.displaySelectAll && normalizedOptions.value.length > 0 ? -1 : 0))
const listRef = ref<HTMLElement | null>(null)
const listHeight = ref(0)
const normalizedOptions = computed<Option[]>(() => props.options)
@@ -427,6 +450,70 @@ function toggleAll() {
nextTick(() => buttonRef.value?.focus())
}
// Garde l'option active visible quand on navigue au clavier
watch(activeIndex, async (index) => {
if (!isOpen.value) return
await nextTick()
const id = index === -1 ? selectAllId : (index >= 0 ? optionId(index) : null)
if (id) document.getElementById(id)?.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 (multi-select : Entrée/Espace togglent et la liste reste ouverte)
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
// -1 = ligne « tout sélectionner »
if (activeIndex.value === -1) {
toggleAll()
return
}
const opt = normalizedOptions.value[activeIndex.value]
if (opt) toggleOption(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, lowestIndex.value)
return
}
if (e.key === 'Home') {
e.preventDefault()
activeIndex.value = lowestIndex.value
return
}
if (e.key === 'End') {
e.preventDefault()
activeIndex.value = normalizedOptions.value.length - 1
}
}
function onClickOutside(e: MouseEvent) {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()