Liste déroulante enrichie - DsfrMultiselect 
🌟 Introduction 
Le DsfrMultiselect est un composant Vue permettant à un utilisateur de choisir un ou plusieurs élément dans une liste donnée.
La liste déroulante fournit une liste d’option parmi lesquelles l’utilisateur peut choisir. L'utilisateur peut filtrer cette liste et utiliser un bouton pour sélectionner/déselectionner tous les éléments visibles
🏅 La documentation sur liste déroulante riche sur le DSFR
📐 Structure 
- Libellé - Obligatoire (prop - label)
- Une description - Optionnelle (prop - hintou slot- hint)
- Une liste, composée d’un ensemble d’options sélectionnables - Obligatoire (prop - options)- À l’intérieur de la liste : - Un bouton “tout sélectionner” “tout désélectionner” - Optionnel (props selectAlletselectAllLabel)
- Un champs de saisie - Optionnel
 - Une section de formulaire (fieldset) contenant : - Une légende - Optionnelle (qui peut être masquée à l’écran - prop legendou slotlegend)
- Une description du groupe - Optionnelle (qui peut être masquée à l’écran)
- Une liste d’options - Obligatoire (prop options)
 
- Un bouton “tout sélectionner” “tout désélectionner” - Optionnel (props 
| nom | type | défaut | obligatoire | Description | 
|---|---|---|---|---|
| id | string | random string | Identifiant unique pour l'input. Si non spécifié, un ID aléatoire est généré. | |
| modelValue | (string | number)[] | `` | ✅ | La valeur liée au modèle de l'input. | 
| options | (T | string | number)[] | ✅ | Options sélectionnables. | |
| label | string | ✅ | Le libellé de l'input. | |
| labelVisible | boolean | true | Gére l'affichage du label ou non. | |
| labelClass | string | '' | Classe personnalisée pour le style du libellé. | |
| legend | string | '' | Texte de legend. | |
| hint | string | '' | Texte d'indice pour guider l'utilisateur. | |
| successMessage | string | '' | Message de validation à afficher en dessous du select. | |
| errorMessage | string | '' | Message d'erreur à afficher en dessous du select. | |
| buttonLabel | string | Sélectionner une option, ... | Texte qui s'affiche sur le bouton. | |
| selectAll | boolean | true | Gérer l'affichage du bouton de 'sélectionner tout'. | |
| search | boolean | true | Gérer le label du 'sélectionner tout'. | |
| selectAllLabel | [string, string] | ["Tout sélectionner", "Tout désélectionner"] | Gérer le label du 'sélectionner tout'. | |
| idKey | keyof T | id | Voir ci dessous. | |
| labelKey | keyof T | label | Voir ci dessous. | |
| filteringKeys | (keyof T)[] | ['label'] | Voir ci dessous. | |
| maxOverflowHeight | CSSStyleDeclaration['maxHeight'] | '400px' | Taille maximum du dropdown. | 
⚠️ Cas particuliers 
Cas d'utilisation d'objets dans des options 
Pour l'utilisation d'objets comme props, il peut être nécessaire de renseigner idKey, labelKey et filteringKeys:
- idKeyest la clef d'un identifiant unique de chaque élément. C'est cette valeur qui sera utilisée dans- modelValue
- labelKeyest la clef utilisée pour afficher le label des checkboxs
- filteringKeysest une array de clefs qui sont utilisé pour filtrer dans le search
Attributs implicitement déclarés 
Important
Toutes les props passées à <DsfrMultiselect> dans une template et qui ne sont pas définies dans les props seront passées à la balise <button> native du composant (cf. Attributs implicitement déclarés (Fallthrough attributes) de la documentation officielle de Vue.js.). Comme par exemple readonly.
Voici une liste non-exhaustive:
- name
- readonly
- disabled
- autocomplete
- autofocus(déconseillé)
- size
- maxlength
- pattern
DsfrMultiselect dans une iframe 
Important
Si DsfrMultiselect est placé dans une iframe, il n'aura pas accès aux clics exterieurs pour se fermer.
📡Évenements 
DsfrMultiselect émet l'événement suivant :
| Nom | type | Description | 
|---|---|---|
| update:modelValue | Array<(T | string | number)> | Est émis lorsque la valeur du select change. | 
🧩 Slots 
DsfrMultiselect permet les slots suivants :
| Nom | props | Description | 
|---|---|---|
| label | Permet de changer le label. | |
| required-tip | Permet de changer le required-tip. | |
| hint | Permet de changer le hint. | |
| button-label | Permet de changer le label du bouton. | |
| legend | Permet de changer la legend du bouton. | |
| checkbox-label | (props: { option: T | string | number }) | Permet de changer le label des checkboxs. | 
| no-results | Permet de changer l'affichage lorsque la recherche donne aucun élément. | 
📝 Exemples 
Exemple Basique 
<script setup lang="ts">
import { ref } from 'vue'
import DsfrMultiselect from '../DsfrMultiselect.vue'
const options = [
  'Dupont',
  'Martin',
  'Durand',
  'Petit',
  'Lefevre',
]
const values = ref<string[]>([])
</script>
<template>
  <div class="flex flex-col">
    <div style="padding-left: 5rem; padding-right: 5rem">
      {{ values }}
      <DsfrMultiselect
        v-model="values"
        :options="options"
        search
        select-all
      />
    </div>
  </div>
</template>Exemple Complexe 
<script setup lang="ts">
import { computed, ref } from 'vue'
import DsfrMultiselect from '../DsfrMultiselect.vue'
const options = [
  {
    nom: 'Dupont',
    prenom: 'Marie',
    age: 28,
  },
  {
    nom: 'Martin',
    prenom: 'Paul',
    age: 34,
  },
  {
    nom: 'Durand',
    prenom: 'Lucie',
    age: 22,
  },
  {
    nom: 'Petit',
    prenom: 'Julien',
    age: 45,
  },
  {
    nom: 'Lefevre',
    prenom: 'Elise',
    age: 30,
  },
]
const values = ref<string[]>([])
const buttonLabel = computed(() => {
  const nbElements = values.value.length
  if (nbElements === 0) {
    return '0 option'
  }
  return `${nbElements} option${nbElements > 1 ? 's' : ''}`
})
const errorMessage = computed(() => values.value.length ? '' : 'Érreur')
</script>
<template>
  <div class="flex flex-col">
    <div style="padding-left: 5rem; padding-right: 5rem">
      {{ values }}
      <DsfrMultiselect
        v-model="values"
        :options="options"
        :button-label="buttonLabel"
        legend="DsfrMultiselect"
        search
        select-all
        :error-message="errorMessage"
        id-key="nom"
        :filtering-keys="['nom', 'prenom']"
      >
        <template #label>
          DsfrMultiselect exemple
        </template>
        <template #checkbox-label="{ option }">
          {{ option.nom }} - {{ option.prenom }} {{ option.age }}
        </template>
      </DsfrMultiselect>
    </div>
  </div>
</template>⚙️ Code source du composant 
<script lang="ts" setup generic="T extends Object | string | number">
import type { DsfrMultiSelectProps, DsfrMultiSelectSlots } from './DsfrMultiselect.types'
import { computed, onUnmounted, ref } from 'vue'
import { useCollapsable } from '../../composables'
import DsfrButton from '../DsfrButton/DsfrButton.vue'
import DsfrCheckbox from '../DsfrCheckbox/DsfrCheckbox.vue'
import DsfrFieldset from '../DsfrFieldset/DsfrFieldset.vue'
import DsfrInput from '../DsfrInput/DsfrInput.vue'
import { useRandomId } from '@/utils/random-utils'
const props = withDefaults(
  defineProps<DsfrMultiSelectProps<T>>(),
  {
    labelVisible: true,
    labelClass: '',
    hint: '',
    legend: '',
    id: () => useRandomId('multiselect'),
    buttonLabel: '',
    selectAll: false,
    errorMessage: '',
    successMessage: '',
    selectAllLabel: () => ['Tout sélectionner', 'Tout désélectionner'],
    search: false,
    idKey: 'id' as keyof {
      [K in keyof T as T[K] extends string | number ? K : never]: T[K];
    },
    labelKey: 'label' as keyof {
      [K in keyof T as T[K] extends string | number ? K : never]: T[K];
    },
    filteringKeys: () => ['label'] as (keyof T)[],
    maxOverflowHeight: '400px',
  },
)
defineSlots<DsfrMultiSelectSlots<T>>()
const isObjectWithIdKey = (
  option: unknown,
  idKey: keyof T | undefined,
): option is T => {
  return (
    typeof option === 'object' && option !== null && !!idKey && idKey in option
  )
}
const getValueOrId = (
  option: T,
  idKey: keyof T | undefined,
): string | number => {
  if (idKey && isObjectWithIdKey(option, idKey)) {
    const value = option[idKey]
    if (typeof value === 'string' || typeof value === 'number') {
      return value
    }
    throw new Error(
      `The value of idKey ${String(idKey)} is not a string or number.`,
    )
  }
  if (typeof option === 'string' || typeof option === 'number') {
    return option
  }
  throw new Error(
    'Option is not a valid string, number, or object with idKey.',
  )
}
const generateId = (
  option: T,
  id: string,
  idKey: keyof T | undefined,
): string => {
  return `${id}-${getValueOrId(option, idKey)}`
}
const host = ref<InstanceType<typeof DsfrButton> | null>(null)
const expanded = ref(false)
const model = defineModel<(string | number)[]>({ required: true })
const hostWidth = ref(0)
const message = computed(() => {
  return props.errorMessage || props.successMessage
})
const messageType = computed(() => {
  return props.errorMessage ? 'error' : 'valid'
})
const observations: (() => void)[] = []
const {
  collapse,
  collapsing,
  cssExpanded,
  doExpand,
  onTransitionEnd,
} = useCollapsable()
const getAllCheckbox = (): NodeListOf<HTMLElement> =>
  document.querySelectorAll(`[id^="${props.id}-"][id$="-checkbox"]`)
const isVisible = ref(false)
const searchInput = ref('')
function handleKeyDownEscape (event: KeyboardEvent) {
  if (event.key === 'Escape') {
    close()
  }
}
function handleClickOutside (event: MouseEvent) {
  const element = event.target as HTMLElement
  if (!host.value?.$el.contains(element) && !collapse.value?.contains(element)) {
    close()
  }
}
function observeElementSize (
  element: HTMLElement,
  callback: (element: HTMLElement, entry: ResizeObserverEntry) => void,
) {
  if (window.ResizeObserver) {
    const resizeObserver = new window.ResizeObserver((entries) => {
      for (const entry of entries) {
        callback(element, entry)
      }
    })
    resizeObserver.observe(element)
    return () => {
      resizeObserver.unobserve(element)
      resizeObserver.disconnect()
    }
  }
  return () => {}
}
function updateSize (element: HTMLElement) {
  const rect = element.getBoundingClientRect()
  if (rect.width !== hostWidth.value) {
    hostWidth.value = rect.width
  }
}
function open () {
  expanded.value = true
  isVisible.value = true
  if (host.value) {
    observations.push(observeElementSize(host.value.$el, updateSize))
  }
  document.addEventListener('click', handleClickOutside)
  document.addEventListener('keydown', handleKeyDownEscape)
  setTimeout(() => {
    doExpand(true)
  }, 100)
}
function close () {
  expanded.value = false
  doExpand(false)
  setTimeout(() => {
    isVisible.value = false
  }, 300)
  clean()
}
const handleClick = async () => {
  if (isVisible.value) {
    close()
  } else {
    open()
  }
}
function clean () {
  while (observations.length) {
    const observation = observations.pop()
    if (observation) {
      observation()
    }
  }
  document.removeEventListener('click', handleClickOutside)
  document.removeEventListener('keydown', handleKeyDownEscape)
}
const filteredOptions = computed(() =>
  props.options.filter((option) => {
    if (typeof option === 'object' && option !== null) {
      return props.filteringKeys.some((key) =>
        `${option[key]}`
          .toLowerCase()
          .includes(searchInput.value.toLowerCase()),
      )
    }
    return `${option}`.toLowerCase().includes(searchInput.value.toLowerCase())
  }),
)
const isAllSelected = computed(() => {
  if (props.modelValue.length < filteredOptions.value.length) {
    return false
  }
  return filteredOptions.value.every((option) => {
    const value = getValueOrId(option, props.idKey)
    return props.modelValue.includes(value)
  })
})
const handleClickSelectAllClick = () => {
  const modelSet = new Set<string | number>(model.value || [])
  if (isAllSelected.value) {
    filteredOptions.value.forEach((option) => {
      const value = getValueOrId(option, props.idKey)
      modelSet.delete(value)
    })
  } else {
    filteredOptions.value.forEach((option) => {
      const value = getValueOrId(option, props.idKey)
      modelSet.add(value)
    })
  }
  model.value = Array.from(modelSet)
}
const handleFocusFirstCheckbox = (event: KeyboardEvent) => {
  const [firstCheckbox] = getAllCheckbox()
  if (firstCheckbox) {
    event.preventDefault()
    firstCheckbox.focus()
  }
}
const handleFocusNextCheckbox = (event: KeyboardEvent) => {
  event.preventDefault()
  const checkboxes = getAllCheckbox()
  const activeElement = document.activeElement as HTMLElement
  const currentIndex = Array.from(checkboxes).indexOf(activeElement)
  if (currentIndex !== -1) {
    const nextIndex = (currentIndex + 1) % checkboxes.length
    checkboxes[nextIndex].focus()
  }
}
const handleFocusPreviousCheckbox = (event: KeyboardEvent) => {
  event.preventDefault()
  const checkboxes = getAllCheckbox()
  const activeElement = document.activeElement as HTMLElement
  const currentIndex = Array.from(checkboxes).indexOf(activeElement)
  if (currentIndex !== -1) {
    const previousIndex =
      (currentIndex - 1 + checkboxes.length) % checkboxes.length
    checkboxes[previousIndex].focus()
  }
}
const handleFocusNextElementUsingTab = (event: KeyboardEvent) => {
  const checkboxes = getAllCheckbox()
  const activeElement = document.activeElement as HTMLElement
  const currentIndex = Array.from(checkboxes).indexOf(activeElement)
  if (currentIndex + 1 === checkboxes.length && host.value && !event.shiftKey) {
    close()
  }
}
const handleFocusPreviousElement = (event: KeyboardEvent) => {
  const currentElement = document.activeElement as HTMLElement
  if (event.shiftKey && currentElement === host.value?.$el) {
    close()
  }
}
onUnmounted(() => {
  clean()
})
const defaultButtonLabel = computed(() => {
  const nbElements = model.value?.length ?? 0
  const noElements = nbElements === 0
  const severalElements = nbElements > 1
  if (noElements) {
    return 'Sélectionner une option'
  }
  return `${nbElements} option${severalElements ? 's' : ''} sélectionnée${severalElements ? 's' : ''}`
})
const finalLabelClass = computed(() => [
  'fr-label',
  { invisible: !props.labelVisible },
  props.labelClass,
])
</script>
<template>
  <div
    class="fr-select-group"
    :class="{ [`fr-select-group--${messageType}`]: message }"
  >
    <label
      :class="finalLabelClass"
      :for="id"
    >
      <slot name="label">
        {{ label }}
        <slot name="required-tip">
          <span
            v-if="'required' in $attrs && $attrs.required !== false"
            class="required"
          />
        </slot>
      </slot>
      <span
        v-if="props.hint || $slots.hint"
        class="fr-hint-text"
      >
        <slot name="hint">{{ props.hint }}</slot>
      </span>
    </label>
    <DsfrButton
      :id="props.id"
      ref="host"
      type="button"
      v-bind="$attrs"
      class="fr-select fr-multiselect"
      :aria-expanded="expanded"
      :aria-controls="`${props.id}-collapse`"
      :class="{
        'fr-multiselect--is-open': expanded,
        [`fr-select--${messageType}`]: message,
      }"
      @click="handleClick"
      @keydown.shift.tab="handleFocusPreviousElement"
    >
      <slot name="button-label">
        {{ props.buttonLabel || defaultButtonLabel }}
      </slot>
    </DsfrButton>
    <!-- collapse -->
    <div
      v-if="isVisible"
      :id="`${props.id}-collapse`"
      ref="collapse"
      :style="{
        '--width-host': `${hostWidth}px`,
      }"
      class="fr-multiselect__collapse fr-collapse"
      :class="{ 'fr-collapse--expanded': cssExpanded, 'fr-collapsing': collapsing }"
      @transitionend="onTransitionEnd(expanded)"
    >
      <p
        :id="`${id}-text-hint`"
        class="fr-sr-only"
      >
        Utilisez la tabulation (ou les touches flèches) pour naviguer dans
        la liste des suggestions
      </p>
      <ul
        v-if="selectAll"
        class="fr-btns-group"
      >
        <li>
          <DsfrButton
            type="button"
            name="select-all"
            secondary
            size="sm"
            :disabled="filteredOptions.length === 0"
            @click="handleClickSelectAllClick"
            @keydown.shift.tab="handleFocusPreviousElement"
          >
            <span
              class="fr-multiselect__search__icon"
              :class="
                isAllSelected
                  ? 'fr-icon-close-circle-line'
                  : 'fr-icon-check-line'"
            />
            {{ props.selectAllLabel[isAllSelected ? 1 : 0] }}
          </DsfrButton>
        </li>
      </ul>
      <div
        v-if="props.search"
        class="fr-input-group"
      >
        <div class="fr-input-wrap fr-icon-search-line">
          <DsfrInput
            v-model="searchInput"
            :aria-describedby="`${props.id}-text-hint`"
            :aria-controls="`${props.id}-checkboxes`"
            aria-live="polite"
            placeholder="Rechercher"
            type="text"
            @keydown.down="handleFocusFirstCheckbox"
            @keydown.right="handleFocusFirstCheckbox"
            @keydown.tab="handleFocusPreviousElement"
          />
        </div>
        <div
          class="fr-messages-group"
          aria-live="assertive"
        />
      </div>
      <DsfrFieldset
        :id="`${props.id}-checkboxes`"
        class="fr-multiselect__collapse__fieldset"
        aria-live="polite"
        :style="{ '--maxOverflowHeight': `${props.maxOverflowHeight}` }"
        :legend="props.legend"
        :legend-id="`${props.id}-checkboxes-legend`"
      >
        <slot name="legend" />
        <div
          v-for="option in filteredOptions"
          :key="`${generateId(option as T, id, props.idKey)}-checkbox`"
          class="fr-fieldset__element"
        >
          <div class="fr-checkbox-group fr-checkbox-group--sm">
            <DsfrCheckbox
              :id="`${generateId(option as T, id, props.idKey)}-checkbox`"
              v-model="model"
              :value="getValueOrId(option as T, props.idKey)"
              :name="`${generateId(option as T, id, props.idKey)}-checkbox`"
              small
              @keydown.down="handleFocusNextCheckbox"
              @keydown.right="handleFocusNextCheckbox"
              @keydown.up="handleFocusPreviousCheckbox"
              @keydown.left="handleFocusPreviousCheckbox"
              @keydown.tab="handleFocusNextElementUsingTab"
            >
              <template #label>
                <slot
                  name="checkbox-label"
                  :option="option as T"
                >
                  {{ getValueOrId(option as T, props.labelKey) }}
                </slot>
              </template>
            </DsfrCheckbox>
          </div>
        </div>
      </DsfrFieldset>
      <div v-if="filteredOptions.length === 0">
        <slot name="no-results">
          Pas de résultat
        </slot>
      </div>
      <!-- end collapse -->
    </div>
    <p
      v-if="message"
      :id="`select-${messageType}-desc-${messageType}`"
      :class="`fr-${messageType}-text`"
    >
      {{ message }}
    </p>
  </div>
</template>
<style scoped>
.fr-multiselect {
  text-align: left;
  background-image: none;
  display: inline-flex;
  flex-direction: row;
  padding: 0.75rem 1rem;
}
.fr-multiselect::after {
  --icon-size: 1rem;
  background-color: currentColor;
  content: "";
  display: inline-block;
  flex: 0 0 auto;
  height: 1rem;
  height: var(--icon-size);
  margin-left: auto;
  margin-right: 0;
  -webkit-mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0ibTEyIDEzLjE3MiA0Ljk1LTQuOTUgMS40MTQgMS40MTRMMTIgMTYgNS42MzYgOS42MzYgNy4wNSA4LjIyMmw0Ljk1IDQuOTVaIi8+PC9zdmc+);
  mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0ibTEyIDEzLjE3MiA0Ljk1LTQuOTUgMS40MTQgMS40MTRMMTIgMTYgNS42MzYgOS42MzYgNy4wNSA4LjIyMmw0Ljk1IDQuOTVaIi8+PC9zdmc+);
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  transition: transform 0.3s;
  vertical-align: calc(0.375em - 0.5rem);
  vertical-align: calc((0.75em - var(--icon-size)) * 0.5);
  width: 1rem;
  width: var(--icon-size);
  margin-top: auto;
  margin-bottom: auto;
}
.fr-multiselect--is-open::after {
  transform: rotate(-180deg);
}
.fr-multiselect__search__icon {
  margin-right: 1rem;
}
.fr-multiselect__collapse {
  z-index: 1;
  position: absolute;
  transform-origin: left top;
  width: var(--width-host);
  padding: 1rem;
  margin-top: 0.25rem;
  background-color: var(--background-overlap-grey);
  filter: drop-shadow(var(--overlap-shadow));
}
.fr-multiselect__collapse__fieldset {
  max-height: var(--maxOverflowHeight);
  overflow: auto;
}
.fr-multiselect__collapse__fieldset label {
  color: inherit;
}
</style>import type { VNode } from 'vue'
export type DsfrMultiSelectProps<T> = {
  modelValue: (string | number)[]
  options: T[]
  label: string
  labelVisible?: boolean
  labelClass?: string
  hint?: string
  legend?: string
  errorMessage?: string
  successMessage?: string
  buttonLabel?: string
  id?: string
  selectAll?: boolean
  search?: boolean
  selectAllLabel?: [string, string]
  idKey?: keyof {
    [K in keyof T as T[K] extends string | number ? K : never]: T[K];
  }
  labelKey?: keyof {
    [K in keyof T as T[K] extends string | number ? K : never]: T[K];
  }
  filteringKeys?: (keyof T)[]
  maxOverflowHeight?: CSSStyleDeclaration['maxHeight']
}
export type DsfrMultiSelectSlots<T> = {
  label?: () => VNode
  'required-tip'?: () => VNode
  hint?: () => VNode
  'button-label'?: () => VNode
  legend?: () => VNode
  'checkbox-label'?: (props: { option: T }) => VNode
  'no-results'?: () => VNode
}