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',
|
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||||
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 ? undefined : (activeIndex === -1 ? selectAllId : (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"
|
||||||
@@ -162,7 +171,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
|
||||||
@@ -174,18 +186,23 @@
|
|||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-if="displaySelectAll && normalizedOptions.length > 0"
|
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
|
@mousedown.prevent
|
||||||
|
@click="toggleAll"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="allSelected"
|
:model-value="allSelected"
|
||||||
:label="selectAllLabel"
|
:label="selectAllLabel"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
group-class="!mt-0"
|
group-class="!mt-0 pointer-events-none"
|
||||||
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
label-class="option-checkbox w-full font-semibold"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
:reserve-message-space="false"
|
:reserve-message-space="false"
|
||||||
@update:model-value="toggleAll"
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
@@ -194,7 +211,7 @@
|
|||||||
:key="String(opt.value)"
|
:key="String(opt.value)"
|
||||||
role="option"
|
role="option"
|
||||||
:aria-selected="isChecked(opt.value)"
|
:aria-selected="isChecked(opt.value)"
|
||||||
class="px-3 py-2"
|
class="cursor-pointer px-3 py-2"
|
||||||
:class="[
|
:class="[
|
||||||
index === activeIndex ? 'bg-m-muted/10' : '',
|
index === activeIndex ? 'bg-m-muted/10' : '',
|
||||||
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
||||||
@@ -202,16 +219,16 @@
|
|||||||
]"
|
]"
|
||||||
@mouseenter="activeIndex = index"
|
@mouseenter="activeIndex = index"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
|
@click="toggleOption(opt.value)"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="isChecked(opt.value)"
|
:model-value="isChecked(opt.value)"
|
||||||
:label="opt.label || '\u00A0'"
|
:label="opt.label || '\u00A0'"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
group-class="!mt-0"
|
group-class="!mt-0 pointer-events-none"
|
||||||
label-class="option-checkbox w-full cursor-pointer"
|
label-class="option-checkbox w-full"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
:reserve-message-space="false"
|
:reserve-message-space="false"
|
||||||
@update:model-value="toggleOption(opt.value)"
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -235,14 +252,17 @@
|
|||||||
</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 Checkbox from '../checkbox/Checkbox.vue'
|
import Checkbox from '../checkbox/Checkbox.vue'
|
||||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number
|
value: string | number
|
||||||
@@ -302,6 +322,9 @@ const openDirection = ref<'down' | 'up'>('down')
|
|||||||
const uid = useId()
|
const uid = useId()
|
||||||
const buttonId = `custom-select-btn-${uid}`
|
const buttonId = `custom-select-btn-${uid}`
|
||||||
const listboxId = `custom-select-listbox-${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 listRef = ref<HTMLElement | null>(null)
|
||||||
const listHeight = ref(0)
|
const listHeight = ref(0)
|
||||||
const normalizedOptions = computed<Option[]>(() => props.options)
|
const normalizedOptions = computed<Option[]>(() => props.options)
|
||||||
@@ -427,6 +450,70 @@ function toggleAll() {
|
|||||||
nextTick(() => buttonRef.value?.focus())
|
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) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
if (!root.value) return
|
if (!root.value) return
|
||||||
if (!root.value.contains(e.target as Node)) close()
|
if (!root.value.contains(e.target as Node)) close()
|
||||||
|
|||||||
Reference in New Issue
Block a user