5 Commits

Author SHA1 Message Date
8de950c402 fix: distribution de tailwind.config.ts aux projets consommateurs avec paths content absolus (#31)
All checks were successful
Release / release (push) Successful in 1m8s
| 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é

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #31
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 12:54:42 +00:00
1a14629404 feat: ajout du composant site selector (#30)
All checks were successful
Release / release (push) Successful in 1m7s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #30
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-17 12:32:09 +00:00
6720e3062a feat: ajout du composant datatable (#28)
All checks were successful
Release / release (push) Successful in 1m5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #28
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-16 07:06:50 +00:00
e38255341d fix: suppression du doublon du composant Checkbox (#26)
All checks were successful
Release / release (push) Successful in 1m13s
| 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é

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #26
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-07 12:32:09 +00:00
1bbe77d391 fix: style des composants inputs + affichage des hint/error/success (#25)
All checks were successful
Release / release (push) Successful in 1m19s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-07 08:15:16 +00:00
28 changed files with 2813 additions and 932 deletions

View File

@@ -1,101 +0,0 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioCheckbox
v-model="simpleValue"
label="Accepter les conditions"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Coche par default</h2>
<MalioCheckbox
v-model="checkedValue"
label="Recevoir la newsletter"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Hint</h2>
<MalioCheckbox
v-model="hintValue"
label="J'accepte le traitement des donnees"
hint="Vous pouvez retirer votre consentement a tout moment."
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioCheckbox
:model-value="false"
label="Accepter les CGU"
error="Ce champ est obligatoire."
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioCheckbox
:model-value="true"
label="Adresse vérifiée"
success="Choix valide."
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Disabled et Readonly</h2>
<div class="space-y-4">
<MalioCheckbox
:model-value="true"
label="Option désactivée"
disabled
/>
<MalioCheckbox
:model-value="true"
label="Option readonly"
readonly
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Plusieurs checkbox</h2>
<div class="space-y-4">
<MalioCheckbox
label="Option 1"
/>
<MalioCheckbox
label="Option 2"
/>
<MalioCheckbox
label="Option 3"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Plusieurs checkbox avec v-for</h2>
<div class="space-y-4">
<MalioCheckbox
v-for="option in options"
:key="option"
:label="option"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioCheckbox from '../../../app/components/malio/Checkbox.vue'
const simpleValue = ref(false)
const checkedValue = ref(true)
const hintValue = ref(false)
const options = [
'Option A',
'Option B',
'Option C',
'Option D',
]
</script>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
const page = ref(1)
const perPage = ref(10)
const filtreNom = ref('')
const filtreVille = ref<string | number | null>(null)
const columns = [
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'ville', label: 'Ville' },
{ key: 'montant', label: 'Montant' },
]
const allItems = [
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
{ id: 13, nom: 'Roux', prenom: 'Hugo', ville: 'Paris', montant: 2800 },
{ id: 14, nom: 'David', prenom: 'Léa', ville: 'Lyon', montant: 670 },
{ id: 15, nom: 'Bertrand', prenom: 'Lucas', ville: 'Marseille', montant: 1950 },
]
const filteredItems = computed(() => {
return allItems.filter((item) => {
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
if (filtreVille.value && item.ville !== filtreVille.value) return false
return true
})
})
const paginatedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return filteredItems.value.slice(start, start + perPage.value)
})
function onRowClick(item: Record<string, unknown>) {
alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`)
}
</script>
<template>
<div class="space-y-6">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">DataTable avec filtres et pagination</h2>
<MalioDataTable
:columns="columns"
:items="paginatedItems"
:total-items="filteredItems.length"
v-model:page="page"
v-model:per-page="perPage"
@row-click="onRowClick"
>
<template #header-nom>
<input
v-model="filtreNom"
type="text"
placeholder="Nom"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 outline-none text-[20px]"
>
</template>
<template #header-ville>
<select
:value="filtreVille ?? ''"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-[20px] outline-none"
@change="filtreVille = ($event.target as HTMLSelectElement).value || null"
>
<option value="">Ville</option>
<option value="Paris">Paris</option>
<option value="Lyon">Lyon</option>
<option value="Marseille">Marseille</option>
</select>
</template>
<template #cell-montant="{ item }">
<strong>{{ item.montant }} </strong>
</template>
</MalioDataTable>
</div>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<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>

View File

@@ -24,7 +24,11 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-10] Création d'un composant bouton
* [#MUI-2] Faire un MCP pour la librairie de composant
* [#MUI-15] Création d'un composant drawer
* [#MUI-22] Création d'un composant datatable
* [#MUI-27] Création d'un composant sélection de site
### Changed
### 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

View File

@@ -394,3 +394,59 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transp
<p>Drawer plus large</p>
</MalioDrawer>
```
---
## MalioDataTable
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `columns` | `{ key: string, label: string }[]` | **requis** | Définition des colonnes |
| `items` | `Record<string, unknown>[]` | **requis** | Données à afficher |
| `totalItems` | `number` | **requis** | Total pour la pagination |
| `page` | `number` | `1` | Page courante (v-model) |
| `perPage` | `number` | `10` | Lignes par page (v-model) |
| `perPageOptions` | `number[]` | `[10, 25, 50]` | Options du sélecteur de lignes |
| `rowClickable` | `boolean` | `true` | Lignes cliquables (cursor pointer + hover) |
| `tableClass` | `string` | `''` | Classes CSS sur `<table>` (twMerge) |
| `emptyMessage` | `string` | `'Aucune donnée'` | Message si items vide |
**Events :** `update:page(value: number)`, `update:per-page(value: number)`, `row-click(item: Record<string, unknown>)`
**Slots :** `#header-{key}` (filtre dans le `<th>`, placeholder = label), `#cell-{key}` (contenu du `<td>`), `#empty` (état vide)
```vue
<!-- Avec filtres et pagination -->
<MalioDataTable
:columns="[{ key: 'nom', label: 'Nom' }, { key: 'ville', label: 'Ville' }]"
:items="data"
:total-items="total"
v-model:page="page"
v-model:per-page="perPage"
@row-click="router.push(`/contact/${$event.id}`)"
>
<template #header-nom>
<input v-model="filtreNom" placeholder="Nom" class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none">
</template>
<template #header-ville>
<select v-model="filtreVille" class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none">
<option value="">Ville</option>
<option v-for="v in villes" :key="v" :value="v">{{ v }}</option>
</select>
</template>
<template #cell-nom="{ item }">
<strong>{{ item.nom }}</strong>
</template>
</MalioDataTable>
<!-- Simple sans filtres -->
<MalioDataTable
:columns="columns"
:items="data"
:total-items="total"
v-model:page="page"
v-model:per-page="perPage"
/>
```

View File

@@ -35,6 +35,6 @@
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
--m-site-green: 116 191 4; /* #74BF04 - Vert Pommevic */
--m-radius: 8px;
--m-radius: 6px;
}
}

View File

@@ -1,142 +0,0 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Checkbox from './Checkbox.vue'
type CheckboxProps = {
id?: string
label?: string
name?: string
modelValue?: boolean | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
const mountCheckbox = (props: CheckboxProps = {}) =>
mount(CheckboxForTest, {props})
describe('MalioCheckbox', () => {
it('renders a checkbox input', () => {
const wrapper = mountCheckbox()
expect(wrapper.get('input').attributes('type')).toBe('checkbox')
})
it('renders the label text', () => {
const wrapper = mountCheckbox({label: 'Accept terms'})
expect(wrapper.get('label').text()).toContain('Accept terms')
})
it('uses a provided id on input and label', () => {
const wrapper = mountCheckbox({
id: 'checkbox-id',
label: 'Accept terms',
})
expect(wrapper.get('input').attributes('id')).toBe('checkbox-id')
expect(wrapper.get('label').attributes('for')).toBe('checkbox-id')
})
it('generates an id when none is provided', () => {
const wrapper = mountCheckbox({label: 'Accept terms'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-checkbox-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('applies the name attribute', () => {
const wrapper = mountCheckbox({name: 'terms'})
expect(wrapper.get('input').attributes('name')).toBe('terms')
})
it('reflects the checked state from modelValue', () => {
const wrapper = mountCheckbox({modelValue: true})
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
})
it('emits update:modelValue when toggled', async () => {
const wrapper = mountCheckbox({modelValue: false})
const input = wrapper.get('input')
await input.setValue(true)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
})
it('does not emit when readonly', async () => {
const wrapper = mountCheckbox({
modelValue: true,
readonly: true,
})
const input = wrapper.get('input')
await input.setValue(false)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).checked).toBe(true)
})
it('sets disabled and required attributes', () => {
const wrapper = mountCheckbox({
disabled: true,
required: true,
})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').attributes('required')).toBeDefined()
})
it('shows a hint message and links it with aria-describedby', () => {
const wrapper = mountCheckbox({hint: 'Required field'})
const inputId = wrapper.get('input').attributes('id')
expect(wrapper.get('p').text()).toBe('Required field')
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
})
it('shows an error state and message', () => {
const wrapper = mountCheckbox({
label: 'Accept terms',
error: 'You must accept',
})
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('label').classes()).toContain('text-m-error')
expect(wrapper.get('p').text()).toBe('You must accept')
})
it('shows success only when there is no error', () => {
const wrapper = mountCheckbox({
success: 'Valid',
error: 'Invalid',
})
expect(wrapper.get('p').text()).toBe('Invalid')
expect(wrapper.get('p').classes()).toContain('text-m-error')
})
it('shows success styles and message when there is no error', () => {
const wrapper = mountCheckbox({
label: 'Accept terms',
success: 'Valid',
modelValue: true,
})
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('p').text()).toBe('Valid')
expect(wrapper.get('p').classes()).toContain('text-m-success')
})
})

View File

@@ -1,227 +0,0 @@
<template>
<div :class="mergedGroupClass">
<input
:id="inputId"
:name="name"
:checked="isChecked"
:required="required"
:disabled="disabled"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:class="mergedInputClass"
v-bind="attrs"
type="checkbox"
@change="onChange"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
<span>
<svg width="12" height="10" viewBox="0 0 12 10" aria-hidden="true">
<polyline points="1.5 6 4.5 9 10.5 1"/>
</svg>
</span>
<span>
{{ label }}
</span>
</label>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="mergedMessageClass"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: boolean | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}>(),
{
id: '',
label: '',
name: '',
modelValue: undefined,
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
},
)
const attrs = useAttrs()
const generatedId = useId()
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
const isChecked = computed(() => !!props.modelValue)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const disabled = computed(() => props.disabled)
const describedBy = computed(() => {
if (!props.hint && !hasError.value && !hasSuccess.value) return undefined
return `${inputId.value}-describedby`
})
const mergedGroupClass = computed(() =>
twMerge(
'checkbox-wrapper-4 mt-4 w-full',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'inp-cbx peer',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'cbx text-black',
disabled.value ? 'cursor-not-allowed text-black/60' : '',
hasError.value ? 'text-m-error' : '',
hasSuccess.value ? 'text-m-success' : '',
props.labelClass,
),
)
const mergedMessageClass = computed(() =>
twMerge(
'text-xs',
hasError.value
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'text-m-muted',
),
)
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const onChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (props.readonly) {
target.checked = isChecked.value
return
}
emit('update:modelValue', target.checked)
}
</script>
<style scoped>
.cbx {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.cbx span {
display: inline-flex;
align-items: center;
}
.cbx span:first-child {
position: relative;
width: 18px;
height: 18px;
flex: 0 0 18px;
transform: scale(1);
border: 2px solid rgb(0, 0, 0);
transition: all 0.1s ease;
}
.cbx span:first-child svg {
position: absolute;
top: 2px;
left: 1px;
fill: none;
stroke: #000000;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16px;
stroke-dashoffset: 16px;
transition: all 0.125s ease;
}
.cbx span:last-child {
padding-left: 12px;
line-height: 18px;
}
.inp-cbx {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.inp-cbx:checked + .cbx span:first-child svg {
stroke-dashoffset: 0;
}
.inp-cbx + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
}
.cbx.text-m-error span:first-child svg {
stroke: rgb(var(--m-error) / 1);
}
.inp-cbx:checked + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
}
.inp-cbx + .cbx.text-m-success span:first-child {
border-color: rgb(var(--m-success) / 1);
}
.cbx.text-m-success span:first-child svg {
stroke: rgb(var(--m-success) / 1);
}
.inp-cbx:checked + .cbx.text-m-success span:first-child {
border-color: rgb(var(--m-success) / 1);
}
.inp-cbx:disabled + .cbx {
cursor: not-allowed;
opacity: 0.6;
}
</style>

View File

@@ -114,7 +114,7 @@ describe('MalioCheckbox', () => {
})
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('label').classes()).toContain('text-m-danger')
expect(wrapper.get('label').classes()).toContain('text-m-error')
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').classes()).toContain('text-m-danger')
expect(wrapper.get('p').classes()).toContain('text-m-error')
})
it('shows success styles and message when there is no error', () => {

View File

@@ -110,7 +110,7 @@ const mergedLabelClass = computed(() =>
twMerge(
'cbx text-black',
disabled.value ? 'cursor-not-allowed text-black/60' : '',
hasError.value ? 'text-m-danger' : '',
hasError.value ? 'text-m-error' : '',
hasSuccess.value ? 'text-m-success' : '',
props.labelClass,
),
@@ -120,7 +120,7 @@ const mergedMessageClass = computed(() =>
twMerge(
'text-xs',
hasError.value
? 'text-m-danger'
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'text-m-muted',
@@ -200,14 +200,14 @@ const onChange = (event: Event) => {
stroke-dashoffset: 0;
}
.inp-cbx + .cbx.text-m-danger span:first-child {
border-color: rgb(var(--m-danger) / 1);
.inp-cbx + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
}
.cbx.text-m-danger span:first-child svg {
stroke: rgb(var(--m-danger) / 1);
.cbx.text-m-error span:first-child svg {
stroke: rgb(var(--m-error) / 1);
}
.inp-cbx:checked + .cbx.text-m-danger span:first-child {
border-color: rgb(var(--m-danger) / 1);
.inp-cbx:checked + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
}
.inp-cbx + .cbx.text-m-success span:first-child {

View File

@@ -0,0 +1,278 @@
import { describe, expect, it } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import DataTable from './DataTable.vue'
type DataTableProps = {
id?: string
columns?: { key: string; label: string }[]
items?: Record<string, unknown>[]
totalItems?: number
page?: number
perPage?: number
perPageOptions?: number[]
rowClickable?: boolean
tableClass?: string
emptyMessage?: string
}
const DataTableForTest = DataTable as DefineComponent<DataTableProps>
const defaultColumns = [
{ key: 'nom', label: 'Nom' },
{ key: 'ville', label: 'Ville' },
]
const defaultItems = [
{ nom: 'Dupont', ville: 'Paris' },
{ nom: 'Martin', ville: 'Lyon' },
{ nom: 'Bernard', ville: 'Marseille' },
]
function mountComponent(props: DataTableProps = {}, slots?: Record<string, unknown>) {
return mount(DataTableForTest, {
props: {
columns: defaultColumns,
items: defaultItems,
totalItems: 3,
...props,
},
slots,
global: {
stubs: {
MalioSelect: {
name: 'MalioSelect',
template: '<div data-test="malio-select"><slot /></div>',
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
},
MalioButton: {
template: '<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)"><slot>{{ label }}</slot></button>',
props: ['label', 'disabled', 'variant', 'buttonClass'],
emits: ['click'],
inheritAttrs: true,
},
},
},
})
}
describe('MalioDataTable', () => {
describe('Table rendering', () => {
it('renders column headers as text when no header slot', () => {
const wrapper = mountComponent()
const headers = wrapper.findAll('th')
expect(headers).toHaveLength(2)
expect(headers[0].text()).toBe('Nom')
expect(headers[1].text()).toBe('Ville')
})
it('renders header slot when provided', () => {
const wrapper = mountComponent({}, {
'header-nom': '<input data-test="filter-nom" placeholder="Nom" />',
})
expect(wrapper.find('[data-test="filter-nom"]').exists()).toBe(true)
})
it('renders items as rows', () => {
const wrapper = mountComponent()
const rows = wrapper.findAll('[data-test="row"]')
expect(rows).toHaveLength(3)
expect(rows[0].text()).toContain('Dupont')
expect(rows[0].text()).toContain('Paris')
})
it('renders cell slot when provided', () => {
const wrapper = mountComponent({}, {
'cell-nom': ({ item }: { item: Record<string, unknown> }) => h('strong', String(item.nom)),
})
const firstRow = wrapper.findAll('[data-test="row"]')[0]
expect(firstRow.find('strong').text()).toBe('Dupont')
})
it('renders empty message when items is empty', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Aucune donnée')
})
it('renders custom empty message', () => {
const wrapper = mountComponent({ items: [], totalItems: 0, emptyMessage: 'Rien ici' })
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Rien ici')
})
it('renders empty slot when provided', () => {
const wrapper = mountComponent(
{ items: [], totalItems: 0 },
{ empty: '<p data-test="custom-empty">Vide</p>' },
)
expect(wrapper.find('[data-test="custom-empty"]').text()).toBe('Vide')
})
it('empty row has colspan equal to columns length', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
const td = wrapper.find('[data-test="empty-row"] td')
expect(td.attributes('colspan')).toBe('2')
})
})
describe('Row click', () => {
it('emits row-click with item on row click', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('emits row-click on Enter key', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.enter')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('emits row-click on Space key', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.space')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('rows have tabindex when clickable', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBe('0')
})
it('rows have cursor-pointer when clickable', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('[data-test="row"]')[0].classes()).toContain('cursor-pointer')
})
it('rows are not clickable when rowClickable is false', async () => {
const wrapper = mountComponent({ rowClickable: false })
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
expect(wrapper.emitted('row-click')).toBeUndefined()
})
it('rows have no tabindex when not clickable', () => {
const wrapper = mountComponent({ rowClickable: false })
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBeUndefined()
})
})
describe('Accessibility', () => {
it('th elements have scope="col"', () => {
const wrapper = mountComponent()
const ths = wrapper.findAll('th')
ths.forEach(th => {
expect(th.attributes('scope')).toBe('col')
})
})
it('generates an id when not provided', () => {
const wrapper = mountComponent()
const id = wrapper.find('div').attributes('id')
expect(id).toMatch(/^malio-datatable-/)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ id: 'my-table' })
expect(wrapper.find('div').attributes('id')).toBe('my-table')
})
})
describe('Pagination', () => {
it('hides pagination when totalItems is 0', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(false)
})
it('shows pagination when totalItems > 0', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
})
it('renders all pages when totalPages <= 5', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
for (let i = 1; i <= 5; i++) {
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
}
})
it('highlights current page', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
})
it('emits update:page on page button click', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
await wrapper.find('[data-test="page-3"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
})
it('Prev button is disabled on page 1', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
})
it('Next button is disabled on last page', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 5 })
expect(wrapper.find('[data-test="next-button"]').attributes('disabled')).toBeDefined()
})
it('Prev button emits update:page with page - 1', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
await wrapper.find('[data-test="prev-button"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
})
it('Next button emits update:page with page + 1', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
await wrapper.find('[data-test="next-button"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
})
it('shows ellipsis for truncated pages (> 5 pages)', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
expect(ellipsis.length).toBeGreaterThan(0)
expect(ellipsis[0].text()).toBe('…')
})
it('always shows first and last page when > 5 pages', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
})
it('shows 1 neighbor on each side of current page', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
})
it('pagination nav has aria-label', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
})
it('Prev button has aria-label "Page précédente"', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="prev-button"]').attributes('aria-label')).toBe('Page précédente')
})
it('Next button has aria-label "Page suivante"', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="next-button"]').attributes('aria-label')).toBe('Page suivante')
})
})
describe('Per-page selector', () => {
it('emits update:per-page and reset page to 1 on change', async () => {
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
const select = wrapper.findComponent({ name: 'MalioSelect' })
select.vm.$emit('update:modelValue', 25)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:per-page')?.[0]).toEqual([25])
expect(wrapper.emitted('update:page')?.[0]).toEqual([1])
})
})
})

View File

@@ -0,0 +1,222 @@
<template>
<div :id="componentId" class="w-full" v-bind="attrs">
<table :class="twMerge('w-full border-separate border-spacing-0 border border-black rounded-malio overflow-hidden', tableClass)">
<thead>
<tr class="bg-m-surface">
<th
v-for="col in columns"
:key="col.key"
scope="col"
class="border-b border-black px-3 py-3 text-left align-middle text-[20px]"
>
<slot
v-if="$slots[`header-${col.key}`]"
:name="`header-${col.key}`"
:column="col"
/>
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in items"
:key="index"
:class="rowClickable ? 'cursor-pointer hover:bg-m-bg' : ''"
:tabindex="rowClickable ? 0 : undefined"
data-test="row"
@click="rowClickable ? emit('row-click', item) : undefined"
@keydown.enter="rowClickable ? emit('row-click', item) : undefined"
@keydown.space.prevent="rowClickable ? emit('row-click', item) : undefined"
>
<td
v-for="col in columns"
:key="col.key"
class="px-3 py-4 text-[18px] text-m-primary"
:class="index < items.length - 1 ? 'border-b border-black' : ''"
>
<slot
v-if="$slots[`cell-${col.key}`]"
:name="`cell-${col.key}`"
:item="item"
:column="col"
/>
<template v-else>{{ item[col.key] }}</template>
</td>
</tr>
<tr v-if="!items.length" data-test="empty-row">
<td
:colspan="columns.length"
class="px-3 py-4 text-center text-m-muted"
>
<slot name="empty">{{ emptyMessage }}</slot>
</td>
</tr>
</tbody>
</table>
<div
v-if="totalItems > 0"
class="flex justify-between pt-2"
data-test="pagination"
>
<div class="flex gap-4">
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
<MalioSelect
:model-value="perPage"
:options="perPageSelectOptions"
min-width="w-20 !mt-0"
rounded="rounded"
text-field="text-sm"
text-value="text-sm"
text-label="text-xs"
data-test="per-page-select"
@update:model-value="onPerPageChange"
/>
</div>
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav">
<MalioButton
variant="tertiary"
label="Prev"
:disabled="page <= 1"
button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente"
data-test="prev-button"
@click="goToPage(page - 1)"
/>
<template v-for="(p, idx) in visiblePages" :key="idx">
<span
v-if="p === '...'"
class="px-1 text-sm text-m-muted"
aria-hidden="true"
></span>
<button
v-else
type="button"
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
:class="p === page
? 'bg-m-btn-primary text-white font-semibold'
: 'text-m-text hover:bg-m-bg'"
:aria-current="p === page ? 'page' : undefined"
:data-test="`page-${p}`"
@click="goToPage(p)"
>
{{ p }}
</button>
</template>
<MalioButton
variant="tertiary"
label="Next"
:disabled="page >= totalPages"
button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante"
data-test="next-button"
@click="goToPage(page + 1)"
/>
</nav>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useAttrs, useId } from 'vue'
import { twMerge } from 'tailwind-merge'
import MalioSelect from '../select/Select.vue'
import MalioButton from '../button/Button.vue'
defineOptions({ name: 'MalioDataTable', inheritAttrs: false })
type DataTableColumn = {
key: string
label: string
}
const attrs = useAttrs()
const props = withDefaults(
defineProps<{
id?: string
columns: DataTableColumn[]
items: Record<string, unknown>[]
totalItems: number
page?: number
perPage?: number
perPageOptions?: number[]
rowClickable?: boolean
tableClass?: string
emptyMessage?: string
}>(),
{
id: '',
page: 1,
perPage: 10,
perPageOptions: () => [10, 25, 50],
rowClickable: true,
tableClass: '',
emptyMessage: 'Aucune donnée',
},
)
const emit = defineEmits<{
(e: 'update:page' | 'update:per-page', value: number): void
(e: 'row-click', item: Record<string, unknown>): void
}>()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
const perPageSelectOptions = computed(() =>
props.perPageOptions.map(n => ({ label: String(n), value: n }))
)
function onPerPageChange(value: string | number | null) {
if (value !== null) {
emit('update:per-page', Number(value))
emit('update:page', 1)
}
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
emit('update:page', page)
}
}
const visiblePages = computed(() => {
const total = totalPages.value
const current = props.page
if (total <= 5) {
return Array.from({ length: total }, (_, i) => i + 1)
}
const pages: (number | '...')[] = []
pages.push(1)
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('...')
}
if (total > 1) {
pages.push(total)
}
return pages
})
</script>

View File

@@ -1,67 +1,69 @@
<template>
<div
: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"
<div>
<div
: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"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
? 'text-m-success' : iconColor,
iconPositionClass,
]"
>
{{ 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>
<script setup lang="ts">
@@ -133,7 +135,7 @@ const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
'relative flex h-12 w-full items-center',
props.groupClass,
),
)

View File

@@ -1,68 +1,70 @@
<template>
<div :class="mergedGroupClass" >
<label
v-if="label"
:for="inputId"
: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>
<div :class="mergedGroupClass" >
<label
v-if="label"
:for="inputId"
: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 }}
</p>
</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>
<script setup lang="ts">
@@ -170,7 +172,7 @@ const isPlusDisabled = computed(() =>
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
'relative flex h-12 w-full items-center',
props.groupClass,
),
)

View File

@@ -1,67 +1,69 @@
<template>
<div
: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"
<div>
<div
: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"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="displayIcon"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24"
:height="24"
data-test="icon"
: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="[
<IconifyIcon
v-if="displayIcon"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24"
:height="24"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
? 'text-m-success' : 'text-m-muted',
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
]"
>
{{ hint || error || success }}
</p>
@click="toggleVisibility"
/>
</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>
<script setup lang="ts">
@@ -132,7 +134,7 @@ const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
'relative flex h-12 w-full items-center',
props.groupClass,
),
)

View File

@@ -1,67 +1,69 @@
<template>
<div
: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"
<div>
<div
: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"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
? 'text-m-success' : iconColor,
iconPositionClass,
]"
>
{{ 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>
<script setup lang="ts">
@@ -138,7 +140,7 @@ const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
'relative flex h-12 w-full items-center',
props.groupClass,
),
)

View File

@@ -1,70 +1,72 @@
<template>
<div
:class="mergedGroupClass"
>
<input
ref="fileInputRef"
type="file"
:accept="accept"
class="hidden"
:disabled="disabled"
@change="onFileChange"
<div>
<div
:class="mergedGroupClass"
>
<input
ref="fileInputRef"
type="file"
:accept="accept"
class="hidden"
:disabled="disabled"
@change="onFileChange"
>
<input
:id="inputId"
:class="mergedInputClass"
:disabled="disabled"
:value="currentDisplayValue"
:readonly="true"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
type="text"
@click="openFilePicker"
@focus="isFocused = true"
@blur="isFocused = false"
>
<input
:id="inputId"
:class="mergedInputClass"
:disabled="disabled"
:value="currentDisplayValue"
:readonly="true"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
type="text"
@click="openFilePicker"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="displayIcon"
icon="mdi:cloud-arrow-up-outline"
:width="24"
:height="24"
data-test="icon"
: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="[
<IconifyIcon
v-if="displayIcon"
icon="mdi:cloud-arrow-up-outline"
:width="24"
:height="24"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
? 'text-m-success' : 'text-m-muted',
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
]"
>
{{ 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>
<script setup lang="ts">
@@ -121,7 +123,7 @@ const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
'relative flex h-12 w-full items-center',
props.groupClass,
),
)

View File

@@ -1,8 +1,9 @@
<template>
<div
ref="root"
:class="mergedGroupClass"
>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<button
:id="buttonId"
type="button"
@@ -133,21 +134,22 @@
{{ opt.label || '\u00A0' }}
</li>
</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>
<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>
<script setup lang="ts">
@@ -211,7 +213,7 @@ const normalizedOptions = computed<Option[]>(() => [
...props.options,
])
const mergedGroupClass = computed(() =>
twMerge('relative mt-4 w-full', props.minWidth, props.maxWidth, props.groupClass),
twMerge('relative w-full', props.minWidth, props.maxWidth, props.groupClass),
)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)

View File

@@ -1,9 +1,10 @@
<template>
<div
ref="root"
class="relative mt-4 w-full"
:class="[minWidth, maxWidth]"
>
<div>
<div
ref="root"
class="relative w-full"
:class="[minWidth, maxWidth]"
>
<button
:id="buttonId"
type="button"
@@ -184,21 +185,22 @@
/>
</li>
</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>
<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>
<script setup lang="ts">

View File

@@ -0,0 +1,154 @@
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')
})
})

View File

@@ -0,0 +1,104 @@
<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>

View File

@@ -0,0 +1,195 @@
<template>
<Story title="Data/DataTable">
<Variant title="Avec filtres et pagination">
<div class="p-4">
<MalioDataTable
:columns="columns"
:items="paginatedItems"
:total-items="filteredItems.length"
v-model:page="page"
v-model:per-page="perPage"
@row-click="onRowClick"
>
<template #header-nom>
<input
v-model="filtreNom"
type="text"
placeholder="Nom"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none"
>
</template>
<template #header-ville>
<select
:value="filtreVille ?? ''"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none"
@change="filtreVille = ($event.target as HTMLSelectElement).value || null"
>
<option value="">Ville</option>
<option value="Paris">Paris</option>
<option value="Lyon">Lyon</option>
<option value="Marseille">Marseille</option>
</select>
</template>
<template #cell-montant="{ item }">
<strong>{{ item.montant }} </strong>
</template>
</MalioDataTable>
</div>
</Variant>
<Variant title="Sans filtres">
<div class="p-4">
<MalioDataTable
:columns="columnsSimple"
:items="simpleItems"
:total-items="simpleItems.length"
v-model:page="pageSimple"
v-model:per-page="perPageSimple"
/>
</div>
</Variant>
<Variant title="État vide">
<div class="p-4">
<MalioDataTable
:columns="columns"
:items="[]"
:total-items="0"
/>
</div>
</Variant>
<Variant title="Lignes non cliquables">
<div class="p-4">
<MalioDataTable
:columns="columnsSimple"
:items="simpleItems.slice(0, 3)"
:total-items="3"
:row-clickable="false"
/>
</div>
</Variant>
<Variant title="Sans filtre ni pagination">
<div class="p-4">
<MalioDataTable
:columns="columnsSimple"
:items="simpleItems.slice(0, 5)"
:total-items="0"
:row-clickable="false"
/>
</div>
</Variant>
</Story>
</template>
<docs lang="md">
# MalioDataTable
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
## Props détaillées
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto-généré | Identifiant HTML |
| `columns` | `{ key: string, label: string }[]` | **requis** | Définition des colonnes |
| `items` | `Record<string, any>[]` | **requis** | Données à afficher |
| `totalItems` | `number` | **requis** | Total pour la pagination |
| `page` | `number` | `1` | Page courante (v-model) |
| `perPage` | `number` | `10` | Lignes par page (v-model) |
| `perPageOptions` | `number[]` | `[10, 25, 50]` | Options du sélecteur de lignes |
| `rowClickable` | `boolean` | `true` | Lignes cliquables |
| `tableClass` | `string` | `''` | Classes CSS sur le wrapper (twMerge) |
| `emptyMessage` | `string` | `'Aucune donnée'` | Message si items vide |
## Slots
| Slot | Scope | Description |
|------|-------|-------------|
| `#header-{key}` | `{ column }` | Filtre dans le `<th>` (placeholder = label). Fallback : texte du label |
| `#cell-{key}` | `{ item, column }` | Contenu du `<td>`. Fallback : `item[key]` |
| `#empty` | | Contenu état vide. Fallback : `emptyMessage` |
## Events
| Event | Payload | Description |
|-------|---------|-------------|
| `update:page` | `number` | Changement de page |
| `update:per-page` | `number` | Changement du nb de lignes (reset page à 1) |
| `row-click` | `Record<string, any>` | Clic sur une ligne |
## Pagination
- 5 pages : toutes affichées
- \> 5 pages : page 1 [voisin] **[courante]** [voisin] dernière
- Boutons Prev/Next toujours visibles, désactivés aux extrêmes
## Accessibilité
- `<th scope="col">` sur chaque en-tête
- `<nav aria-label="Pagination">` autour de la pagination
- Page courante avec `aria-current="page"`
- Lignes cliquables : `tabindex="0"` + Enter/Space
</docs>
<script setup lang="ts">
import { ref, computed } from 'vue'
import MalioDataTable from '../../components/malio/datatable/DataTable.vue'
defineOptions({ name: 'DataTableStory' })
const columns = [
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'ville', label: 'Ville' },
{ key: 'montant', label: 'Montant' },
]
const columnsSimple = [
{ key: 'nom', label: 'Nom' },
{ key: 'ville', label: 'Ville' },
]
const allItems = [
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
]
const simpleItems = allItems.map(i => ({ nom: i.nom, ville: i.ville }))
const page = ref(1)
const perPage = ref(5)
const filtreNom = ref('')
const filtreVille = ref<string | number | null>(null)
const pageSimple = ref(1)
const perPageSimple = ref(10)
const filteredItems = computed(() => {
return allItems.filter((item) => {
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
if (filtreVille.value && item.ville !== filtreVille.value) return false
return true
})
})
const paginatedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return filteredItems.value.slice(start, start + perPage.value)
})
function onRowClick(item: Record<string, unknown>) {
alert(`Clic sur ${item.nom} ${item.prenom}`)
}
</script>

View File

@@ -1,114 +0,0 @@
<template>
<Story title="Input/Checkbox">
<MalioCheckbox
v-model="simpleValue"
label="Accepter les conditions"
/>
</Story>
</template>
<docs lang="md">
# MalioCheckbox
Composant checkbox custom avec `v-model`, message d'aide, et états visuels
`error` / `success`.
------------------------------------------------------------------------
## Props
### id
- Type: `string`
- Description: Identifiant HTML du checkbox.
- Comportement: si absent, un id unique est généré automatiquement.
### label
- Type: `string`
- Description: Texte affiche a cote de la case.
### name
- Type: `string`
- Description: Attribut `name` du champ.
### modelValue
- Type: `boolean | null | undefined`
- Description: État coche du composant.
### inputClass
- Type: `string`
- Description: Classes supplémentaires appliquées a l'input natif.
### labelClass
- Type: `string`
- Description: Classes supplémentaires appliquées au label.
### groupClass
- Type: `string`
- Description: Classes supplémentaires appliquées au conteneur.
### required
- Type: `boolean`
- Description: Ajoute l'attribut HTML `required`.
### disabled
- Type: `boolean`
- Description: Désactive le composant.
### readonly
- Type: `boolean`
- Description: Empêche la mise a jour du `v-model` tout en gardant
l'affichage courant.
### hint
- Type: `string`
- Description: Message d'aide affiche sous le checkbox.
### error
- Type: `string`
- Description: Message d'erreur.
- Effet: prioritaire sur `success`, applique `aria-invalid` et la couleur
d'erreur au texte et a la case.
### success
- Type: `string`
- Description: Message de succès.
- Effet: applique la couleur de succès au texte et a la case si `error`
est absent.
------------------------------------------------------------------------
## Accessibilité
- `aria-invalid` est active si `error` existe.
- `aria-describedby` pointe vers le message affiche.
- L'input natif reste present pour conserver le comportement formulaire.
------------------------------------------------------------------------
## Event
### update:modelValue
- Émis a chaque changement de l'état coche.
- Retourne un booléen `true` ou `false`.
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioCheckbox from '../components/malio/Checkbox.vue'
const simpleValue = ref(false)
</script>

View File

@@ -0,0 +1,116 @@
<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>

View File

@@ -0,0 +1,966 @@
# MalioDataTable Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create a presentational data table component with pagination, slot-based column filters, and clickable rows.
**Architecture:** Single component `MalioDataTable` in `app/components/malio/datatable/DataTable.vue`. Uses `MalioSelect` internally for the per-page selector and `MalioButton variant="tertiary"` for Prev/Next pagination buttons. All data is provided by the parent via props; the component emits events for page/perPage changes and row clicks.
**Tech Stack:** Vue 3 Composition API, TypeScript, Tailwind CSS, tailwind-merge, Vitest + @vue/test-utils
**Spec:** `docs/superpowers/specs/2026-03-24-datatable-design.md`
**Skill:** Follow `creating-malio-component` workflow (component → tests → playground → story → CHANGELOG → COMPONENTS.md)
---
## File Map
| File | Action | Responsibility |
|------|--------|---------------|
| `app/components/malio/datatable/DataTable.vue` | Create | Main component |
| `app/components/malio/datatable/DataTable.test.ts` | Create | Unit tests |
| `.playground/pages/composant/datatable/datatable.vue` | Create | Playground page |
| `app/story/datatable/datatable.story.vue` | Create | Histoire story + docs |
| `CHANGELOG.md` | Modify | Add entry |
| `COMPONENTS.md` | Modify | Add documentation |
---
### Task 1: Write DataTable component — table rendering (no pagination yet)
**Files:**
- Create: `app/components/malio/datatable/DataTable.vue`
- [ ] **Step 1: Create the component with table rendering only**
The component renders a `<table>` with `<thead>` and `<tbody>`. No pagination yet — just the table structure, columns, items, slots, and row click.
```vue
<template>
<div :id="componentId" class="w-full" v-bind="attrs">
<table :class="twMerge('w-full border-collapse', tableClass)">
<thead>
<tr class="bg-m-surface">
<th
v-for="col in columns"
:key="col.key"
scope="col"
class="border-b-2 border-m-border px-3 py-2 text-left align-middle"
>
<slot
v-if="$slots[`header-${col.key}`]"
:name="`header-${col.key}`"
:column="col"
/>
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in items"
:key="index"
:class="rowClickable ? 'cursor-pointer hover:bg-m-bg' : ''"
:tabindex="rowClickable ? 0 : undefined"
data-test="row"
@click="rowClickable ? emit('row-click', item) : undefined"
@keydown.enter="rowClickable ? emit('row-click', item) : undefined"
@keydown.space.prevent="rowClickable ? emit('row-click', item) : undefined"
>
<td
v-for="col in columns"
:key="col.key"
class="border-b border-m-border px-3 py-2"
>
<slot
v-if="$slots[`cell-${col.key}`]"
:name="`cell-${col.key}`"
:item="item"
:column="col"
/>
<template v-else>{{ item[col.key] }}</template>
</td>
</tr>
<tr v-if="!items.length" data-test="empty-row">
<td
:colspan="columns.length"
class="px-3 py-8 text-center text-m-muted"
>
<slot name="empty">{{ emptyMessage }}</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed, useAttrs, useId } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioDataTable', inheritAttrs: false })
type DataTableColumn = {
key: string
label: string
}
const attrs = useAttrs()
const props = withDefaults(
defineProps<{
id?: string
columns: DataTableColumn[]
items: Record<string, any>[]
totalItems: number
page?: number
perPage?: number
perPageOptions?: number[]
rowClickable?: boolean
tableClass?: string
emptyMessage?: string
}>(),
{
id: '',
page: 1,
perPage: 10,
perPageOptions: () => [10, 25, 50],
rowClickable: true,
tableClass: '',
emptyMessage: 'Aucune donnée',
},
)
const emit = defineEmits<{
(e: 'update:page', value: number): void
(e: 'update:per-page', value: number): void
(e: 'row-click', item: Record<string, any>): void
}>()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
</script>
```
- [ ] **Step 2: Verify the file was created**
Run: `ls app/components/malio/datatable/DataTable.vue`
---
### Task 2: Write tests for table rendering
**Files:**
- Create: `app/components/malio/datatable/DataTable.test.ts`
- [ ] **Step 1: Write tests for table rendering, slots, row click, empty state**
```ts
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import DataTable from './DataTable.vue'
type DataTableProps = {
id?: string
columns?: { key: string; label: string }[]
items?: Record<string, any>[]
totalItems?: number
page?: number
perPage?: number
perPageOptions?: number[]
rowClickable?: boolean
tableClass?: string
emptyMessage?: string
}
const DataTableForTest = DataTable as DefineComponent<DataTableProps>
const defaultColumns = [
{ key: 'nom', label: 'Nom' },
{ key: 'ville', label: 'Ville' },
]
const defaultItems = [
{ nom: 'Dupont', ville: 'Paris' },
{ nom: 'Martin', ville: 'Lyon' },
{ nom: 'Bernard', ville: 'Marseille' },
]
function mountComponent(props: DataTableProps = {}, slots?: Record<string, any>) {
return mount(DataTableForTest, {
props: {
columns: defaultColumns,
items: defaultItems,
totalItems: 3,
...props,
},
slots,
global: {
stubs: {
MalioSelect: {
template: '<div data-test="malio-select"><slot /></div>',
props: ['modelValue', 'options'],
},
MalioButton: {
template: '<button data-test="malio-button" :disabled="disabled" @click="$emit(\'click\', $event)"><slot>{{ label }}</slot></button>',
props: ['label', 'disabled', 'variant', 'buttonClass'],
emits: ['click'],
},
},
},
})
}
describe('MalioDataTable', () => {
describe('Table rendering', () => {
it('renders column headers as text when no header slot', () => {
const wrapper = mountComponent()
const headers = wrapper.findAll('th')
expect(headers).toHaveLength(2)
expect(headers[0].text()).toBe('Nom')
expect(headers[1].text()).toBe('Ville')
})
it('renders header slot when provided', () => {
const wrapper = mountComponent({}, {
'header-nom': '<input data-test="filter-nom" placeholder="Nom" />',
})
expect(wrapper.find('[data-test="filter-nom"]').exists()).toBe(true)
})
it('renders items as rows', () => {
const wrapper = mountComponent()
const rows = wrapper.findAll('[data-test="row"]')
expect(rows).toHaveLength(3)
expect(rows[0].text()).toContain('Dupont')
expect(rows[0].text()).toContain('Paris')
})
it('renders cell slot when provided', () => {
const wrapper = mountComponent({}, {
'cell-nom': ({ item }: any) => `<strong>${item.nom}</strong>`,
})
const firstRow = wrapper.findAll('[data-test="row"]')[0]
expect(firstRow.find('strong').text()).toBe('Dupont')
})
it('renders empty message when items is empty', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Aucune donnée')
})
it('renders custom empty message', () => {
const wrapper = mountComponent({ items: [], totalItems: 0, emptyMessage: 'Rien ici' })
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Rien ici')
})
it('renders empty slot when provided', () => {
const wrapper = mountComponent(
{ items: [], totalItems: 0 },
{ empty: '<p data-test="custom-empty">Vide</p>' },
)
expect(wrapper.find('[data-test="custom-empty"]').text()).toBe('Vide')
})
it('empty row has colspan equal to columns length', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
const td = wrapper.find('[data-test="empty-row"] td')
expect(td.attributes('colspan')).toBe('2')
})
})
describe('Row click', () => {
it('emits row-click with item on row click', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('emits row-click on Enter key', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.enter')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('emits row-click on Space key', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.space')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('rows have tabindex when clickable', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBe('0')
})
it('rows have cursor-pointer when clickable', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('[data-test="row"]')[0].classes()).toContain('cursor-pointer')
})
it('rows are not clickable when rowClickable is false', async () => {
const wrapper = mountComponent({ rowClickable: false })
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
expect(wrapper.emitted('row-click')).toBeUndefined()
})
it('rows have no tabindex when not clickable', () => {
const wrapper = mountComponent({ rowClickable: false })
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBeUndefined()
})
})
describe('Accessibility', () => {
it('th elements have scope="col"', () => {
const wrapper = mountComponent()
const ths = wrapper.findAll('th')
ths.forEach(th => {
expect(th.attributes('scope')).toBe('col')
})
})
it('generates an id when not provided', () => {
const wrapper = mountComponent()
const id = wrapper.find('div').attributes('id')
expect(id).toMatch(/^malio-datatable-/)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ id: 'my-table' })
expect(wrapper.find('div').attributes('id')).toBe('my-table')
})
})
})
```
- [ ] **Step 2: Run tests to verify they pass**
Run: `npm run test -- --run app/components/malio/datatable/DataTable.test.ts`
Expected: All tests PASS
- [ ] **Step 3: Fix any failures and re-run**
---
### Task 3: Add pagination to the component
**Files:**
- Modify: `app/components/malio/datatable/DataTable.vue`
- [ ] **Step 1: Add pagination computed logic and template**
Add these computed properties to the `<script>`:
```ts
import MalioSelect from '../select/Select.vue'
import MalioButton from '../button/Button.vue'
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
const perPageSelectOptions = computed(() =>
props.perPageOptions.map(n => ({ label: String(n), value: n }))
)
function onPerPageChange(value: string | number | null) {
if (value !== null) {
emit('update:per-page', Number(value))
emit('update:page', 1)
}
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
emit('update:page', page)
}
}
const visiblePages = computed(() => {
const total = totalPages.value
const current = props.page
if (total <= 5) {
return Array.from({ length: total }, (_, i) => i + 1)
}
const pages: (number | '...')[] = []
pages.push(1)
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('...')
}
if (total > 1) {
pages.push(total)
}
return pages
})
```
Add this template block after `</table>` and before closing `</div>`:
```html
<div
v-if="totalItems > 0"
class="flex items-center justify-between border-t border-m-border px-3 py-2"
data-test="pagination"
>
<div class="flex items-center gap-2">
<span class="text-sm text-m-muted">Lignes</span>
<MalioSelect
:model-value="perPage"
:options="perPageSelectOptions"
min-width="w-20"
rounded="rounded"
text-field="text-sm"
text-value="text-sm"
text-label="text-xs"
data-test="per-page-select"
@update:model-value="onPerPageChange"
/>
</div>
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
<MalioButton
variant="tertiary"
label="Prev"
:disabled="page <= 1"
button-class="h-8 w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente"
data-test="prev-button"
@click="goToPage(page - 1)"
/>
<template v-for="(p, idx) in visiblePages" :key="idx">
<span
v-if="p === '...'"
class="px-1 text-sm text-m-muted"
aria-hidden="true"
></span>
<button
v-else
type="button"
class="h-8 min-w-[2rem] rounded px-2 text-sm transition-colors"
:class="p === page
? 'bg-m-btn-primary text-white font-semibold'
: 'text-m-text hover:bg-m-bg'"
:aria-current="p === page ? 'page' : undefined"
:data-test="`page-${p}`"
@click="goToPage(p)"
>
{{ p }}
</button>
</template>
<MalioButton
variant="tertiary"
label="Next"
:disabled="page >= totalPages"
button-class="h-8 w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante"
data-test="next-button"
@click="goToPage(page + 1)"
/>
</nav>
</div>
```
- [ ] **Step 2: Verify component renders without errors**
Run: `npm run test -- --run app/components/malio/datatable/DataTable.test.ts`
Expected: Existing tests still pass
---
### Task 4: Write pagination tests
**Files:**
- Modify: `app/components/malio/datatable/DataTable.test.ts`
- [ ] **Step 1: Add pagination test suite**
Add these test blocks to the existing test file:
```ts
describe('Pagination', () => {
it('hides pagination when totalItems is 0', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(false)
})
it('shows pagination when totalItems > 0', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
})
it('renders all pages when totalPages <= 5', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
for (let i = 1; i <= 5; i++) {
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
}
})
it('highlights current page', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
})
it('emits update:page on page button click', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
await wrapper.find('[data-test="page-3"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
})
it('Prev button is disabled on page 1', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
})
it('Next button is disabled on last page', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 5 })
expect(wrapper.find('[data-test="next-button"]').attributes('disabled')).toBeDefined()
})
it('Prev button emits update:page with page - 1', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
await wrapper.find('[data-test="prev-button"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
})
it('Next button emits update:page with page + 1', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
await wrapper.find('[data-test="next-button"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
})
it('shows ellipsis for truncated pages (> 5 pages)', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
expect(ellipsis.length).toBeGreaterThan(0)
expect(ellipsis[0].text()).toBe('…')
})
it('always shows first and last page when > 5 pages', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
})
it('shows 1 neighbor on each side of current page', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
})
it('pagination nav has aria-label', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
})
it('Prev button has aria-label "Page précédente"', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="prev-button"]').attributes('aria-label')).toBe('Page précédente')
})
it('Next button has aria-label "Page suivante"', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="next-button"]').attributes('aria-label')).toBe('Page suivante')
})
})
describe('Per-page selector', () => {
it('emits update:per-page and reset page to 1 on change', async () => {
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
const select = wrapper.findComponent({ name: 'MalioSelect' })
select.vm.$emit('update:modelValue', 25)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:per-page')?.[0]).toEqual([25])
expect(wrapper.emitted('update:page')?.[0]).toEqual([1])
})
})
```
- [ ] **Step 2: Run all tests**
Run: `npm run test -- --run app/components/malio/datatable/DataTable.test.ts`
Expected: All tests PASS
- [ ] **Step 3: Fix any failures and re-run**
---
### Task 5: Run full test suite + lint
- [ ] **Step 1: Run all project tests**
Run: `npm run test`
Expected: All tests pass
- [ ] **Step 2: Run lint**
Run: `npm run lint`
Expected: No errors
- [ ] **Step 3: Fix any issues and re-run**
---
### Task 6: Create playground page
**Files:**
- Create: `.playground/pages/composant/datatable/datatable.vue`
- [ ] **Step 1: Create playground page with demo variants**
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
const page = ref(1)
const perPage = ref(10)
const filtreNom = ref('')
const filtreVille = ref<string | number | null>(null)
const columns = [
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'ville', label: 'Ville' },
{ key: 'montant', label: 'Montant' },
]
const allItems = [
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
{ id: 13, nom: 'Roux', prenom: 'Hugo', ville: 'Paris', montant: 2800 },
{ id: 14, nom: 'David', prenom: 'Léa', ville: 'Lyon', montant: 670 },
{ id: 15, nom: 'Bertrand', prenom: 'Lucas', ville: 'Marseille', montant: 1950 },
]
const villeOptions = [
{ label: 'Paris', value: 'Paris' },
{ label: 'Lyon', value: 'Lyon' },
{ label: 'Marseille', value: 'Marseille' },
]
const filteredItems = computed(() => {
return allItems.filter((item) => {
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
if (filtreVille.value && item.ville !== filtreVille.value) return false
return true
})
})
const paginatedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return filteredItems.value.slice(start, start + perPage.value)
})
function onRowClick(item: Record<string, any>) {
alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`)
}
</script>
<template>
<div class="space-y-6">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">DataTable avec filtres et pagination</h2>
<MalioDataTable
:columns="columns"
:items="paginatedItems"
:total-items="filteredItems.length"
v-model:page="page"
v-model:per-page="perPage"
@row-click="onRowClick"
>
<template #header-nom>
<MalioInputText
v-model="filtreNom"
placeholder="Nom"
group-class="mt-0"
input-class="border-0 border-b border-m-border rounded-none bg-transparent px-0 text-sm"
label-class="hidden"
/>
</template>
<template #header-ville>
<MalioSelect
v-model="filtreVille"
:options="villeOptions"
empty-option-label="Ville"
min-width="w-full"
rounded="rounded-none"
text-field="text-sm"
text-value="text-sm"
/>
</template>
<template #cell-montant="{ item }">
<strong>{{ item.montant }} </strong>
</template>
</MalioDataTable>
</div>
</div>
</template>
```
- [ ] **Step 2: Verify page renders**
Run: `npm run dev` and navigate to `/composant/datatable/datatable`
---
### Task 7: Create Histoire story
**Files:**
- Create: `app/story/datatable/datatable.story.vue`
- [ ] **Step 1: Create story with variants and docs**
```vue
<template>
<Story title="Data/DataTable">
<Variant title="Avec filtres et pagination">
<div class="p-4">
<MalioDataTable
:columns="columns"
:items="paginatedItems"
:total-items="filteredItems.length"
v-model:page="page"
v-model:per-page="perPage"
@row-click="onRowClick"
>
<template #header-nom>
<MalioInputText
v-model="filtreNom"
placeholder="Nom"
group-class="mt-0"
input-class="border-0 border-b border-m-border rounded-none bg-transparent px-0 text-sm"
label-class="hidden"
/>
</template>
<template #header-ville>
<MalioSelect
v-model="filtreVille"
:options="villeOptions"
empty-option-label="Ville"
min-width="w-full"
rounded="rounded-none"
text-field="text-sm"
text-value="text-sm"
/>
</template>
<template #cell-montant="{ item }">
<strong>{{ item.montant }} </strong>
</template>
</MalioDataTable>
</div>
</Variant>
<Variant title="Sans filtres">
<div class="p-4">
<MalioDataTable
:columns="columnsSimple"
:items="simpleItems"
:total-items="simpleItems.length"
v-model:page="pageSimple"
v-model:per-page="perPageSimple"
/>
</div>
</Variant>
<Variant title="État vide">
<div class="p-4">
<MalioDataTable
:columns="columns"
:items="[]"
:total-items="0"
/>
</div>
</Variant>
<Variant title="Lignes non cliquables">
<div class="p-4">
<MalioDataTable
:columns="columnsSimple"
:items="simpleItems.slice(0, 3)"
:total-items="3"
:row-clickable="false"
/>
</div>
</Variant>
</Story>
</template>
<docs lang="md">
# MalioDataTable
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
## Props détaillées
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto-généré | Identifiant HTML |
| `columns` | `{ key: string, label: string }[]` | **requis** | Définition des colonnes |
| `items` | `Record<string, any>[]` | **requis** | Données à afficher |
| `totalItems` | `number` | **requis** | Total pour la pagination |
| `page` | `number` | `1` | Page courante (v-model) |
| `perPage` | `number` | `10` | Lignes par page (v-model) |
| `perPageOptions` | `number[]` | `[10, 25, 50]` | Options du sélecteur de lignes |
| `rowClickable` | `boolean` | `true` | Lignes cliquables |
| `tableClass` | `string` | `''` | Classes CSS sur le wrapper (twMerge) |
| `emptyMessage` | `string` | `'Aucune donnée'` | Message si items vide |
## Slots
| Slot | Scope | Description |
|------|-------|-------------|
| `#header-{key}` | `{ column }` | Filtre dans le `<th>` (placeholder = label). Fallback : texte du label |
| `#cell-{key}` | `{ item, column }` | Contenu du `<td>`. Fallback : `item[key]` |
| `#empty` | | Contenu état vide. Fallback : `emptyMessage` |
## Events
| Event | Payload | Description |
|-------|---------|-------------|
| `update:page` | `number` | Changement de page |
| `update:per-page` | `number` | Changement du nb de lignes (reset page à 1) |
| `row-click` | `Record<string, any>` | Clic sur une ligne |
## Pagination
- 5 pages : toutes affichées
- \> 5 pages : page 1 [voisin] **[courante]** [voisin] dernière
- Boutons Prev/Next toujours visibles, désactivés aux extrêmes
## Accessibilité
- `<th scope="col">` sur chaque en-tête
- `<nav aria-label="Pagination">` autour de la pagination
- Page courante avec `aria-current="page"`
- Lignes cliquables : `tabindex="0"` + Enter/Space
</docs>
<script setup lang="ts">
import { ref, computed } from 'vue'
import MalioDataTable from '../../components/malio/datatable/DataTable.vue'
import MalioInputText from '../../components/malio/input/InputText.vue'
import MalioSelect from '../../components/malio/select/Select.vue'
defineOptions({ name: 'DataTableStory' })
const columns = [
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'ville', label: 'Ville' },
{ key: 'montant', label: 'Montant' },
]
const columnsSimple = [
{ key: 'nom', label: 'Nom' },
{ key: 'ville', label: 'Ville' },
]
const allItems = [
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
]
const simpleItems = allItems.map(i => ({ nom: i.nom, ville: i.ville }))
const villeOptions = [
{ label: 'Paris', value: 'Paris' },
{ label: 'Lyon', value: 'Lyon' },
{ label: 'Marseille', value: 'Marseille' },
]
const page = ref(1)
const perPage = ref(5)
const filtreNom = ref('')
const filtreVille = ref<string | number | null>(null)
const pageSimple = ref(1)
const perPageSimple = ref(10)
const filteredItems = computed(() => {
return allItems.filter((item) => {
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
if (filtreVille.value && item.ville !== filtreVille.value) return false
return true
})
})
const paginatedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return filteredItems.value.slice(start, start + perPage.value)
})
function onRowClick(item: Record<string, any>) {
alert(`Clic sur ${item.nom} ${item.prenom}`)
}
</script>
```
- [ ] **Step 2: Verify story renders**
Run: `npm run story:dev`
---
### Task 8: Update CHANGELOG.md and COMPONENTS.md
**Files:**
- Modify: `CHANGELOG.md`
- Modify: `COMPONENTS.md`
- [ ] **Step 1: Add CHANGELOG entry**
Add to `### Added` section:
```
* [#MUI-22] Création d'un composant datatable
```
- [ ] **Step 2: Add COMPONENTS.md section**
Add a `## MalioDataTable` section after `## MalioDrawer` with the component documentation: props table, events, slots, pagination behavior, and 2 usage examples (with filters, simple).
- [ ] **Step 3: Commit all changes**
```bash
git add app/components/malio/datatable/ app/story/datatable/ .playground/pages/composant/datatable/ CHANGELOG.md COMPONENTS.md
git commit -m "feat(MUI-22): création du composant MalioDataTable"
```

View File

@@ -0,0 +1,192 @@
# MalioDataTable — Design Spec
Composant de tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
**Ticket :** MUI-22
**Branche :** `feature/MUI-22-developper-le-composant-datatable`
## Architecture
Composant unique `MalioDataTable` dans `app/components/malio/datatable/DataTable.vue`. Pas de décomposition — la pagination est intégrée dans le composant.
Le composant est **presentational** : il ne fait aucun fetch. Le parent fournit les données (`items`) et le total (`totalItems`), et réagit aux events de pagination/filtre pour relancer ses propres requêtes API.
## Props
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto-généré | Identifiant HTML du wrapper |
| `columns` | `Column[]` | **requis** | Définition des colonnes |
| `items` | `Record<string, any>[]` | **requis** | Données à afficher |
| `totalItems` | `number` | **requis** | Nombre total d'items (pour calculer le nb de pages) |
| `page` | `number` | `1` | Page courante, 1-based (v-model) |
| `perPage` | `number` | `10` | Nombre de lignes par page (v-model) |
| `perPageOptions` | `number[]` | `[10, 25, 50]` | Options du sélecteur de lignes |
| `rowClickable` | `boolean` | `true` | Rend les lignes cliquables (cursor pointer + hover) |
| `tableClass` | `string` | `''` | Classes CSS additionnelles sur `<table>` (twMerge) |
| `emptyMessage` | `string` | `'Aucune donnée'` | Message affiché quand `items` est vide |
### Type Column
```ts
type Column = {
key: string // Clé correspondant à item[key]
label: string // Texte affiché dans le <th> (fallback si pas de slot header)
}
```
## Events
| Event | Payload | Description |
|-------|---------|-------------|
| `update:page` | `number` | Changement de page (pagination ou Prev/Next) |
| `update:per-page` | `number` | Changement du nombre de lignes par page |
| `row-click` | `Record<string, any>` | Clic sur une ligne (l'item de la ligne) |
## Slots
| Slot | Scope | Description |
|------|-------|-------------|
| `#header-{key}` | `{ column }` | Contenu du `<th>` — filtre (input, select…). Si absent, affiche `column.label` en texte |
| `#cell-{key}` | `{ item, column }` | Contenu du `<td>`. Si absent, affiche `item[column.key]` en texte |
| `#empty` | — | Contenu affiché quand `items` est vide. Si absent, affiche `emptyMessage` |
## Structure HTML
```
<div :id="id"> ← wrapper
<table>
<thead>
<tr>
<th v-for="col" scope="col"> ← une seule ligne d'en-tête
slot #header-{key} ← filtre (placeholder = nom colonne)
OU label texte ← si pas de slot
</th>
</tr>
</thead>
<tbody>
<tr v-for="item" ← cliquable si rowClickable
tabindex="0" ← (si rowClickable) navigation clavier
@click="emit row-click"
@keydown.enter/space="emit row-click">
<td v-for="col">
slot #cell-{key} ← contenu custom
OU item[col.key] ← texte brut
</td>
</tr>
<tr v-if="!items.length"> ← état vide
<td :colspan="columns.length">
slot #empty OU emptyMessage
</td>
</tr>
</tbody>
</table>
<div v-if="totalItems > 0"> ← barre de pagination (masquée si aucune donnée)
<MalioSelect /> ← sélecteur nb lignes (options mappées depuis perPageOptions)
<nav aria-label="Pagination"> ← numéros de page + Prev/Next
<MalioButton variant="tertiary" label="Prev" /> ← disabled si page 1
<button> pour chaque numéro de page ← éléments <button>
<span aria-hidden="true">…</span> ← ellipsis
<MalioButton variant="tertiary" label="Next" /> ← disabled si dernière page
</nav>
</div>
</div>
```
## Logique de pagination (troncature)
### Règles
- **≤ 5 pages** : afficher toutes les pages, pas d'ellipsis
- **> 5 pages** : toujours afficher page 1 et dernière page, **1 voisin** de chaque côté de la page active, ellipsis `…` quand écart > 1
- **Prev** : `MalioButton variant="tertiary"`, toujours visible, `disabled` sur page 1
- **Next** : `MalioButton variant="tertiary"`, toujours visible, `disabled` sur dernière page
- **Changement de `perPage`** : émet automatiquement `update:page` avec `1` (reset à la première page)
- **`totalItems = 0`** : la barre de pagination est masquée entièrement
### Exemples
```
≤ 5 pages (toutes affichées) :
Page 1/3 : Prev(disabled) [1] 2 3 Next
Page 2/5 : Prev 1 [2] 3 4 5 Next
Page 5/5 : Prev 1 2 3 4 [5] Next(disabled)
> 5 pages (troncature 1 voisin) :
Page 1/20 : Prev(disabled) [1] 2 … 20 Next
Page 2/20 : Prev 1 [2] 3 … 20 Next
Page 3/20 : Prev 1 2 [3] 4 … 20 Next
Page 4/20 : Prev 1 … 3 [4] 5 … 20 Next
Page 7/20 : Prev 1 … 6 [7] 8 … 20 Next
Page 18/20 : Prev 1 … 17 [18] 19 20 Next
Page 19/20 : Prev 1 … 18 [19] 20 Next
Page 20/20 : Prev 1 … 19 [20] Next(disabled)
```
## En-têtes — logique du `<th>`
Chaque `<th>` vérifie si le slot `#header-{key}` est fourni :
- **Slot fourni** → rend le slot (le consommateur y met un `MalioInputText`, `MalioSelect`, etc. avec le placeholder qui sert de label de colonne)
- **Slot absent** → rend `column.label` en texte (`font-semibold text-m-primary`)
Pas de label séparé au-dessus du filtre. Le placeholder de l'input/select fait office de nom de colonne.
## Composants Malio utilisés en interne
- `MalioSelect` — sélecteur du nombre de lignes par page. Les `perPageOptions` sont mappés au format `{ label: string, value: number }[]` attendu par MalioSelect (ex: `{ label: '10', value: 10 }`)
- `MalioButton variant="tertiary"` — boutons Prev / Next
## Exemple d'utilisation consommateur
```vue
<MalioDataTable
:columns="[
{ key: 'nom', label: 'Nom' },
{ key: 'ville', label: 'Ville' },
{ key: 'montant', label: 'Montant' },
]"
:items="data"
:total-items="total"
v-model:page="page"
v-model:per-page="perPage"
@row-click="router.push(`/contact/${$event.id}`)"
>
<!-- Filtre texte placeholder sert de label -->
<template #header-nom>
<MalioInputText v-model="filtres.nom" placeholder="Nom" />
</template>
<!-- Filtre select placeholder sert de label -->
<template #header-ville>
<MalioSelect v-model="filtres.ville" :options="villes"
empty-option-label="Ville" />
</template>
<!-- Pas de slot header pour "montant" affiche "Montant" en texte -->
<!-- Cellule custom -->
<template #cell-montant="{ item }">
<strong>{{ item.montant }} </strong>
</template>
</MalioDataTable>
```
## Accessibilité
- `<table>` élément natif (sémantique table implicite)
- `<th scope="col">` sur chaque en-tête
- Pagination dans un `<nav aria-label="Pagination">`
- Numéros de page : éléments `<button>`, page courante avec `aria-current="page"`
- Ellipsis `…` : `<span aria-hidden="true">` (ignoré par les lecteurs d'écran)
- Boutons Prev/Next avec `aria-label` explicites ("Page précédente" / "Page suivante")
- Lignes cliquables : `tabindex="0"` + gestion `Enter`/`Space` pour navigation clavier (pas de `role="link"` — on garde la sémantique `<tr>` native)
## Styles
- En-têtes : `bg-m-surface`, label en `text-m-primary font-semibold`
- Bordures : `border-m-border`
- Lignes hover : `hover:bg-m-bg` (si `rowClickable`)
- Ligne cursor : `cursor-pointer` (si `rowClickable`)
- Page active : `bg-m-btn-primary text-white rounded`
- Boutons Prev/Next : `MalioButton variant="tertiary"`
- Message vide : `text-m-muted text-center`, `<td>` avec `colspan` sur toute la largeur

View File

@@ -6,6 +6,7 @@
"files": [
"app/**",
"nuxt.config.ts",
"tailwind.config.ts",
"README.md",
"COMPONENTS.md"
],

View File

@@ -1,12 +1,16 @@
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 {
content: [
'./app/**/*.{vue,js,ts}',
'./app/**/*.story.{vue,js,ts}',
'./.playground/**/*.{vue,js,ts}',
'./histoire.setup.ts',
'./histoire.config.ts',
join(dir, 'app/**/*.{vue,js,ts}'),
join(dir, 'app/**/*.story.{vue,js,ts}'),
join(dir, '.playground/**/*.{vue,js,ts}'),
join(dir, 'histoire.setup.ts'),
join(dir, 'histoire.config.ts'),
],
theme: {
extend: {
@@ -39,7 +43,7 @@ export default {
},
},
fontFamily: {
sans: ['"Inter"', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif'],
},
},
},