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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user