feat : reorganisation de la structure projet

This commit is contained in:
2026-03-20 14:22:40 +01:00
parent c95a3657c0
commit 09cc3edf6f
51 changed files with 116 additions and 45 deletions

View File

@@ -0,0 +1,177 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Select from './Select.vue'
type Option = {
label: string
value: string | number | null
}
type SelectProps = {
modelValue?: string | number | null
options?: Option[]
emptyOptionLabel?: string
label?: string
hint?: string
error?: string
success?: string
minWidth?: string
maxWidth?: string
textField?: string
textValue?: string
textLabel?: string
rounded?: string
disabled?: boolean
}
const SelectForTest = Select as DefineComponent<SelectProps>
const options: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
]
describe('MalioSelect', () => {
it('renders the label text', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Country'},
})
expect(wrapper.get('label').text()).toBe('Country')
})
it('generates button and listbox ids and links them together', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
const button = wrapper.get('button')
expect(button.attributes('id')?.startsWith('custom-select-btn-')).toBe(true)
expect(button.attributes('aria-controls')?.startsWith('custom-select-listbox-')).toBe(true)
await button.trigger('click')
expect(wrapper.get('ul').attributes('id')).toBe(button.attributes('aria-controls'))
})
it('uses disabled styles and prevents opening when disabled', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, disabled: true, options},
})
const button = wrapper.get('button')
expect(button.attributes('disabled')).toBeDefined()
expect(button.classes()).toContain('cursor-not-allowed')
await button.trigger('click')
expect(wrapper.find('ul').exists()).toBe(false)
})
it('opens the list and rotates the icon on click', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('ul').exists()).toBe(true)
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('svg').classes()).toContain('rotate-180')
})
it('emits update:modelValue when selecting an option', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
await wrapper.findAll('li')[2].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
})
it('renders the empty option with muted text style', async () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
emptyOptionLabel: 'Aucune selection',
},
})
await wrapper.get('button').trigger('click')
const firstOption = wrapper.findAll('li')[0]
expect(firstOption.text()).toBe('Aucune selection')
expect(firstOption.classes()).toContain('text-black/40')
})
it('shows the selected value text when an option is selected', () => {
const wrapper = mount(SelectForTest, {
props: {
options,
modelValue: 'fr',
},
})
expect(wrapper.text()).toContain('France')
expect(wrapper.get('button').classes()).toContain('border-black')
})
it('shows hint message in muted color', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, hint: 'Select a country'},
})
expect(wrapper.get('p.text-m-muted').text()).toBe('Select a country')
})
it('shows error state on button, label and helper text', () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
label: 'Country',
error: 'Selection error',
},
})
expect(wrapper.get('button').classes()).toContain('border-m-error')
expect(wrapper.get('label').classes()).toContain('text-m-error')
expect(wrapper.get('p.text-m-error').text()).toBe('Selection error')
expect(wrapper.get('button').attributes('aria-invalid')).toBe('true')
})
it('shows success state on button, label and helper text', () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
label: 'Country',
success: 'Selection success',
},
})
expect(wrapper.get('button').classes()).toContain('border-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('p.text-m-success').text()).toBe('Selection success')
})
it('prioritizes error over success', () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
error: 'Selection error',
success: 'Selection success',
},
})
expect(wrapper.get('button').classes()).toContain('border-m-error')
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-error').text()).toBe('Selection error')
})
})

View File

@@ -0,0 +1,333 @@
<template>
<div
ref="root"
class="relative mt-4 w-full"
:class="[minWidth, maxWidth]"
>
<button
:id="buttonId"
type="button"
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
:class="[
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-error !border-b-0'
: 'rounded-t-none !border-2 !border-m-error !border-t-0'
: 'border-m-error'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
: 'border-m-success'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded,
textField,
]"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:disabled="disabled"
@click="toggle"
>
<label
v-if="label"
class="floating-label pointer-events-none absolute left-3 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
isOpen ? 'top-2 z-30' : 'top-2',
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
>
{{ label }}
</label>
<span
class="block truncate"
:class="[
textValue,
isOptionSelected ? 'text-black' : 'select-none text-transparent'
]"
>
{{ selectedLabel || '\u00A0' }}
</span>
<span
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-current'
]"
>
<slot name="icon">
<IconifyIcon
icon="mdi:chevron-down"
width="20"
class="transition-transform duration-300"
:class="isOpen ? 'rotate-180' : 'rotate-0'"
/>
</slot>
</span>
</button>
<ul
v-if="isOpen"
:id="listboxId"
ref="listRef"
role="listbox"
:aria-labelledby="buttonId"
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
:class="[
openDirection === 'down'
? 'top-[calc(100%-2px)] rounded-b-md border-t-0'
: 'bottom-[calc(100%-2px)] rounded-t-md border-b-0',
hasError
? 'select-scrollbar-error'
: hasSuccess
? 'select-scrollbar-success'
: 'select-scrollbar-primary',
hasError
? 'border-m-error'
: hasSuccess
? 'border-m-success'
: 'border-m-primary'
]"
>
<li
v-for="(opt, index) in normalizedOptions"
:id="optionId(index)"
:key="String(opt.value)"
role="option"
:aria-selected="opt.value === modelValue"
class="cursor-pointer px-3 py-2"
:class="[
index === activeIndex ? 'bg-m-muted/10' : '',
opt.value === modelValue ? 'bg-m-muted/10 font-semibold' : '',
opt.value === null ? 'text-black/40' : 'text-black'
]"
@mouseenter="activeIndex = index"
@mousedown.prevent
@click="select(opt.value)"
>
{{ opt.label || '\u00A0' }}
</li>
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
defineOptions({name: 'MalioSelect', inheritAttrs: false})
type Option = {
label: string;
value: string | number | null
}
const props = withDefaults(defineProps<{
modelValue: string | number | null
options?: Option[]
emptyOptionLabel?: string
label?: string
hint?: string
error?: string
success?: string
minWidth?: string
maxWidth?: string
textField?: string
textValue?: string
textLabel?: string
rounded?: string
disabled?: boolean
}>(), {
options: () => [],
emptyOptionLabel: '',
label: '',
hint: '',
error: '',
success: '',
minWidth: 'w-96',
maxWidth: '',
textField: 'text-lg',
textValue: 'text-lg',
textLabel: 'text-sm',
rounded: 'rounded-md',
disabled: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string | number | null): void
}>()
const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const activeIndex = ref(-1)
const openDirection = ref<'down' | 'up'>('down')
const uid = useId()
const buttonId = `custom-select-btn-${uid}`
const listboxId = `custom-select-listbox-${uid}`
const listRef = ref<HTMLElement | null>(null)
const listHeight = ref(0)
const normalizedOptions = computed<Option[]>(() => [
{label: props.emptyOptionLabel, value: null},
...props.options,
])
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() =>
props.options.some(o => o.value === props.modelValue)
)
const shouldFloatLabel = computed(() =>
isOpen.value || isOptionSelected.value
)
const selectedLabel = computed(() =>
props.options.find(o => o.value === props.modelValue)?.label ?? ''
)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${buttonId}-describedby` : undefined,
)
function optionId(index: number) {
return `custom-select-opt-${uid}-${index}`
}
function updateOpenDirection() {
if (!root.value) return
const rect = root.value.getBoundingClientRect()
const estimatedListHeight = Math.min(normalizedOptions.value.length * 40, 240)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
openDirection.value =
spaceBelow >= estimatedListHeight || spaceBelow >= spaceAbove
? 'down'
: 'up'
}
function open() {
updateOpenDirection()
isOpen.value = true
const selectedIndex = normalizedOptions.value.findIndex(o => o.value === props.modelValue)
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0
nextTick(() => {
if (openDirection.value === 'up' && listRef.value) {
listHeight.value = listRef.value.offsetHeight
} else {
listHeight.value = 0
}
})
}
const labelTransformStyle = computed(() => {
// label non flottant
if (!shouldFloatLabel.value) {
return {}
}
// fermé ou ouverture vers le bas : comportement classique
if (!isOpen.value || openDirection.value === 'down') {
return {
transform: 'translateY(-1.15rem) scale(0.9)',
}
}
// ouverture vers le haut : on remonte en fonction de la hauteur de la liste
const extraOffset = 8 // marge visuelle au-dessus de la liste en px
const total = 4 +listHeight.value + extraOffset
// 18 ≈ 1.15rem pour garder la même base que votre flottant actuel
return {
transform: `translateY(-${total}px) scale(0.9)`,
}
})
function close() {
isOpen.value = false
}
function toggle() {
if (props.disabled) return
if (isOpen.value) {
close()
return
}
open()
}
function select(value: string | number | null) {
emit('update:modelValue', value)
close()
}
function onClickOutside(e: MouseEvent) {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()
}
onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
:deep(ul[role="listbox"]) {
scrollbar-width: auto;
scrollbar-gutter: stable;
}
:deep(.select-scrollbar-primary) {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
:deep(.select-scrollbar-error) {
scrollbar-color: #000000 transparent;
}
:deep(.select-scrollbar-success) {
scrollbar-color: #000000 transparent;
}
</style>

View File

@@ -0,0 +1,178 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import SelectCheckbox from './SelectCheckbox.vue'
type Option = {
label: string
value: string | number
}
type SelectCheckboxProps = {
modelValue: Array<string | number>
options?: Option[]
emptyOptionLabel?: string
label?: string
hint?: string
error?: string
success?: string
minWidth?: string
maxWidth?: string
textField?: string
textValue?: string
textLabel?: string
rounded?: string
displayTag?: boolean
displaySelectAll?: boolean
selectAllLabel?: string
disabled?: boolean
}
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
const options: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
]
describe('MalioSelectCheckbox', () => {
it('renders checkbox inputs for options', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(options.length)
})
it('emits an array with the toggled option value', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options},
})
await wrapper.get('button').trigger('click')
const checkboxInputs = wrapper.findAll('input[type="checkbox"]')
await checkboxInputs[1].setValue(true)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be']])
})
it('shows the selected count over the total count in the trigger', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr', 'ca'], options},
})
expect(wrapper.text()).toContain('2/3')
})
it('shows 0 over the total count when nothing is selected', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
expect(wrapper.text()).toContain('0/3')
})
it('hides the summary when displayTag is enabled and options are selected', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr', 'ca'], options, displayTag: true},
})
expect(wrapper.text()).not.toContain('2/3')
expect(wrapper.text()).toContain('France')
expect(wrapper.text()).toContain('Canada')
})
it('hides the summary when displayTag is enabled and nothing is selected', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, displayTag: true, emptyOptionLabel: 'Aucune selection'},
})
expect(wrapper.text()).not.toContain('0/3')
expect(wrapper.text()).toContain('Aucune selection')
})
it('does not show select all checkbox by default', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(options.length)
})
it('shows select all checkbox when displaySelectAll is true', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, displaySelectAll: true},
})
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(options.length + 1)
expect(wrapper.text()).toContain('Tout sélectionner')
})
it('shows custom select all label', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, displaySelectAll: true, selectAllLabel: 'Sélectionner tout'},
})
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('Sélectionner tout')
})
it('emits all values when select all is clicked and none selected', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, displaySelectAll: true},
})
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(true)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be', 'ca']])
})
it('emits empty array when select all is clicked and all selected', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr', 'be', 'ca'], options, displaySelectAll: true},
})
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(false)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]])
})
it('select all checkbox is checked when all options are selected', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr', 'be', 'ca'], options, displaySelectAll: true},
})
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(true)
})
it('select all checkbox is unchecked when not all options are selected', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, displaySelectAll: true},
})
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false)
})
})

View File

@@ -0,0 +1,410 @@
<template>
<div
ref="root"
class="relative mt-4 w-full"
:class="[minWidth, maxWidth]"
>
<button
:id="buttonId"
type="button"
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
:class="[
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-error !border-b-0'
: 'rounded-t-none !border-2 !border-m-error !border-t-0'
: 'border-m-error'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
: 'border-m-success'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded,
textField,
]"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:disabled="disabled"
@click="toggle"
>
<label
v-if="label"
class="floating-label pointer-events-none absolute left-3 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? 'top-2 z-30' : 'top-1/2 -translate-y-1/2',
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
>
{{ label }}
</label>
<div
v-if="displayTags && selectedOptions.length > 0"
class="flex flex-wrap items-center justify-start gap-1"
:class="[label ? 'pt-1' : '']"
>
<span
v-for="option in selectedOptions"
:key="String(option.value)"
class="inline-flex max-w-full items-center rounded-md border border-black px-2 text-sm leading-none text-black"
>
<span class="truncate pb-[2px]">{{ option.label }}</span>
</span>
</div>
<span
v-else-if="displayTag && emptyOptionLabel"
class="block truncate text-right"
:class="[
textValue,
label ? 'pl-24' : '',
'text-m-muted'
]"
>
{{ emptyOptionLabel }}
</span>
<span
v-if="!displayTag"
class="block truncate text-right"
:class="[
textValue,
label ? 'pl-24' : '',
isOptionSelected ? 'text-black' : 'text-m-muted'
]"
>
{{ selectionSummary }}
</span>
<span
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-current'
]"
>
<slot name="icon">
<IconifyIcon
icon="mdi:chevron-down"
width="20"
class="transition-transform duration-300"
:class="isOpen ? 'rotate-180' : 'rotate-0'"
/>
</slot>
</span>
</button>
<ul
v-if="isOpen"
:id="listboxId"
ref="listRef"
role="listbox"
:aria-labelledby="buttonId"
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
:class="[
openDirection === 'down'
? 'top-[calc(100%-2px)] rounded-b-md border-t-0'
: 'bottom-[calc(100%-2px)] rounded-t-md border-b-0',
hasError
? 'select-scrollbar-error'
: hasSuccess
? 'select-scrollbar-success'
: 'select-scrollbar-primary',
hasError
? 'border-m-error'
: hasSuccess
? 'border-m-success'
: 'border-m-primary'
]"
>
<li
v-if="displaySelectAll"
class="border-b border-m-muted/30 px-3 py-2"
@mousedown.prevent
>
<Checkbox
:model-value="allSelected"
:label="selectAllLabel"
:disabled="disabled"
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer font-semibold"
tabindex="-1"
@update:model-value="toggleAll"
/>
</li>
<li
v-for="(opt, index) in normalizedOptions"
:id="optionId(index)"
:key="String(opt.value)"
role="option"
:aria-selected="isChecked(opt.value)"
class="px-3 py-2"
:class="[
index === activeIndex ? 'bg-m-muted/10' : '',
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
'text-black'
]"
@mouseenter="activeIndex = index"
@mousedown.prevent
>
<Checkbox
:model-value="isChecked(opt.value)"
:label="opt.label || '\u00A0'"
:disabled="disabled"
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer"
tabindex="-1"
@update:model-value="toggleOption(opt.value)"
/>
</li>
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import Checkbox from '../checkbox/Checkbox.vue'
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
type Option = {
label: string;
value: string | number
}
const props = withDefaults(defineProps<{
modelValue: Array<string | number>
options?: Option[]
emptyOptionLabel?: string
label?: string
hint?: string
error?: string
success?: string
minWidth?: string
maxWidth?: string
textField?: string
textValue?: string
textLabel?: string
rounded?: string
displayTag?: boolean
displaySelectAll?: boolean
selectAllLabel?: string
disabled?: boolean
}>(), {
options: () => [],
emptyOptionLabel: '',
label: '',
hint: '',
error: '',
success: '',
minWidth: 'w-96',
maxWidth: '',
textField: 'text-lg',
textValue: 'text-lg',
textLabel: 'text-sm',
rounded: 'rounded-md',
displayTag: false,
displaySelectAll: false,
selectAllLabel: 'Tout sélectionner',
disabled: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', v: Array<string | number>): void
}>()
const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const activeIndex = ref(-1)
const openDirection = ref<'down' | 'up'>('down')
const uid = useId()
const buttonId = `custom-select-btn-${uid}`
const listboxId = `custom-select-listbox-${uid}`
const listRef = ref<HTMLElement | null>(null)
const listHeight = ref(0)
const normalizedOptions = computed<Option[]>(() => props.options)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() =>
props.modelValue.length > 0
)
const selectedOptions = computed(() =>
normalizedOptions.value.filter(option => props.modelValue.includes(option.value)),
)
const displayTags = computed(() =>
props.displayTag && selectedOptions.value.length > 0,
)
const shouldFloatLabel = computed(() =>
isOpen.value || displayTags.value
)
const selectionSummary = computed(() =>
`${props.modelValue.length}/${normalizedOptions.value.length}`
)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${buttonId}-describedby` : undefined,
)
function optionId(index: number) {
return `custom-select-opt-${uid}-${index}`
}
function updateOpenDirection() {
if (!root.value) return
const rect = root.value.getBoundingClientRect()
const estimatedListHeight = Math.min(normalizedOptions.value.length * 40, 240)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
openDirection.value =
spaceBelow >= estimatedListHeight || spaceBelow >= spaceAbove
? 'down'
: 'up'
}
function open() {
updateOpenDirection()
isOpen.value = true
const selectedIndex = normalizedOptions.value.findIndex(o => props.modelValue.includes(o.value))
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0
nextTick(() => {
if (openDirection.value === 'up' && listRef.value) {
listHeight.value = listRef.value.offsetHeight
} else {
listHeight.value = 0
}
})
}
const labelTransformStyle = computed(() => {
if (!shouldFloatLabel.value) {
return undefined
}
if (!isOpen.value || openDirection.value === 'down') {
return {
transform: 'translateY(-1.15rem) scale(0.9)',
}
}
const extraOffset = 8 // marge visuelle au-dessus de la liste en px
const total = 4 + listHeight.value + extraOffset
return {
transform: `translateY(-${total}px) scale(0.9)`,
}
})
function close() {
isOpen.value = false
}
function toggle() {
if (props.disabled) return
if (isOpen.value) {
close()
return
}
open()
}
const allSelected = computed(() =>
normalizedOptions.value.length > 0
&& normalizedOptions.value.every(opt => props.modelValue.includes(opt.value)),
)
function toggleAll() {
if (allSelected.value) {
emit('update:modelValue', [])
} else {
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
}
}
function isChecked(value: string | number) {
return props.modelValue.includes(value)
}
function toggleOption(value: string | number) {
if (isChecked(value)) {
emit('update:modelValue', props.modelValue.filter(item => item !== value))
return
}
emit('update:modelValue', [...props.modelValue, value])
}
function onClickOutside(e: MouseEvent) {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()
}
onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
:deep(ul[role="listbox"]) {
scrollbar-width: auto;
scrollbar-gutter: stable;
}
:deep(.select-scrollbar-primary) {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
:deep(.select-scrollbar-error) {
scrollbar-color: #000000 transparent;
}
:deep(.select-scrollbar-success) {
scrollbar-color: #000000 transparent;
}
</style>