Compare commits
5 Commits
2df5aff1b7
..
v1.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
| e38255341d | |||
| 1bbe77d391 | |||
| ccc8410da0 | |||
| 82c4cfaa90 | |||
| 898bc0f761 |
@@ -1,92 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -82,6 +82,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="shortListValue"
|
||||||
|
:options="shortOptions"
|
||||||
|
label="Civilite"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4 md:col-span-2">
|
<div class="rounded-lg border p-4 md:col-span-2">
|
||||||
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -121,6 +131,11 @@ const options = [
|
|||||||
{label: 'Portugal', value: 'pt'},
|
{label: 'Portugal', value: 'pt'},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const shortOptions = [
|
||||||
|
{label: 'Monsieur', value: 'M'},
|
||||||
|
{label: 'Madame', value: 'Mme'},
|
||||||
|
]
|
||||||
|
|
||||||
const longOptions = [
|
const longOptions = [
|
||||||
...options,
|
...options,
|
||||||
{label: 'Pays-Bas', value: 'nl'},
|
{label: 'Pays-Bas', value: 'nl'},
|
||||||
@@ -144,6 +159,7 @@ const errorValue = ref<string | number | null>(null)
|
|||||||
const successValue = ref<string | number | null>('be')
|
const successValue = ref<string | number | null>('be')
|
||||||
const disabledValue = ref<string | number | null>('ca')
|
const disabledValue = ref<string | number | null>('ca')
|
||||||
const emptyValue = ref<string | number | null>(null)
|
const emptyValue = ref<string | number | null>(null)
|
||||||
|
const shortListValue = ref<string | number | null>(null)
|
||||||
const longListValue = ref<string | number | null>(null)
|
const longListValue = ref<string | number | null>(null)
|
||||||
const bottomValue = ref<string | number | null>(null)
|
const bottomValue = ref<string | number | null>(null)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-10] Création d'un composant bouton
|
* [#MUI-10] Création d'un composant bouton
|
||||||
* [#MUI-2] Faire un MCP pour la librairie de composant
|
* [#MUI-2] Faire un MCP pour la librairie de composant
|
||||||
* [#MUI-15] Création d'un composant drawer
|
* [#MUI-15] Création d'un composant drawer
|
||||||
* [#MUI-22] Création d'un composant datatable
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+11
-57
@@ -163,8 +163,17 @@ Liste déroulante.
|
|||||||
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options disponibles |
|
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options disponibles |
|
||||||
| `emptyOptionLabel` | `string` | `''` | Placeholder option vide |
|
| `emptyOptionLabel` | `string` | `''` | Placeholder option vide |
|
||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||||
|
| `minWidth` | `string` | `'w-96'` | Classe largeur minimum |
|
||||||
|
| `maxWidth` | `string` | `''` | Classe largeur maximum |
|
||||||
|
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
|
||||||
|
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
||||||
|
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
||||||
|
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string | number | null)`
|
**Events :** `update:modelValue(value: string | number | null)`
|
||||||
**Slots :** `icon` (icône dropdown custom)
|
**Slots :** `icon` (icône dropdown custom)
|
||||||
@@ -172,6 +181,7 @@ Liste déroulante.
|
|||||||
```vue
|
```vue
|
||||||
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
|
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
|
||||||
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
|
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
|
||||||
|
<MalioSelect v-model="civilite" label="Civilité" :options="civilites" group-class="mt-0" />
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -384,59 +394,3 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transp
|
|||||||
<p>Drawer plus large</p>
|
<p>Drawer plus large</p>
|
||||||
</MalioDrawer>
|
</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"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe('MalioCheckbox', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||||
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
||||||
expect(wrapper.get('p').text()).toBe('You must accept')
|
expect(wrapper.get('p').text()).toBe('You must accept')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ describe('MalioCheckbox', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('p').text()).toBe('Invalid')
|
expect(wrapper.get('p').text()).toBe('Invalid')
|
||||||
expect(wrapper.get('p').classes()).toContain('text-m-danger')
|
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows success styles and message when there is no error', () => {
|
it('shows success styles and message when there is no error', () => {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
twMerge(
|
twMerge(
|
||||||
'cbx text-black',
|
'cbx text-black',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||||
hasError.value ? 'text-m-danger' : '',
|
hasError.value ? 'text-m-error' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
@@ -120,7 +120,7 @@ const mergedMessageClass = computed(() =>
|
|||||||
twMerge(
|
twMerge(
|
||||||
'text-xs',
|
'text-xs',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-error'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
@@ -200,14 +200,14 @@ const onChange = (event: Event) => {
|
|||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inp-cbx + .cbx.text-m-danger span:first-child {
|
.inp-cbx + .cbx.text-m-error span:first-child {
|
||||||
border-color: rgb(var(--m-danger) / 1);
|
border-color: rgb(var(--m-error) / 1);
|
||||||
}
|
}
|
||||||
.cbx.text-m-danger span:first-child svg {
|
.cbx.text-m-error span:first-child svg {
|
||||||
stroke: rgb(var(--m-danger) / 1);
|
stroke: rgb(var(--m-error) / 1);
|
||||||
}
|
}
|
||||||
.inp-cbx:checked + .cbx.text-m-danger span:first-child {
|
.inp-cbx:checked + .cbx.text-m-error span:first-child {
|
||||||
border-color: rgb(var(--m-danger) / 1);
|
border-color: rgb(var(--m-error) / 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inp-cbx + .cbx.text-m-success span:first-child {
|
.inp-cbx + .cbx.text-m-success span:first-child {
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
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])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<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-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>
|
|
||||||
</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>
|
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<Transition
|
||||||
v-if="isOpen"
|
name="drawer"
|
||||||
:id="componentId"
|
appear
|
||||||
class="fixed inset-0 z-50"
|
@after-leave="isRendered = false"
|
||||||
v-bind="attrs"
|
|
||||||
>
|
>
|
||||||
<Transition name="drawer-backdrop">
|
<div
|
||||||
|
v-if="isRendered && isOpen"
|
||||||
|
:id="componentId"
|
||||||
|
class="fixed inset-0 z-50 flex justify-end"
|
||||||
|
v-bind="attrs"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
|
||||||
class="absolute inset-0 bg-black/40"
|
class="absolute inset-0 bg-black/40"
|
||||||
data-test="backdrop"
|
data-test="backdrop"
|
||||||
@click="close"
|
@click="close"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition name="drawer-panel">
|
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
|
||||||
:class="twMerge(
|
:class="twMerge(
|
||||||
'absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl',
|
'relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl',
|
||||||
drawerClass,
|
drawerClass,
|
||||||
)"
|
)"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -51,19 +51,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="overflow-y-auto px-5"
|
class="flex-1 overflow-y-auto px-5"
|
||||||
style="max-height: calc(100% - 96px)"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useAttrs, useId } from 'vue'
|
import { computed, ref, useAttrs, useId, watch } from 'vue'
|
||||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
@@ -103,6 +102,12 @@ const isOpen = computed(() =>
|
|||||||
isControlled.value ? props.modelValue! : localValue.value,
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isRendered = ref(isOpen.value)
|
||||||
|
|
||||||
|
watch(isOpen, (val) => {
|
||||||
|
if (val) isRendered.value = true
|
||||||
|
})
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (!isControlled.value) {
|
if (!isControlled.value) {
|
||||||
localValue.value = false
|
localValue.value = false
|
||||||
@@ -112,24 +117,23 @@ function close() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.drawer-backdrop-enter-active,
|
.drawer-enter-active,
|
||||||
.drawer-backdrop-leave-active {
|
.drawer-leave-active {
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-backdrop-enter-from,
|
.drawer-enter-active > div:last-child,
|
||||||
.drawer-backdrop-leave-to {
|
.drawer-leave-active > div:last-child {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-from,
|
||||||
|
.drawer-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-panel-enter-active,
|
.drawer-enter-from > div:last-child,
|
||||||
.drawer-panel-leave-active {
|
.drawer-leave-to > div:last-child {
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-panel-enter-from,
|
|
||||||
.drawer-panel-leave-to {
|
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,67 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
:class="mergedGroupClass"
|
<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"
|
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
:name="name"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:required="required"
|
||||||
|
:maxlength="maxLength"
|
||||||
|
:minlength="minLength"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="currentValue"
|
||||||
|
:readonly="readonly"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
v-bind="attrs"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="_"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="onBlur"
|
||||||
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
v-if="iconName"
|
v-if="iconName"
|
||||||
:icon="iconName"
|
:icon="iconName"
|
||||||
:width="iconSize"
|
:width="iconSize"
|
||||||
:height="iconSize"
|
:height="iconSize"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : iconColor,
|
|
||||||
iconPositionClass,
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success' : iconColor,
|
||||||
: 'text-m-muted',
|
iconPositionClass,
|
||||||
'mt-1 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',
|
||||||
|
'mt-1 text-xs ml-[2px] ',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -133,7 +135,7 @@ const isFilled = computed(() => currentValue.value.trim().length > 0)
|
|||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,68 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="mergedGroupClass" >
|
<div>
|
||||||
<label
|
<div :class="mergedGroupClass" >
|
||||||
v-if="label"
|
<label
|
||||||
:for="inputId"
|
v-if="label"
|
||||||
:class="mergedLabelClass"
|
:for="inputId"
|
||||||
>
|
:class="mergedLabelClass"
|
||||||
{{ label }}
|
>
|
||||||
</label>
|
{{ label }}
|
||||||
<button
|
</label>
|
||||||
type="button"
|
<button
|
||||||
:disabled="isMinusDisabled"
|
type="button"
|
||||||
@click="decrement"
|
:disabled="isMinusDisabled"
|
||||||
>
|
@click="decrement"
|
||||||
<IconifyIcon
|
>
|
||||||
icon="mdi:minus"
|
<IconifyIcon
|
||||||
:class="mergedButtonMinusClass"
|
icon="mdi:minus"
|
||||||
/>
|
:class="mergedButtonMinusClass"
|
||||||
</button>
|
/>
|
||||||
<input
|
</button>
|
||||||
:id="inputId"
|
<input
|
||||||
:name="name"
|
:id="inputId"
|
||||||
autocomplete="off"
|
:name="name"
|
||||||
:class="mergedInputClass"
|
autocomplete="off"
|
||||||
:style="inputWidthStyle"
|
:class="mergedInputClass"
|
||||||
:value="displayedValue"
|
:style="inputWidthStyle"
|
||||||
:required="required"
|
:value="displayedValue"
|
||||||
:disabled="disabled"
|
:required="required"
|
||||||
:readonly="readonly"
|
:disabled="disabled"
|
||||||
:aria-invalid="!!error"
|
:readonly="readonly"
|
||||||
:aria-describedby="describedBy"
|
:aria-invalid="!!error"
|
||||||
v-bind="attrs"
|
:aria-describedby="describedBy"
|
||||||
type="text"
|
v-bind="attrs"
|
||||||
inputmode="numeric"
|
type="text"
|
||||||
placeholder="_"
|
inputmode="numeric"
|
||||||
@input="onInput"
|
placeholder="_"
|
||||||
@focus="isFocused = true"
|
@input="onInput"
|
||||||
@blur="isFocused = false"
|
@focus="isFocused = true"
|
||||||
>
|
@blur="isFocused = false"
|
||||||
<button
|
>
|
||||||
type="button"
|
<button
|
||||||
:disabled="isPlusDisabled"
|
type="button"
|
||||||
@click="increment"
|
:disabled="isPlusDisabled"
|
||||||
>
|
@click="increment"
|
||||||
<IconifyIcon
|
>
|
||||||
icon="mdi:plus"
|
<IconifyIcon
|
||||||
:class="mergedButtonPlusClass"
|
icon="mdi:plus"
|
||||||
/>
|
:class="mergedButtonPlusClass"
|
||||||
</button>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success'
|
|
||||||
: 'text-m-muted',
|
|
||||||
'text-xs ml-[2px] ',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ hint || error || success }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -170,7 +172,7 @@ const isPlusDisabled = computed(() =>
|
|||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,67 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
:class="mergedGroupClass"
|
<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"
|
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
:name="name"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:required="required"
|
||||||
|
:maxlength="maxLength"
|
||||||
|
:minlength="minLength"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="currentValue"
|
||||||
|
:readonly="readonly"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
:type="isPasswordVisible ? 'text' : 'password'"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="isFocused = false"
|
||||||
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
v-if="displayIcon"
|
v-if="displayIcon"
|
||||||
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
|
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
|
||||||
:width="24"
|
:width="24"
|
||||||
:height="24"
|
:height="24"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : 'text-m-muted',
|
|
||||||
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
|
|
||||||
]"
|
|
||||||
@click="toggleVisibility"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success' : 'text-m-muted',
|
||||||
: 'text-m-muted',
|
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
|
||||||
]"
|
]"
|
||||||
>
|
@click="toggleVisibility"
|
||||||
{{ hint || error || success }}
|
/>
|
||||||
</p>
|
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'mt-1 text-xs ml-[2px] ',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -132,7 +134,7 @@ const hasSuccess = computed(() => !!props.success)
|
|||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,67 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
:class="mergedGroupClass"
|
<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"
|
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
v-maska="mask"
|
||||||
|
:name="name"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:required="required"
|
||||||
|
:maxlength="maxLength"
|
||||||
|
:minlength="minLength"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="currentValue"
|
||||||
|
:readonly="readonly"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
type="text"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="isFocused = false"
|
||||||
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
v-if="iconName"
|
v-if="iconName"
|
||||||
:icon="iconName"
|
:icon="iconName"
|
||||||
:width="iconSize"
|
:width="iconSize"
|
||||||
:height="iconSize"
|
:height="iconSize"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : iconColor,
|
|
||||||
iconPositionClass,
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success' : iconColor,
|
||||||
: 'text-m-muted',
|
iconPositionClass,
|
||||||
'mt-1 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',
|
||||||
|
'mt-1 text-xs ml-[2px] ',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -138,7 +140,7 @@ const hasSuccess = computed(() => !!props.success)
|
|||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,70 +1,72 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
:class="mergedGroupClass"
|
<div
|
||||||
>
|
:class="mergedGroupClass"
|
||||||
<input
|
|
||||||
ref="fileInputRef"
|
|
||||||
type="file"
|
|
||||||
:accept="accept"
|
|
||||||
class="hidden"
|
|
||||||
:disabled="disabled"
|
|
||||||
@change="onFileChange"
|
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="onFileChange"
|
||||||
|
>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
:class="mergedInputClass"
|
:class="mergedInputClass"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:value="currentDisplayValue"
|
:value="currentDisplayValue"
|
||||||
:readonly="true"
|
:readonly="true"
|
||||||
:aria-invalid="!!error"
|
:aria-invalid="!!error"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
placeholder="_"
|
placeholder="_"
|
||||||
type="text"
|
type="text"
|
||||||
@click="openFilePicker"
|
@click="openFilePicker"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
v-if="displayIcon"
|
v-if="displayIcon"
|
||||||
icon="mdi:cloud-arrow-up-outline"
|
icon="mdi:cloud-arrow-up-outline"
|
||||||
:width="24"
|
:width="24"
|
||||||
:height="24"
|
:height="24"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : 'text-m-muted',
|
|
||||||
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success' : 'text-m-muted',
|
||||||
: 'text-m-muted',
|
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||||
'mt-1 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',
|
||||||
|
'mt-1 text-xs ml-[2px] ',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -121,7 +123,7 @@ const hasSuccess = computed(() => !!props.success)
|
|||||||
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
|
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
ref="root"
|
<div
|
||||||
class="relative mt-4 w-full"
|
ref="root"
|
||||||
:class="[minWidth, maxWidth]"
|
:class="mergedGroupClass"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:id="buttonId"
|
:id="buttonId"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -134,26 +134,28 @@
|
|||||||
{{ opt.label || '\u00A0' }}
|
{{ opt.label || '\u00A0' }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${buttonId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ error || success || hint }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${buttonId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success'
|
|
||||||
: 'text-m-muted',
|
|
||||||
'mt-1 ml-[2px] text-xs',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ error || success || hint }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -176,6 +178,7 @@ const props = withDefaults(defineProps<{
|
|||||||
textLabel?: string
|
textLabel?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
groupClass?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
options: () => [],
|
options: () => [],
|
||||||
emptyOptionLabel: '',
|
emptyOptionLabel: '',
|
||||||
@@ -190,6 +193,7 @@ const props = withDefaults(defineProps<{
|
|||||||
textLabel: 'text-sm',
|
textLabel: 'text-sm',
|
||||||
rounded: 'rounded-md',
|
rounded: 'rounded-md',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
groupClass: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -208,6 +212,9 @@ const normalizedOptions = computed<Option[]>(() => [
|
|||||||
{label: props.emptyOptionLabel, value: null},
|
{label: props.emptyOptionLabel, value: null},
|
||||||
...props.options,
|
...props.options,
|
||||||
])
|
])
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge('relative w-full', props.minWidth, props.maxWidth, props.groupClass),
|
||||||
|
)
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
const isOptionSelected = computed(() =>
|
const isOptionSelected = computed(() =>
|
||||||
@@ -315,7 +322,6 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|||||||
|
|
||||||
:deep(ul[role="listbox"]) {
|
:deep(ul[role="listbox"]) {
|
||||||
scrollbar-width: auto;
|
scrollbar-width: auto;
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.select-scrollbar-primary) {
|
:deep(.select-scrollbar-primary) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
ref="root"
|
<div
|
||||||
class="relative mt-4 w-full"
|
ref="root"
|
||||||
:class="[minWidth, maxWidth]"
|
class="relative w-full"
|
||||||
>
|
:class="[minWidth, maxWidth]"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
:id="buttonId"
|
:id="buttonId"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -184,21 +185,22 @@
|
|||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${buttonId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ error || success || hint }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${buttonId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success'
|
|
||||||
: 'text-m-muted',
|
|
||||||
'mt-1 ml-[2px] text-xs',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ error || success || hint }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,966 +0,0 @@
|
|||||||
# 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"
|
|
||||||
```
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# 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
|
|
||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"app/**",
|
"app/**",
|
||||||
"nuxt.config.ts",
|
"nuxt.config.ts",
|
||||||
"README.md"
|
"README.md",
|
||||||
|
"COMPONENTS.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxi dev .playground",
|
"dev": "nuxi dev .playground",
|
||||||
|
|||||||
Reference in New Issue
Block a user