-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #972 from dnum-mi/feat/add-DsfrMultiselect
feat(DsfrMultiselect): ✨ create dsfrMultiselect with doc and tests
- Loading branch information
Showing
9 changed files
with
1,017 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.