Skip to content

Commit 72433db

Browse files
iNeoOlaruiss
authored andcommitted
feat(DsfrMultiselect): ✨ create dsfrMultiselect with doc and tests
1 parent 516dd56 commit 72433db

File tree

9 files changed

+1017
-0
lines changed

9 files changed

+1017
-0
lines changed

.vitepress/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,10 @@ const composants = [
231231
text: 'DsfrModal',
232232
link: '/composants/DsfrModal.md',
233233
},
234+
{
235+
text: 'DsfrMultiselect',
236+
link: '/composants/DsfrMultiselect.md',
237+
},
234238
{
235239
text: 'DsfrNotice',
236240
link: '/composants/DsfrNotice.md',
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Liste déroulante enrichie - DsfrMultiselect
2+
3+
## 🌟 Introduction
4+
5+
Le `DsfrMultiselect` est un composant Vue permettant à un utilisateur de choisir un ou plusieurs élément dans une liste donnée.
6+
7+
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
8+
9+
🏅 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)
10+
11+
## 🛠️ Props
12+
13+
| nom | type | défaut | obligatoire | Description |
14+
|--------------------|---------------------------------------|-----------------------------------------------|-------------|-------------------------------------------------------------------------------|
15+
| `id` | *`string`* | *random string* | | Identifiant unique pour l'input. Si non spécifié, un ID aléatoire est généré. |
16+
| `modelValue` | *`(string \| number)[]`* | `` || La valeur liée au modèle de l'input. |
17+
| `options` | *`(T \| string \| number)[]`* | `''` || Options sélectionnables. |
18+
| `label` | *`string`* | `''` | | Le libellé de l'input. |
19+
| `labelVisible` | *`boolean`* | `true` | | Gére l'affichage du label ou non. |
20+
| `labelClass` | *`string`* | `''` | | Classe personnalisée pour le style du libellé. |
21+
| `legend` | *`string`* | `''` | | Texte de legend. |
22+
| `hint` | *`string`* | `''` | | Texte d'indice pour guider l'utilisateur. |
23+
| `successMessage` | *`string`* | `''` | | Message de validation à afficher en dessous du select. |
24+
| `errorMessage` | *`string`* | `''` | | Message d'erreur à afficher en dessous du select. |
25+
| `buttonLabel` | *`string`* | `Sélectionner une option, ...` | | Texte qui s'affiche sur le bouton. |
26+
| `selectAll` | *`boolean`* | `true` | | Gérer l'affichage du bouton de 'sélectionner tout'. |
27+
| `search` | *`boolean`* | `true` | | Gérer le label du 'sélectionner tout'. |
28+
| `selectAllLabel` | *`boolean`* | `["Tout sélectionner", "Tout désélectionner"]`| | Gérer le label du 'sélectionner tout'. |
29+
| `idKey` | *`keyof T`* | `id` | | Voir ci dessous. |
30+
| `labelKey` | *`keyof T`* | `label` | | Voir ci dessous. |
31+
| `filteringKeys` | *`(keyof T)[]`* | `['label']` | | Voir ci dessous. |
32+
| `maxOverflowHeight`| *`CSSStyleDeclaration['maxHeight']`* | `'400px'` | | Taille maximum du dropdown. |
33+
34+
### Cas d'utilisation d'objets dans des options
35+
36+
Pour l'utilisation d'objets comme props, il peut être nécessaire de renseigner `idKey`, `labelKey` et `filteringKeys`:
37+
38+
- `idKey` est la clef d'un identifiant unique de chaque élément. C'est cette valeur qui sera utilisée dans `modelValue`
39+
- `labelKey` est la clef utilisée pour afficher le label des checkboxs
40+
- `filteringKeys` est une array de clefs qui sont utilisé pour filtrer dans le search
41+
42+
### Attributs implicitement déclarés
43+
44+
::: warning Important
45+
46+
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`.
47+
48+
Voici une liste non-exhaustive:
49+
50+
- `name`
51+
- `readonly`
52+
- `disabled`
53+
- `autocomplete`
54+
- `autofocus` ([déconseillé](https://brucelawson.co.uk/2009/the-accessibility-of-html-5-autofocus/))
55+
- `size`
56+
- `maxlength`
57+
- `pattern`
58+
59+
:::
60+
61+
### DsfrMultiselect dans une iframe
62+
63+
::: warning Important
64+
65+
Si DsfrMultiselect est placé dans une iframe, il n'aura pas accès aux clics exterieurs pour se fermer.
66+
67+
:::
68+
69+
## 📡 Évenements
70+
71+
`DsfrMultiselect` émet l'événement suivant :
72+
73+
| Nom | type | Description |
74+
|--------------------|--------------------------|----------------------------------------------|
75+
| `update:modelValue`| *`(string \| number)[]`* | Est émis lorsque la valeur du select change. |
76+
77+
## 🧩 Slots
78+
79+
`DsfrMultiselect` permet les slots suivants :
80+
81+
| Nom | props | Description |
82+
|--------------------|------------------------------------------------|-------------------------------------------------------------------------|
83+
| `label` | | Permet de changer le label. |
84+
| `required-tip` | | Permet de changer le required-tip. |
85+
| `hint` | | Permet de changer le hint. |
86+
| `button-label` | | Permet de changer le label du bouton. |
87+
| `legend` | | Permet de changer la legend du bouton. |
88+
| `checkbox-label` | *`(props: { option: T \| string \| number })`* | Permet de changer le label des checkboxs. |
89+
| `no-results` | | Permet de changer l'affichage lorsque la recherche donne aucun élément. |
90+
91+
## 📝 Exemples
92+
93+
### Exemple Basique
94+
95+
::: code-group
96+
97+
<Story data-title="Démo simple" min-h="550px">
98+
<div
99+
class="flex flex-col"
100+
>
101+
<DsfrMultiselectDemoSimple />
102+
</div>
103+
</Story>
104+
105+
<<< docs-demo/DsfrMultiselectDemoSimple.vue
106+
107+
:::
108+
109+
### Exemple Complexe
110+
111+
::: code-group
112+
113+
<Story data-title="Démo complexe" min-h="550px">
114+
<div
115+
class="flex flex-col"
116+
>
117+
<DsfrMultiselectDemoComplexe />
118+
</div>
119+
</Story>
120+
121+
<<< docs-demo/DsfrMultiselectDemoComplexe.vue
122+
123+
:::
124+
125+
## ⚙️ Code source du composant
126+
127+
::: code-group
128+
129+
<<< DsfrMultiselect.vue
130+
<<< DsfrMultiselect.types.ts
131+
132+
:::
133+
134+
<script setup lang="ts">
135+
import DsfrMultiselectDemoSimple from './docs-demo/DsfrMultiselectDemoSimple.vue'
136+
import DsfrMultiselectDemoComplexe from './docs-demo/DsfrMultiselectDemoComplexe.vue'
137+
</script>
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { fireEvent, render } from '@testing-library/vue'
2+
import DsfrMultiselect from './DsfrMultiselect.vue'
3+
4+
describe('DsfrMultiselect', () => {
5+
it('should render a multiselect', async () => {
6+
// Given
7+
const options = [
8+
'Dupont',
9+
'Martin',
10+
'Durand',
11+
'Petit',
12+
'Lefevre',
13+
]
14+
const values = []
15+
16+
// When
17+
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
18+
props: {
19+
modelValue: values,
20+
options,
21+
},
22+
})
23+
24+
const button = getByRole('button')
25+
expect(button.textContent).toBe(' Sélectionner une option')
26+
await fireEvent.click(button)
27+
28+
const checkboxes = getAllByRole('checkbox')
29+
expect(checkboxes).toHaveLength(5)
30+
})
31+
32+
it('should render a multiselect with selected options', async () => {
33+
// Given
34+
const options = [
35+
'Dupont',
36+
'Martin',
37+
'Durand',
38+
'Petit',
39+
'Lefevre',
40+
]
41+
const values = ['Dupont', 'Martin']
42+
43+
// When
44+
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
45+
props: {
46+
modelValue: values,
47+
options,
48+
},
49+
})
50+
51+
const button = getByRole('button')
52+
expect(button.textContent).toBe(' 2 options sélectionnées')
53+
await fireEvent.click(button)
54+
55+
const checkboxes = getAllByRole('checkbox')
56+
await fireEvent.click(checkboxes[1])
57+
58+
expect(button.textContent).toBe(' 1 option sélectionnée')
59+
})
60+
61+
it('should test search', async () => {
62+
// Given
63+
const options = [
64+
'Dupont',
65+
'Martin',
66+
'Durand',
67+
'Petit',
68+
'Lefevre',
69+
]
70+
const values = ['Dupont', 'Martin']
71+
72+
// When
73+
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
74+
props: {
75+
modelValue: values,
76+
options,
77+
search: true,
78+
},
79+
})
80+
81+
const button = getByRole('button')
82+
await fireEvent.click(button)
83+
84+
const checkboxes = getAllByRole('checkbox')
85+
expect(checkboxes).toHaveLength(5)
86+
87+
const search = getByRole('textbox')
88+
await fireEvent.update(search, 'petit')
89+
90+
const checkboxesAfterSearch = getAllByRole('checkbox')
91+
expect(checkboxesAfterSearch).toHaveLength(1)
92+
})
93+
94+
it('should use search to filter', async () => {
95+
// Given
96+
const options = [
97+
'Dupont',
98+
'Martin',
99+
'Durand',
100+
'Petit',
101+
'Lefevre',
102+
]
103+
const values = ['Dupont', 'Martin']
104+
105+
// When
106+
const { getByRole, getAllByRole } = render(DsfrMultiselect, {
107+
props: {
108+
modelValue: values,
109+
options,
110+
search: true,
111+
},
112+
})
113+
114+
const button = getByRole('button')
115+
await fireEvent.click(button)
116+
117+
const checkboxes = getAllByRole('checkbox')
118+
expect(checkboxes).toHaveLength(5)
119+
120+
const search = getByRole('textbox')
121+
await fireEvent.update(search, 'petit')
122+
123+
const checkboxesAfterSearch = getAllByRole('checkbox')
124+
expect(checkboxesAfterSearch).toHaveLength(1)
125+
})
126+
127+
it('should use selectAll', async () => {
128+
// Given
129+
const options = [
130+
'Dupont',
131+
'Martin',
132+
'Durand',
133+
'Petit',
134+
'Lefevre',
135+
]
136+
const values = ['Dupont', 'Martin']
137+
138+
// When
139+
const { getByRole } = render(DsfrMultiselect, {
140+
props: {
141+
modelValue: values,
142+
options,
143+
selectAll: true,
144+
},
145+
})
146+
147+
const button = getByRole('button')
148+
await fireEvent.click(button)
149+
150+
expect(button.textContent).toBe(' 2 options sélectionnées')
151+
152+
const buttonSelectAll = getByRole('button', { name: 'Tout sélectionner' })
153+
await fireEvent.click(buttonSelectAll)
154+
155+
expect(button.textContent).toBe(' 5 options sélectionnées')
156+
})
157+
158+
it('should render with object as option', async () => {
159+
// Given
160+
const options = [
161+
{
162+
nom: 'Dupont',
163+
prenom: 'Marie',
164+
age: 28,
165+
},
166+
{
167+
nom: 'Martin',
168+
prenom: 'Paul',
169+
age: 34,
170+
},
171+
{
172+
nom: 'Durand',
173+
prenom: 'Lucie',
174+
age: 22,
175+
},
176+
{
177+
nom: 'Petit',
178+
prenom: 'Julien',
179+
age: 45,
180+
},
181+
{
182+
nom: 'Lefevre',
183+
prenom: 'Elise',
184+
age: 30,
185+
},
186+
]
187+
188+
const values = ['Dupont', 'Martin']
189+
190+
// When
191+
const { getByRole, getByText } = render(DsfrMultiselect, {
192+
props: {
193+
modelValue: values,
194+
options,
195+
idKey: 'nom',
196+
labelKey: 'prenom',
197+
},
198+
})
199+
200+
const button = getByRole('button')
201+
await fireEvent.click(button)
202+
203+
expect(button.textContent).toBe(' 2 options sélectionnées')
204+
205+
const input = getByText(/Paul/)
206+
207+
expect(input).toBeTruthy()
208+
})
209+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { VNode } from 'vue'
2+
3+
export type DsfrMultiSelectProps<T> = {
4+
modelValue: (string | number)[]
5+
options: T[]
6+
label?: string
7+
labelVisible?: boolean
8+
labelClass?: string
9+
hint?: string
10+
legend?: string
11+
errorMessage?: string
12+
successMessage?: string
13+
buttonLabel?: string
14+
id?: string
15+
selectAll?: boolean
16+
search?: boolean
17+
selectAllLabel?: [string, string]
18+
idKey?: keyof {
19+
[K in keyof T as T[K] extends string | number ? K : never]: T[K];
20+
}
21+
labelKey?: keyof {
22+
[K in keyof T as T[K] extends string | number ? K : never]: T[K];
23+
}
24+
filteringKeys?: (keyof T)[]
25+
maxOverflowHeight?: CSSStyleDeclaration['maxHeight']
26+
}
27+
28+
export type DsfrMultiSelectSlots<T> = {
29+
label: () => VNode
30+
'required-tip': () => VNode
31+
hint: () => VNode
32+
'button-label': () => VNode
33+
legend: () => VNode
34+
'checkbox-label': (props: { option: T }) => VNode
35+
'no-results': () => VNode
36+
}

0 commit comments

Comments
 (0)