Skip to content

Commit

Permalink
Merge pull request #972 from dnum-mi/feat/add-DsfrMultiselect
Browse files Browse the repository at this point in the history
feat(DsfrMultiselect): ✨ create dsfrMultiselect with doc and tests
  • Loading branch information
laruiss authored Oct 31, 2024
2 parents 516dd56 + 72433db commit a755afe
Show file tree
Hide file tree
Showing 9 changed files with 1,017 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ const composants = [
text: 'DsfrModal',
link: '/composants/DsfrModal.md',
},
{
text: 'DsfrMultiselect',
link: '/composants/DsfrMultiselect.md',
},
{
text: 'DsfrNotice',
link: '/composants/DsfrNotice.md',
Expand Down
137 changes: 137 additions & 0 deletions src/components/DsfrMultiselect/DsfrMultiselect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# 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](https://www.systeme-de-design.gouv.fr/composants-et-modeles/composants-beta/liste-deroulante-riche)

## 🛠️ 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` | *`boolean`* | `["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 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

::: warning 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)](https://fr.vuejs.org/guide/components/attrs.html) de la documentation officielle de Vue.js.). Comme par exemple `readonly`.

Voici une liste non-exhaustive:

- `name`
- `readonly`
- `disabled`
- `autocomplete`
- `autofocus` ([déconseillé](https://brucelawson.co.uk/2009/the-accessibility-of-html-5-autofocus/))
- `size`
- `maxlength`
- `pattern`

:::

### DsfrMultiselect dans une iframe

::: warning 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`| *`(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

::: code-group

<Story data-title="Démo simple" min-h="550px">
<div
class="flex flex-col"
>
<DsfrMultiselectDemoSimple />
</div>
</Story>
<<< docs-demo/DsfrMultiselectDemoSimple.vue

:::

### Exemple Complexe

::: code-group

<Story data-title="Démo complexe" min-h="550px">
<div
class="flex flex-col"
>
<DsfrMultiselectDemoComplexe />
</div>
</Story>
<<< docs-demo/DsfrMultiselectDemoComplexe.vue

:::

## ⚙️ Code source du composant

::: code-group

<<< DsfrMultiselect.vue
<<< DsfrMultiselect.types.ts

:::

<script setup lang="ts">
import DsfrMultiselectDemoSimple from './docs-demo/DsfrMultiselectDemoSimple.vue'
import DsfrMultiselectDemoComplexe from './docs-demo/DsfrMultiselectDemoComplexe.vue'
</script>
209 changes: 209 additions & 0 deletions src/components/DsfrMultiselect/DsfrMultiselect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { fireEvent, render } from '@testing-library/vue'
import DsfrMultiselect from './DsfrMultiselect.vue'

describe('DsfrMultiselect', () => {
it('should render a multiselect', async () => {
// Given
const options = [
'Dupont',
'Martin',
'Durand',
'Petit',
'Lefevre',
]
const values = []

// When
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
props: {
modelValue: values,
options,
},
})

const button = getByRole('button')
expect(button.textContent).toBe(' Sélectionner une option')
await fireEvent.click(button)

const checkboxes = getAllByRole('checkbox')
expect(checkboxes).toHaveLength(5)
})

it('should render a multiselect with selected options', async () => {
// Given
const options = [
'Dupont',
'Martin',
'Durand',
'Petit',
'Lefevre',
]
const values = ['Dupont', 'Martin']

// When
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
props: {
modelValue: values,
options,
},
})

const button = getByRole('button')
expect(button.textContent).toBe(' 2 options sélectionnées')
await fireEvent.click(button)

const checkboxes = getAllByRole('checkbox')
await fireEvent.click(checkboxes[1])

expect(button.textContent).toBe(' 1 option sélectionnée')
})

it('should test search', async () => {
// Given
const options = [
'Dupont',
'Martin',
'Durand',
'Petit',
'Lefevre',
]
const values = ['Dupont', 'Martin']

// When
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
props: {
modelValue: values,
options,
search: true,
},
})

const button = getByRole('button')
await fireEvent.click(button)

const checkboxes = getAllByRole('checkbox')
expect(checkboxes).toHaveLength(5)

const search = getByRole('textbox')
await fireEvent.update(search, 'petit')

const checkboxesAfterSearch = getAllByRole('checkbox')
expect(checkboxesAfterSearch).toHaveLength(1)
})

it('should use search to filter', async () => {
// Given
const options = [
'Dupont',
'Martin',
'Durand',
'Petit',
'Lefevre',
]
const values = ['Dupont', 'Martin']

// When
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
props: {
modelValue: values,
options,
search: true,
},
})

const button = getByRole('button')
await fireEvent.click(button)

const checkboxes = getAllByRole('checkbox')
expect(checkboxes).toHaveLength(5)

const search = getByRole('textbox')
await fireEvent.update(search, 'petit')

const checkboxesAfterSearch = getAllByRole('checkbox')
expect(checkboxesAfterSearch).toHaveLength(1)
})

it('should use selectAll', async () => {
// Given
const options = [
'Dupont',
'Martin',
'Durand',
'Petit',
'Lefevre',
]
const values = ['Dupont', 'Martin']

// When
const { getByRole } = render(DsfrMultiselect, {
props: {
modelValue: values,
options,
selectAll: true,
},
})

const button = getByRole('button')
await fireEvent.click(button)

expect(button.textContent).toBe(' 2 options sélectionnées')

const buttonSelectAll = getByRole('button', { name: 'Tout sélectionner' })
await fireEvent.click(buttonSelectAll)

expect(button.textContent).toBe(' 5 options sélectionnées')
})

it('should render with object as option', async () => {
// Given
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 = ['Dupont', 'Martin']

// When
const { getByRole, getByText } = render(DsfrMultiselect, {
props: {
modelValue: values,
options,
idKey: 'nom',
labelKey: 'prenom',
},
})

const button = getByRole('button')
await fireEvent.click(button)

expect(button.textContent).toBe(' 2 options sélectionnées')

const input = getByText(/Paul/)

expect(input).toBeTruthy()
})
})
36 changes: 36 additions & 0 deletions src/components/DsfrMultiselect/DsfrMultiselect.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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
}
Loading

0 comments on commit a755afe

Please sign in to comment.