Skip to content

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

🛠️ Props

nomtypedéfautobligatoireDescription
idstringrandom stringIdentifiant 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.
labelstring''Le libellé de l'input.
labelVisiblebooleantrueGére l'affichage du label ou non.
labelClassstring''Classe personnalisée pour le style du libellé.
legendstring''Texte de legend.
hintstring''Texte d'indice pour guider l'utilisateur.
successMessagestring''Message de validation à afficher en dessous du select.
errorMessagestring''Message d'erreur à afficher en dessous du select.
buttonLabelstringSélectionner une option, ...Texte qui s'affiche sur le bouton.
selectAllbooleantrueGérer l'affichage du bouton de 'sélectionner tout'.
searchbooleantrueGérer le label du 'sélectionner tout'.
selectAllLabelboolean["Tout sélectionner", "Tout désélectionner"]Gérer le label du 'sélectionner tout'.
idKeykeyof TidVoir ci dessous.
labelKeykeyof TlabelVoir ci dessous.
filteringKeys(keyof T)[]['label']Voir ci dessous.
maxOverflowHeightCSSStyleDeclaration['maxHeight']'400px'Taille maximum du dropdown.

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:

  • idKey est la clef d'un identifiant unique de chaque élément. C'est cette valeur qui sera utilisée dans modelValue
  • labelKey est la clef utilisée pour afficher le label des checkboxs
  • filteringKeys est 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 :

NomtypeDescription
update:modelValue(string | number)[]Est émis lorsque la valeur du select change.

🧩 Slots

DsfrMultiselect permet les slots suivants :

NompropsDescription
labelPermet de changer le label.
required-tipPermet de changer le required-tip.
hintPermet de changer le hint.
button-labelPermet de changer le label du bouton.
legendPermet de changer la legend du bouton.
checkbox-label(props: { option: T | string | number })Permet de changer le label des checkboxs.
no-resultsPermet de changer l'affichage lorsque la recherche donne aucun élément.

📝 Exemples

Exemple Basique

vue
<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

vue
<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

vue
<script lang="ts" setup generic="T extends Object | string | number">
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 type { DsfrMultiSelectProps, DsfrMultiSelectSlots } from './DsfrMultiselect.types'

import { getRandomId } from '@/utils/random-utils'

const props = withDefaults(
  defineProps<DsfrMultiSelectProps<T>>(),
  {
    label: '',
    labelVisible: true,
    labelClass: '',
    hint: '',
    legend: '',
    id: () => getRandomId('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 filterdOptions = 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 < filterdOptions.value.length) {
    return false
  }

  return filterdOptions.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) {
    filterdOptions.value.forEach((option) => {
      const value = getValueOrId(option, props.idKey)
      modelSet.delete(value)
    })
  } else {
    filterdOptions.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
  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"
          >*</span>
        </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="filterdOptions.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 filterdOptions"
          :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="filterdOptions.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>
ts
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
}