diff --git a/.playground/pages/composant/datatable/datatable.vue b/.playground/pages/composant/datatable/datatable.vue new file mode 100644 index 0000000..c833aab --- /dev/null +++ b/.playground/pages/composant/datatable/datatable.vue @@ -0,0 +1,92 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 223c25c..efdec6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-10] Création d'un composant bouton * [#MUI-2] Faire un MCP pour la librairie de composant * [#MUI-15] Création d'un composant drawer +* [#MUI-22] Création d'un composant datatable ### Changed diff --git a/COMPONENTS.md b/COMPONENTS.md index 770d20a..5cc0e72 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -384,3 +384,59 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transp

Drawer plus large

``` + +--- + +## 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[]` | **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 `` (twMerge) | +| `emptyMessage` | `string` | `'Aucune donnée'` | Message si items vide | + +**Events :** `update:page(value: number)`, `update:per-page(value: number)`, `row-click(item: Record)` +**Slots :** `#header-{key}` (filtre dans le `
`, placeholder = label), `#cell-{key}` (contenu du ``), `#empty` (état vide) + +```vue + + + + + + + + + +``` diff --git a/app/components/malio/datatable/DataTable.test.ts b/app/components/malio/datatable/DataTable.test.ts new file mode 100644 index 0000000..67098e4 --- /dev/null +++ b/app/components/malio/datatable/DataTable.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import { h } from 'vue' +import { mount } from '@vue/test-utils' +import type { DefineComponent } from 'vue' +import DataTable from './DataTable.vue' + +type DataTableProps = { + id?: string + columns?: { key: string; label: string }[] + items?: Record[] + totalItems?: number + page?: number + perPage?: number + perPageOptions?: number[] + rowClickable?: boolean + tableClass?: string + emptyMessage?: string +} + +const DataTableForTest = DataTable as DefineComponent + +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) { + return mount(DataTableForTest, { + props: { + columns: defaultColumns, + items: defaultItems, + totalItems: 3, + ...props, + }, + slots, + global: { + stubs: { + MalioSelect: { + name: 'MalioSelect', + template: '
', + props: ['modelValue', 'options'], + emits: ['update:modelValue'], + }, + MalioButton: { + template: '', + 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': '', + }) + 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 }) => 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: '

Vide

' }, + ) + 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]) + }) + }) +}) diff --git a/app/components/malio/datatable/DataTable.vue b/app/components/malio/datatable/DataTable.vue new file mode 100644 index 0000000..318ae7a --- /dev/null +++ b/app/components/malio/datatable/DataTable.vue @@ -0,0 +1,222 @@ + + + diff --git a/app/story/datatable/datatable.story.vue b/app/story/datatable/datatable.story.vue new file mode 100644 index 0000000..764753b --- /dev/null +++ b/app/story/datatable/datatable.story.vue @@ -0,0 +1,195 @@ + + + +# 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[]` | **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 `
` (placeholder = label). Fallback : texte du label | +| `#cell-{key}` | `{ item, column }` | Contenu du ``. 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` | 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é + +- `` sur chaque en-tête +- `