Compare commits

..

28 Commits

Author SHA1 Message Date
tristan 2df5aff1b7 feat : wip ajout du composant Datatable 2026-03-25 08:20:04 +01:00
tristan f09f8a91ac [#MUI-15] Création d'un composant drawer (#21)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #21
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 10:49:27 +00:00
tristan bcadd46ce2 [#MUI-2] Faire un MCP pour la librairie de composant (#20)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #20
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 10:31:20 +00:00
tristan e76337502a [#MUI-10] Création d'un composant bouton (#19)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #19
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 10:12:28 +00:00
tristan 968b7087b5 [#MUI-23] Revoir la config couleur tailwind (#18)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #18
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 09:05:23 +00:00
tristan 3deba3f369 [#MUI-20] Développer le composant Menu (#17)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #17
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-23 16:36:16 +00:00
tristan cf46ab0c85 [#MUI-11] Création d'un composant navigation par onglets (#16)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-23 07:48:55 +00:00
tristan 09cc3edf6f feat : reorganisation de la structure projet 2026-03-20 14:22:40 +01:00
tristan c95a3657c0 [#MUI-14] Création d'un composant bouton icône (#15)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #15
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-20 11:00:38 +00:00
tristan 9843f4d032 feat : ajout de state dans les histoires des composants 2026-03-19 17:45:03 +01:00
tristan 9d9b9c9dc4 feat : ajout d'un sélecteur "Tout cocher" dans le composant SelectCheckbox 2026-03-19 17:30:52 +01:00
tristan 187ef52865 [#MUI-9] Ajout composant upload (#14)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #14
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-19 09:51:37 +00:00
tristan 9925f1ced4 [#MUI-8] Ajout composant mot de passe (#13)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #13
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-19 09:43:55 +00:00
tristan ded414ba1a [#MUI12] Correction composant Nombre et Taux horaire (#12)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #12
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-19 08:22:37 +00:00
kevin 11d60e687b [#366] Création d'un composant de type Select checkbox (#11)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|           #366       |            Création d'un composant de type Select checkbox     |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #11
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-17 12:28:57 +00:00
kevin d3038994c3 [#407] Création d'un composant de type time (#10)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #407         |           Création d'un composant de type time      |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #10
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-17 12:28:33 +00:00
kevin 0d350e12c6 Merge pull request '[#365] Création d'un composant de type Number' (#9) from feat/365-creation-composant-number into develop
Reviewed-on: #9
2026-03-11 15:16:18 +00:00
tristan c6acaace27 Merge remote-tracking branch 'origin/develop' into develop 2026-03-08 20:10:32 +01:00
tristan 927c7c3c70 Merge remote-tracking branch 'origin/main' into develop 2026-03-08 20:10:02 +01:00
kevin bf0aa92497 [#363] Création d'un composant de type checkbox (#5)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #363          |        Création d'un composant de type checkbox       |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #5
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-08 19:07:53 +00:00
kevin 88dd76a0e4 [#364 ] Création d'un composant de type radio (#6)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|           #364        |       Création d'un composant de type radio          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #6
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-08 18:59:50 +00:00
kevin cc04114f89 feat : ajout du composant input number 2026-03-05 09:38:56 +01:00
kevin f456ea4ddf feat : ajout du composant input number 2026-03-04 13:15:43 +01:00
kevin 77364daa67 [#362] Création d'un composant de type Montant (#4)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|          #362        |       Création d'un composant de type Montant          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #4
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-03 10:42:39 +00:00
kevin 1ab7b2427a [#337] Création d'un composant Select (#3)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #337           |           Création d'un composant Select      |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #3
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-02 13:24:58 +00:00
tristan 82ecc9cfe2 feat : ajout config vitest/make/pre-commit/commit-msg + un exemple de test vitest 2026-02-23 11:29:16 +01:00
tristan 65d9060e26 feat : ajout du template de MR + CHANGELOG.md 2026-02-23 11:11:31 +01:00
tristan ec4c157226 fix: readme.md 2026-02-19 11:18:36 +01:00
22 changed files with 383 additions and 881 deletions
@@ -82,16 +82,6 @@
/> />
</div> </div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
<MalioSelect
v-model="shortListValue"
:options="shortOptions"
label="Civilite"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2"> <div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Liste longue</h2> <h2 class="mb-4 text-xl font-bold">Liste longue</h2>
<MalioSelect <MalioSelect
@@ -131,11 +121,6 @@ const options = [
{label: 'Portugal', value: 'pt'}, {label: 'Portugal', value: 'pt'},
] ]
const shortOptions = [
{label: 'Monsieur', value: 'M'},
{label: 'Madame', value: 'Mme'},
]
const longOptions = [ const longOptions = [
...options, ...options,
{label: 'Pays-Bas', value: 'nl'}, {label: 'Pays-Bas', value: 'nl'},
@@ -159,7 +144,6 @@ const errorValue = ref<string | number | null>(null)
const successValue = ref<string | number | null>('be') const successValue = ref<string | number | null>('be')
const disabledValue = ref<string | number | null>('ca') const disabledValue = ref<string | number | null>('ca')
const emptyValue = ref<string | number | null>(null) const emptyValue = ref<string | number | null>(null)
const shortListValue = ref<string | number | null>(null)
const longListValue = ref<string | number | null>(null) const longListValue = ref<string | number | null>(null)
const bottomValue = ref<string | number | null>(null) const bottomValue = ref<string | number | null>(null)
</script> </script>
@@ -1,67 +0,0 @@
<template>
<div class="grid grid-cols-1 items-start gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple (3 sites) + event change</h2>
<MalioSiteSelector v-model="simpleValue" :sites="sites" @change="onSiteChange" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ simpleValue }}</code></p>
<p class="mt-1 text-sm text-gray-600">Dernier event <code>change</code> : <code>{{ lastChange }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Deux sites</h2>
<MalioSiteSelector v-model="twoValue" :sites="sitesTwo" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ twoValue }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Cinq sites (largeur proportionnelle)</h2>
<MalioSiteSelector v-model="fiveValue" :sites="sitesFive" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ fiveValue }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé (sans v-model)</h2>
<MalioSiteSelector :sites="sites" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Largeur contrainte</h2>
<div class="w-[480px]">
<MalioSiteSelector v-model="constrainedValue" :sites="sites" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const sites = [
{ id: 'chatellerault', name: 'Châtellerault', color: '#0055ff' },
{ id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a' },
{ id: 'pommevic', name: 'Pommevic', color: '#dc2626' },
]
const sitesTwo = [
{ id: 'nord', name: 'Usine Nord', color: '#7c3aed' },
{ id: 'sud', name: 'Usine Sud', color: '#ea580c' },
]
const sitesFive = [
{ id: 's1', name: 'Site 1', color: '#0ea5e9' },
{ id: 's2', name: 'Site 2', color: '#14b8a6' },
{ id: 's3', name: 'Site 3', color: '#f59e0b' },
{ id: 's4', name: 'Site 4', color: '#ec4899' },
{ id: 's5', name: 'Site 5', color: '#6366f1' },
]
const simpleValue = ref('chatellerault')
const twoValue = ref('nord')
const fiveValue = ref('s3')
const constrainedValue = ref('saint-jean')
const lastChange = ref<string>('—')
function onSiteChange(site: { id: string; name: string; color: string }) {
lastChange.value = JSON.stringify(site)
}
</script>
-3
View File
@@ -25,10 +25,7 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-2] Faire un MCP pour la librairie de composant * [#MUI-2] Faire un MCP pour la librairie de composant
* [#MUI-15] Création d'un composant drawer * [#MUI-15] Création d'un composant drawer
* [#MUI-22] Création d'un composant datatable * [#MUI-22] Création d'un composant datatable
* [#MUI-27] Création d'un composant sélection de site
### Changed ### Changed
### Fixed ### Fixed
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
+1 -11
View File
@@ -163,17 +163,8 @@ Liste déroulante.
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options disponibles | | `options` | `{ value: string \| number, text: string }[]` | `[]` | Options disponibles |
| `emptyOptionLabel` | `string` | `''` | Placeholder option vide | | `emptyOptionLabel` | `string` | `''` | Placeholder option vide |
| `label` | `string` | `''` | Label | | `label` | `string` | `''` | Label |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) | | `error` | `string` | `''` | Message d'erreur |
| `minWidth` | `string` | `'w-96'` | Classe largeur minimum |
| `maxWidth` | `string` | `''` | Classe largeur maximum |
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
**Events :** `update:modelValue(value: string | number | null)` **Events :** `update:modelValue(value: string | number | null)`
**Slots :** `icon` (icône dropdown custom) **Slots :** `icon` (icône dropdown custom)
@@ -181,7 +172,6 @@ Liste déroulante.
```vue ```vue
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" /> <MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." /> <MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
<MalioSelect v-model="civilite" label="Civilité" :options="civilites" group-class="mt-0" />
``` ```
--- ---
+1 -1
View File
@@ -35,6 +35,6 @@
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */ --m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
--m-site-green: 116 191 4; /* #74BF04 - Vert Pommevic */ --m-site-green: 116 191 4; /* #74BF04 - Vert Pommevic */
--m-radius: 6px; --m-radius: 8px;
} }
} }
@@ -114,7 +114,7 @@ describe('MalioCheckbox', () => {
}) })
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true') expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('label').classes()).toContain('text-m-error') expect(wrapper.get('label').classes()).toContain('text-m-danger')
expect(wrapper.get('p').text()).toBe('You must accept') expect(wrapper.get('p').text()).toBe('You must accept')
}) })
@@ -125,7 +125,7 @@ describe('MalioCheckbox', () => {
}) })
expect(wrapper.get('p').text()).toBe('Invalid') expect(wrapper.get('p').text()).toBe('Invalid')
expect(wrapper.get('p').classes()).toContain('text-m-error') expect(wrapper.get('p').classes()).toContain('text-m-danger')
}) })
it('shows success styles and message when there is no error', () => { it('shows success styles and message when there is no error', () => {
+8 -8
View File
@@ -110,7 +110,7 @@ const mergedLabelClass = computed(() =>
twMerge( twMerge(
'cbx text-black', 'cbx text-black',
disabled.value ? 'cursor-not-allowed text-black/60' : '', disabled.value ? 'cursor-not-allowed text-black/60' : '',
hasError.value ? 'text-m-error' : '', hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '', hasSuccess.value ? 'text-m-success' : '',
props.labelClass, props.labelClass,
), ),
@@ -120,7 +120,7 @@ const mergedMessageClass = computed(() =>
twMerge( twMerge(
'text-xs', 'text-xs',
hasError.value hasError.value
? 'text-m-error' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
@@ -200,14 +200,14 @@ const onChange = (event: Event) => {
stroke-dashoffset: 0; stroke-dashoffset: 0;
} }
.inp-cbx + .cbx.text-m-error span:first-child { .inp-cbx + .cbx.text-m-danger span:first-child {
border-color: rgb(var(--m-error) / 1); border-color: rgb(var(--m-danger) / 1);
} }
.cbx.text-m-error span:first-child svg { .cbx.text-m-danger span:first-child svg {
stroke: rgb(var(--m-error) / 1); stroke: rgb(var(--m-danger) / 1);
} }
.inp-cbx:checked + .cbx.text-m-error span:first-child { .inp-cbx:checked + .cbx.text-m-danger span:first-child {
border-color: rgb(var(--m-error) / 1); border-color: rgb(var(--m-danger) / 1);
} }
.inp-cbx + .cbx.text-m-success span:first-child { .inp-cbx + .cbx.text-m-success span:first-child {
+3 -3
View File
@@ -80,7 +80,7 @@
variant="tertiary" variant="tertiary"
label="Prev" label="Prev"
:disabled="page <= 1" :disabled="page <= 1"
button-class="h-10 w-auto min-w-0 px-3 text-sm" button-class="h-8 w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente" aria-label="Page précédente"
data-test="prev-button" data-test="prev-button"
@click="goToPage(page - 1)" @click="goToPage(page - 1)"
@@ -95,7 +95,7 @@
<button <button
v-else v-else
type="button" type="button"
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors" class="h-8 min-w-[2rem] rounded px-2 text-sm transition-colors"
:class="p === page :class="p === page
? 'bg-m-btn-primary text-white font-semibold' ? 'bg-m-btn-primary text-white font-semibold'
: 'text-m-text hover:bg-m-bg'" : 'text-m-text hover:bg-m-bg'"
@@ -111,7 +111,7 @@
variant="tertiary" variant="tertiary"
label="Next" label="Next"
:disabled="page >= totalPages" :disabled="page >= totalPages"
button-class="h-10 w-auto min-w-0 px-3 text-sm" button-class="h-8 w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante" aria-label="Page suivante"
data-test="next-button" data-test="next-button"
@click="goToPage(page + 1)" @click="goToPage(page + 1)"
+28 -32
View File
@@ -1,25 +1,25 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition <div
name="drawer" v-if="isOpen"
appear :id="componentId"
@after-leave="isRendered = false" class="fixed inset-0 z-50"
v-bind="attrs"
> >
<div <Transition name="drawer-backdrop">
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex justify-end"
v-bind="attrs"
>
<div <div
v-if="isOpen"
class="absolute inset-0 bg-black/40" class="absolute inset-0 bg-black/40"
data-test="backdrop" data-test="backdrop"
@click="close" @click="close"
/> />
</Transition>
<Transition name="drawer-panel">
<div <div
v-if="isOpen"
:class="twMerge( :class="twMerge(
'relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl', 'absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl',
drawerClass, drawerClass,
)" )"
role="dialog" role="dialog"
@@ -51,18 +51,19 @@
</div> </div>
<div <div
class="flex-1 overflow-y-auto px-5" class="overflow-y-auto px-5"
style="max-height: calc(100% - 96px)"
> >
<slot /> <slot />
</div> </div>
</div> </div>
</div> </Transition>
</Transition> </div>
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, useAttrs, useId, watch } from 'vue' import { computed, ref, useAttrs, useId } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
@@ -102,12 +103,6 @@ const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value, isControlled.value ? props.modelValue! : localValue.value,
) )
const isRendered = ref(isOpen.value)
watch(isOpen, (val) => {
if (val) isRendered.value = true
})
function close() { function close() {
if (!isControlled.value) { if (!isControlled.value) {
localValue.value = false localValue.value = false
@@ -117,23 +112,24 @@ function close() {
</script> </script>
<style scoped> <style scoped>
.drawer-enter-active, .drawer-backdrop-enter-active,
.drawer-leave-active { .drawer-backdrop-leave-active {
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.drawer-enter-active > div:last-child, .drawer-backdrop-enter-from,
.drawer-leave-active > div:last-child { .drawer-backdrop-leave-to {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0; opacity: 0;
} }
.drawer-enter-from > div:last-child, .drawer-panel-enter-active,
.drawer-leave-to > div:last-child { .drawer-panel-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.drawer-panel-enter-from,
.drawer-panel-leave-to {
transform: translateX(100%); transform: translateX(100%);
opacity: 0;
} }
</style> </style>
+57 -59
View File
@@ -1,69 +1,67 @@
<template> <template>
<div> <div
<div :class="mergedGroupClass"
:class="mergedGroupClass" >
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="decimal"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="onBlur"
> >
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="decimal"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="onBlur"
>
<label <label
v-if="label" v-if="label"
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}
</label> </label>
<IconifyIcon <IconifyIcon
v-if="iconName" v-if="iconName"
:icon="iconName" :icon="iconName"
:width="iconSize" :width="iconSize"
:height="iconSize" :height="iconSize"
data-test="icon" data-test="icon"
:class="[ :class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' : iconColor, ? 'text-m-success'
iconPositionClass, : 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]" ]"
/> >
{{ hint || error || success }}
</div> </p>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -135,7 +133,7 @@ const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge( twMerge(
'relative flex h-12 w-full items-center', 'relative mt-4 flex h-12 w-full items-center',
props.groupClass, props.groupClass,
), ),
) )
+63 -65
View File
@@ -1,70 +1,68 @@
<template> <template>
<div> <div :class="mergedGroupClass" >
<div :class="mergedGroupClass" > <label
<label v-if="label"
v-if="label" :for="inputId"
:for="inputId" :class="mergedLabelClass"
:class="mergedLabelClass"
>
{{ label }}
</label>
<button
type="button"
:disabled="isMinusDisabled"
@click="decrement"
>
<IconifyIcon
icon="mdi:minus"
:class="mergedButtonMinusClass"
/>
</button>
<input
:id="inputId"
:name="name"
autocomplete="off"
:class="mergedInputClass"
:style="inputWidthStyle"
:value="displayedValue"
:required="required"
:disabled="disabled"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="numeric"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<button
type="button"
:disabled="isPlusDisabled"
@click="increment"
>
<IconifyIcon
icon="mdi:plus"
:class="mergedButtonPlusClass"
/>
</button>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px] ',
]"
> >
{{ hint || error || success }} {{ label }}
</p> </label>
<button
type="button"
:disabled="isMinusDisabled"
@click="decrement"
>
<IconifyIcon
icon="mdi:minus"
:class="mergedButtonMinusClass"
/>
</button>
<input
:id="inputId"
:name="name"
autocomplete="off"
:class="mergedInputClass"
:style="inputWidthStyle"
:value="displayedValue"
:required="required"
:disabled="disabled"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="numeric"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<button
type="button"
:disabled="isPlusDisabled"
@click="increment"
>
<IconifyIcon
icon="mdi:plus"
:class="mergedButtonPlusClass"
/>
</button>
</div> </div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -172,7 +170,7 @@ const isPlusDisabled = computed(() =>
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge( twMerge(
'relative flex h-12 w-full items-center', 'relative mt-4 flex h-12 w-full items-center',
props.groupClass, props.groupClass,
), ),
) )
+57 -59
View File
@@ -1,69 +1,67 @@
<template> <template>
<div> <div
<div :class="mergedGroupClass"
:class="mergedGroupClass" >
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
:type="isPasswordVisible ? 'text' : 'password'"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
> >
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
:type="isPasswordVisible ? 'text' : 'password'"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label <label
v-if="label" v-if="label"
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}
</label> </label>
<IconifyIcon <IconifyIcon
v-if="displayIcon" v-if="displayIcon"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'" :icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24" :width="24"
:height="24" :height="24"
data-test="icon" data-test="icon"
:class="[ :class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : 'text-m-muted',
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
]"
@click="toggleVisibility"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' : 'text-m-muted', ? 'text-m-success'
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]" ]"
@click="toggleVisibility" >
/> {{ hint || error || success }}
</p>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -134,7 +132,7 @@ const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0) const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge( twMerge(
'relative flex h-12 w-full items-center', 'relative mt-4 flex h-12 w-full items-center',
props.groupClass, props.groupClass,
), ),
) )
+57 -59
View File
@@ -1,69 +1,67 @@
<template> <template>
<div> <div
<div :class="mergedGroupClass"
:class="mergedGroupClass" >
<input
:id="inputId"
v-maska="mask"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
type="text"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
> >
<input
:id="inputId"
v-maska="mask"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
type="text"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label <label
v-if="label" v-if="label"
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}
</label> </label>
<IconifyIcon <IconifyIcon
v-if="iconName" v-if="iconName"
:icon="iconName" :icon="iconName"
:width="iconSize" :width="iconSize"
:height="iconSize" :height="iconSize"
data-test="icon" data-test="icon"
:class="[ :class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' : iconColor, ? 'text-m-success'
iconPositionClass, : 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]" ]"
/> >
{{ hint || error || success }}
</div> </p>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -140,7 +138,7 @@ const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0) const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge( twMerge(
'relative flex h-12 w-full items-center', 'relative mt-4 flex h-12 w-full items-center',
props.groupClass, props.groupClass,
), ),
) )
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="relative w-full" class="relative mt-4 w-full"
> >
<textarea <textarea
:id="inputId" :id="inputId"
+59 -61
View File
@@ -1,72 +1,70 @@
<template> <template>
<div> <div
<div :class="mergedGroupClass"
:class="mergedGroupClass" >
<input
ref="fileInputRef"
type="file"
:accept="accept"
class="hidden"
:disabled="disabled"
@change="onFileChange"
> >
<input
ref="fileInputRef"
type="file"
:accept="accept"
class="hidden"
:disabled="disabled"
@change="onFileChange"
>
<input <input
:id="inputId" :id="inputId"
:class="mergedInputClass" :class="mergedInputClass"
:disabled="disabled" :disabled="disabled"
:value="currentDisplayValue" :value="currentDisplayValue"
:readonly="true" :readonly="true"
:aria-invalid="!!error" :aria-invalid="!!error"
:aria-describedby="describedBy" :aria-describedby="describedBy"
v-bind="attrs" v-bind="attrs"
placeholder="_" placeholder="_"
type="text" type="text"
@click="openFilePicker" @click="openFilePicker"
@focus="isFocused = true" @focus="isFocused = true"
@blur="isFocused = false" @blur="isFocused = false"
> >
<label <label
v-if="label" v-if="label"
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}
</label> </label>
<IconifyIcon <IconifyIcon
v-if="displayIcon" v-if="displayIcon"
icon="mdi:cloud-arrow-up-outline" icon="mdi:cloud-arrow-up-outline"
:width="24" :width="24"
:height="24" :height="24"
data-test="icon" data-test="icon"
:class="[ :class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : 'text-m-muted',
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' : 'text-m-muted', ? 'text-m-success'
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]" ]"
/> >
{{ hint || error || success }}
</div> </p>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -123,7 +121,7 @@ const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0) const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge( twMerge(
'relative flex h-12 w-full items-center', 'relative mt-4 flex h-12 w-full items-center',
props.groupClass, props.groupClass,
), ),
) )
+20 -26
View File
@@ -1,9 +1,9 @@
<template> <template>
<div> <div
<div ref="root"
ref="root" class="relative mt-4 w-full"
:class="mergedGroupClass" :class="[minWidth, maxWidth]"
> >
<button <button
:id="buttonId" :id="buttonId"
type="button" type="button"
@@ -134,28 +134,26 @@
{{ opt.label || '\u00A0' }} {{ opt.label || '\u00A0' }}
</li> </li>
</ul> </ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</div> </div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue' import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioSelect', inheritAttrs: false}) defineOptions({name: 'MalioSelect', inheritAttrs: false})
@@ -178,7 +176,6 @@ const props = withDefaults(defineProps<{
textLabel?: string textLabel?: string
rounded?: string rounded?: string
disabled?: boolean disabled?: boolean
groupClass?: string
}>(), { }>(), {
options: () => [], options: () => [],
emptyOptionLabel: '', emptyOptionLabel: '',
@@ -193,7 +190,6 @@ const props = withDefaults(defineProps<{
textLabel: 'text-sm', textLabel: 'text-sm',
rounded: 'rounded-md', rounded: 'rounded-md',
disabled: false, disabled: false,
groupClass: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -212,9 +208,6 @@ const normalizedOptions = computed<Option[]>(() => [
{label: props.emptyOptionLabel, value: null}, {label: props.emptyOptionLabel, value: null},
...props.options, ...props.options,
]) ])
const mergedGroupClass = computed(() =>
twMerge('relative w-full', props.minWidth, props.maxWidth, props.groupClass),
)
const hasError = computed(() => !!props.error) const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value) const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() => const isOptionSelected = computed(() =>
@@ -322,6 +315,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
:deep(ul[role="listbox"]) { :deep(ul[role="listbox"]) {
scrollbar-width: auto; scrollbar-width: auto;
scrollbar-gutter: stable;
} }
:deep(.select-scrollbar-primary) { :deep(.select-scrollbar-primary) {
+19 -21
View File
@@ -1,10 +1,9 @@
<template> <template>
<div> <div
<div ref="root"
ref="root" class="relative mt-4 w-full"
class="relative w-full" :class="[minWidth, maxWidth]"
:class="[minWidth, maxWidth]" >
>
<button <button
:id="buttonId" :id="buttonId"
type="button" type="button"
@@ -185,22 +184,21 @@
/> />
</li> </li>
</ul> </ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</div> </div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -1,154 +0,0 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import SiteSelector from './SiteSelector.vue'
type Site = {
id: string
name: string
color: string
}
type SiteSelectorProps = {
sites: Site[]
modelValue?: string
id?: string
groupClass?: string
tileClass?: string
labelClass?: string
}
const SiteSelectorForTest = SiteSelector as DefineComponent<SiteSelectorProps>
const sites: Site[] = [
{id: 'chatellerault', name: 'Châtellerault', color: '#2563eb'},
{id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a'},
{id: 'pommevic', name: 'Pommevic', color: '#dc2626'},
]
function mountComponent(props: SiteSelectorProps) {
return mount(SiteSelectorForTest, {props})
}
describe('MalioSiteSelector', () => {
it('renders one tile per site with the site name', () => {
const wrapper = mountComponent({sites})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles).toHaveLength(3)
expect(tiles[0]!.text()).toBe('Châtellerault')
expect(tiles[1]!.text()).toBe('Saint-Jean')
expect(tiles[2]!.text()).toBe('Pommevic')
})
it('has role="radiogroup" on the wrapper', () => {
const wrapper = mountComponent({sites})
expect(wrapper.find('[role="radiogroup"]').exists()).toBe(true)
})
it('selects the first site by default in uncontrolled mode', () => {
const wrapper = mountComponent({sites})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('aria-checked')).toBe('true')
expect(tiles[1]!.attributes('aria-checked')).toBe('false')
expect(tiles[2]!.attributes('aria-checked')).toBe('false')
})
it('paints all tiles with the selected site color', () => {
const wrapper = mountComponent({sites, modelValue: 'saint-jean'})
const tiles = wrapper.findAll('[role="radio"]')
for (const tile of tiles) {
expect(tile.attributes('style')).toContain('background-color: rgb(22, 163, 74)')
}
})
it('applies opacity 1 on the selected tile and 0.4 on the others', () => {
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('style')).toContain('opacity: 1')
expect(tiles[1]!.attributes('style')).toContain('opacity: 0.4')
expect(tiles[2]!.attributes('style')).toContain('opacity: 0.4')
})
it('updates the shared color when the selection changes', async () => {
const wrapper = mountComponent({sites})
let tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('style')).toContain('background-color: rgb(37, 99, 235)')
await tiles[2]!.trigger('click')
tiles = wrapper.findAll('[role="radio"]')
for (const tile of tiles) {
expect(tile.attributes('style')).toContain('background-color: rgb(220, 38, 38)')
}
})
it('emits update:modelValue with the clicked site id', async () => {
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
const tiles = wrapper.findAll('[role="radio"]')
await tiles[1]!.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['saint-jean'])
})
it('emits change with the full selected site object', async () => {
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
const tiles = wrapper.findAll('[role="radio"]')
await tiles[2]!.trigger('click')
expect(wrapper.emitted('change')?.[0]).toEqual([
{id: 'pommevic', name: 'Pommevic', color: '#dc2626'},
])
})
it('respects modelValue in controlled mode', () => {
const wrapper = mountComponent({sites, modelValue: 'pommevic'})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('aria-checked')).toBe('false')
expect(tiles[1]!.attributes('aria-checked')).toBe('false')
expect(tiles[2]!.attributes('aria-checked')).toBe('true')
})
it('switches selection on click in uncontrolled mode', async () => {
const wrapper = mountComponent({sites})
const tiles = wrapper.findAll('[role="radio"]')
await tiles[1]!.trigger('click')
expect(tiles[0]!.attributes('aria-checked')).toBe('false')
expect(tiles[1]!.attributes('aria-checked')).toBe('true')
expect(tiles[2]!.attributes('aria-checked')).toBe('false')
})
it('sets roving tabindex (active = 0, others = -1)', () => {
const wrapper = mountComponent({sites, modelValue: 'saint-jean'})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('tabindex')).toBe('-1')
expect(tiles[1]!.attributes('tabindex')).toBe('0')
expect(tiles[2]!.attributes('tabindex')).toBe('-1')
})
it('merges groupClass, tileClass and labelClass via twMerge', () => {
const wrapper = mountComponent({
sites,
groupClass: 'rounded-none bg-black',
tileClass: 'py-10',
labelClass: 'text-xs',
})
const group = wrapper.find('[role="radiogroup"]')
expect(group.classes()).toContain('rounded-none')
expect(group.classes()).toContain('bg-black')
const tile = wrapper.find('[role="radio"]')
expect(tile.classes()).toContain('py-10')
expect(tile.classes()).not.toContain('py-4')
const label = tile.find('span')
expect(label.classes()).toContain('text-xs')
})
it('uses a custom id when provided', () => {
const wrapper = mountComponent({sites, id: 'my-selector'})
expect(wrapper.find('[role="radiogroup"]').attributes('id')).toBe('my-selector')
})
})
-104
View File
@@ -1,104 +0,0 @@
<template>
<div
v-bind="$attrs"
:id="componentId"
role="radiogroup"
:class="mergedGroupClass"
>
<button
v-for="site in sites"
:key="site.id"
type="button"
role="radio"
:aria-checked="activeId === site.id"
:tabindex="activeId === site.id ? 0 : -1"
:style="{
backgroundColor: activeColor,
opacity: activeId === site.id ? 1 : 0.4,
}"
:class="mergedTileClass"
@click="select(site.id)"
>
<span :class="mergedLabelClass">{{ site.name }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioSiteSelector', inheritAttrs: false})
type Site = {
id: string
name: string
color: string
}
const props = withDefaults(defineProps<{
sites: Site[]
modelValue?: string
id?: string
groupClass?: string
tileClass?: string
labelClass?: string
}>(), {
modelValue: undefined,
id: '',
groupClass: '',
tileClass: '',
labelClass: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'change', site: Site): void
}>()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-site-selector-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(props.sites.length > 0 ? props.sites[0]!.id : '')
const activeId = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const activeColor = computed(() =>
props.sites.find((s) => s.id === activeId.value)?.color ?? '',
)
const mergedGroupClass = computed(() =>
twMerge(
'flex w-full',
props.groupClass,
),
)
const mergedTileClass = computed(() =>
twMerge(
'flex-1 cursor-pointer px-6 py-4 text-center transition-opacity focus:outline-none',
props.tileClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'text-white font-bold uppercase tracking-wide',
props.labelClass,
),
)
function select(id: string) {
const site = props.sites.find((s) => s.id === id)
if (!site) return
if (!isControlled.value) {
localValue.value = id
}
emit('update:modelValue', id)
emit('change', site)
}
</script>
-116
View File
@@ -1,116 +0,0 @@
<template>
<Story title="Site/Selector">
<div class="grid grid-cols-1 gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Trois sites</h2>
<MalioSiteSelector v-model="threeValue" :sites="sites" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ threeValue }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Deux sites</h2>
<MalioSiteSelector v-model="twoValue" :sites="sitesTwo" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Cinq sites</h2>
<MalioSiteSelector v-model="fiveValue" :sites="sitesFive" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé</h2>
<MalioSiteSelector :sites="sites" />
</div>
</div>
</Story>
</template>
<docs lang="md">
# MalioSiteSelector
Sélecteur horizontal pour choisir **un site** (usine ou lieu) parmi une liste. Les tuiles occupent une largeur proportionnelle du conteneur. La couleur du site sélectionné est appliquée à toutes les tuiles ; la tuile active est opaque (opacité 1), les autres sont atténuées (opacité 0.4).
---
## Props détaillées
### sites
- Type : `Array<{ id: string; name: string; color: string }>`
- Requis : oui
- Description : Liste des sites à afficher. `color` est un hex (ex : `'#0055ff'`). La couleur du site actuellement sélectionné est appliquée à toutes les tuiles.
### modelValue
- Type : `string`
- Description : `id` du site sélectionné (v-model). Sans `v-model`, le premier site est sélectionné par défaut (mode non contrôlé).
### id
- Type : `string`
- Description : Identifiant HTML du conteneur. Auto-généré si absent.
### groupClass / tileClass / labelClass
- Type : `string`
- Description : Classes Tailwind additionnelles fusionnées via `twMerge` sur, respectivement, le conteneur `<div role="radiogroup">`, chaque tuile et le libellé.
---
## Comportement
- **Toujours un site sélectionné.** Re-cliquer sur la tuile active ne la désélectionne pas.
- **Couleur partagée.** Le `background-color` de toutes les tuiles suit la couleur du site sélectionné. Changer de site met à jour instantanément la couleur de la bande.
- **Pas de gestion d'overflow** : les tuiles se répartissent proportionnellement sur toute la largeur disponible.
---
## Accessibilité
- `role="radiogroup"` sur le conteneur.
- `role="radio"` avec `aria-checked` sur chaque tuile.
- Roving `tabindex` : la tuile active est focusable (`tabindex="0"`), les autres sont exclues du tab order (`tabindex="-1"`).
- Activation par Enter/Space via l'élément `<button>`.
---
## Events
### update:modelValue
- Émis au clic sur une tuile.
- Retourne l'`id` (`string`) du site sélectionné.
### change
- Émis au clic sur une tuile, en complément de `update:modelValue`.
- Retourne l'objet `Site` complet (`{ id, name, color }`) utile pour déclencher des actions (appel API, filtrage) sans avoir à relire le tableau `sites` côté consommateur.
</docs>
<script setup lang="ts">
import { ref } from 'vue'
import MalioSiteSelector from '../../components/malio/site/SiteSelector.vue'
const sites = [
{ id: 'chatellerault', name: 'Châtellerault', color: '#0055ff' },
{ id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a' },
{ id: 'pommevic', name: 'Pommevic', color: '#dc2626' },
]
const sitesTwo = [
{ id: 'nord', name: 'Usine Nord', color: '#7c3aed' },
{ id: 'sud', name: 'Usine Sud', color: '#ea580c' },
]
const sitesFive = [
{ id: 's1', name: 'Site 1', color: '#0ea5e9' },
{ id: 's2', name: 'Site 2', color: '#14b8a6' },
{ id: 's3', name: 'Site 3', color: '#f59e0b' },
{ id: 's4', name: 'Site 4', color: '#ec4899' },
{ id: 's5', name: 'Site 5', color: '#6366f1' },
]
const threeValue = ref('chatellerault')
const twoValue = ref('nord')
const fiveValue = ref('s3')
</script>
+1 -3
View File
@@ -6,9 +6,7 @@
"files": [ "files": [
"app/**", "app/**",
"nuxt.config.ts", "nuxt.config.ts",
"tailwind.config.ts", "README.md"
"README.md",
"COMPONENTS.md"
], ],
"scripts": { "scripts": {
"dev": "nuxi dev .playground", "dev": "nuxi dev .playground",
+6 -10
View File
@@ -1,16 +1,12 @@
import type {Config} from 'tailwindcss' import type {Config} from 'tailwindcss'
import {fileURLToPath} from 'node:url'
import {dirname, join} from 'node:path'
const dir = dirname(fileURLToPath(import.meta.url))
export default { export default {
content: [ content: [
join(dir, 'app/**/*.{vue,js,ts}'), './app/**/*.{vue,js,ts}',
join(dir, 'app/**/*.story.{vue,js,ts}'), './app/**/*.story.{vue,js,ts}',
join(dir, '.playground/**/*.{vue,js,ts}'), './.playground/**/*.{vue,js,ts}',
join(dir, 'histoire.setup.ts'), './histoire.setup.ts',
join(dir, 'histoire.config.ts'), './histoire.config.ts',
], ],
theme: { theme: {
extend: { extend: {
@@ -43,7 +39,7 @@ export default {
}, },
}, },
fontFamily: { fontFamily: {
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif'], sans: ['"Inter"', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
}, },
}, },
}, },