| Numéro du ticket | Titre du ticket | |------------------|-----------------| | #366 | Création d'un composant de type Select checkbox | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #11 Co-authored-by: kevin <kevin@yuno.malio.fr> Co-committed-by: kevin <kevin@yuno.malio.fr>
This commit was merged in pull request #11.
This commit is contained in:
168
.playground/pages/composant/selectCheckbox.vue
Normal file
168
.playground/pages/composant/selectCheckbox.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="basicValue"
|
||||||
|
:options="options"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec tag</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="labelValue"
|
||||||
|
:options="options"
|
||||||
|
displayTag="true"
|
||||||
|
empty-option-label=" "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec tag + label</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="labelValue1"
|
||||||
|
:options="options"
|
||||||
|
displayTag="true"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label=" "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="labelValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Valeur preselectionnee</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Hint</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="hintValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
hint="Choisissez votre pays"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="errorValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
error="Ce champ est obligatoire"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succes</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="successValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
success="Selection validee"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Desactive</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="disabledValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
disabled
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sans options</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="emptyValue"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label="Aucun pays disponible"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 md:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="longListValue"
|
||||||
|
:options="longOptions"
|
||||||
|
label="Pays"
|
||||||
|
hint="Permet de verifier la scrollbar"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 md:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Ouverture en bas de page</h2>
|
||||||
|
<div class="h-64" />
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="bottomValue"
|
||||||
|
:options="longOptions"
|
||||||
|
label="Ouverture adaptative"
|
||||||
|
hint="A ouvrir pres du bas de la page"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{label: 'France', value: 'fr'},
|
||||||
|
{label: 'Belgique', value: 'be'},
|
||||||
|
{label: 'Suisse', value: 'ch'},
|
||||||
|
{label: 'Canada', value: 'ca'},
|
||||||
|
{label: 'Allemagne', value: 'de'},
|
||||||
|
{label: 'Espagne', value: 'es'},
|
||||||
|
{label: 'Italie', value: 'it'},
|
||||||
|
{label: 'Portugal', value: 'pt'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const longOptions = [
|
||||||
|
...options,
|
||||||
|
{label: 'Pays-Bas', value: 'nl'},
|
||||||
|
{label: 'Suede', value: 'se'},
|
||||||
|
{label: 'Norvege', value: 'no'},
|
||||||
|
{label: 'Danemark', value: 'dk'},
|
||||||
|
{label: 'Finlande', value: 'fi'},
|
||||||
|
{label: 'Autriche', value: 'at'},
|
||||||
|
{label: 'Irlande', value: 'ie'},
|
||||||
|
{label: 'Grece', value: 'gr'},
|
||||||
|
{label: 'Pologne', value: 'pl'},
|
||||||
|
{label: 'Hongrie', value: 'hu'},
|
||||||
|
{label: 'Republique tcheque', value: 'cz'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const basicValue = ref<Array<string | number>>([])
|
||||||
|
const labelValue = ref<Array<string | number>>([])
|
||||||
|
const labelValue1 = ref<Array<string | number>>([])
|
||||||
|
const selectedValue = ref<Array<string | number>>(['fr'])
|
||||||
|
const hintValue = ref<Array<string | number>>([])
|
||||||
|
const errorValue = ref<Array<string | number>>([])
|
||||||
|
const successValue = ref<Array<string | number>>(['be'])
|
||||||
|
const disabledValue = ref<Array<string | number>>(['ca'])
|
||||||
|
const emptyValue = ref<Array<string | number>>([])
|
||||||
|
const longListValue = ref<Array<string | number>>([])
|
||||||
|
const bottomValue = ref<Array<string | number>>([])
|
||||||
|
</script>
|
||||||
@@ -7,11 +7,13 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
* [#333] Création d'un composant text
|
* [#333] Création d'un composant text
|
||||||
* [#364] Création d'un composant button radio
|
|
||||||
* [#337] Création d'un composant select
|
* [#337] Création d'un composant select
|
||||||
|
* [#362] Création d'un composant checkbox
|
||||||
* [#363] Création d'un composant amount
|
* [#363] Création d'un composant amount
|
||||||
* [#363] Création d'un composant checkbox
|
* [#364] Création d'un composant button radio
|
||||||
|
* [#365] Création d'un composant number
|
||||||
|
* [#366] Création d'un composant select checkbox
|
||||||
|
* [#407] Création d'un composant time
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
95
app/components/malio/SelectCheckbox.test.ts
Normal file
95
app/components/malio/SelectCheckbox.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
378
app/components/malio/SelectCheckbox.vue
Normal file
378
app/components/malio/SelectCheckbox.vue
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<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-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.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
|
||||||
|
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,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user