)`
+**Slots :** `#header-{key}` (filtre dans le ``, placeholder = label), `#cell-{key}` (contenu du ` `), `#empty` (état vide)
+
+```vue
+
+
+
+
+
+
+
+ Ville
+ {{ v }}
+
+
+
+ {{ item.nom }}
+
+
+
+
+
+```
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: '{{ label }} ',
+ 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 @@
+
+
+
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+ {{ item[col.key] }}
+
+
+
+
+ {{ emptyMessage }}
+
+
+
+
+
+
+
+ Lignes :
+
+
+
+
+
+
+
+ …
+
+ {{ p }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ Ville
+ Paris
+ Lyon
+ Marseille
+
+
+
+ {{ item.montant }} €
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# 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
+- `` autour de la pagination
+- Page courante avec `aria-current="page"`
+- Lignes cliquables : `tabindex="0"` + Enter/Space
+
+
+
diff --git a/docs/superpowers/plans/2026-03-24-datatable.md b/docs/superpowers/plans/2026-03-24-datatable.md
new file mode 100644
index 0000000..f23b6fd
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-24-datatable.md
@@ -0,0 +1,966 @@
+# MalioDataTable Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Create a presentational data table component with pagination, slot-based column filters, and clickable rows.
+
+**Architecture:** Single component `MalioDataTable` in `app/components/malio/datatable/DataTable.vue`. Uses `MalioSelect` internally for the per-page selector and `MalioButton variant="tertiary"` for Prev/Next pagination buttons. All data is provided by the parent via props; the component emits events for page/perPage changes and row clicks.
+
+**Tech Stack:** Vue 3 Composition API, TypeScript, Tailwind CSS, tailwind-merge, Vitest + @vue/test-utils
+
+**Spec:** `docs/superpowers/specs/2026-03-24-datatable-design.md`
+
+**Skill:** Follow `creating-malio-component` workflow (component → tests → playground → story → CHANGELOG → COMPONENTS.md)
+
+---
+
+## File Map
+
+| File | Action | Responsibility |
+|------|--------|---------------|
+| `app/components/malio/datatable/DataTable.vue` | Create | Main component |
+| `app/components/malio/datatable/DataTable.test.ts` | Create | Unit tests |
+| `.playground/pages/composant/datatable/datatable.vue` | Create | Playground page |
+| `app/story/datatable/datatable.story.vue` | Create | Histoire story + docs |
+| `CHANGELOG.md` | Modify | Add entry |
+| `COMPONENTS.md` | Modify | Add documentation |
+
+---
+
+### Task 1: Write DataTable component — table rendering (no pagination yet)
+
+**Files:**
+- Create: `app/components/malio/datatable/DataTable.vue`
+
+- [ ] **Step 1: Create the component with table rendering only**
+
+The component renders a `` with `` and ` `. No pagination yet — just the table structure, columns, items, slots, and row click.
+
+```vue
+
+
+
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+ {{ item[col.key] }}
+
+
+
+
+ {{ emptyMessage }}
+
+
+
+
+
+
+
+
+```
+
+- [ ] **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[]
+ 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: {
+ template: '
',
+ props: ['modelValue', 'options'],
+ },
+ MalioButton: {
+ template: '{{ label }} ',
+ 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': ' ',
+ })
+ 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) => `${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')
+ })
+ })
+})
+```
+
+- [ ] **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 `
+
+
+
+
+
DataTable avec filtres et pagination
+
+
+
+
+
+
+
+
+
+
+ {{ item.montant }} €
+
+
+
+
+
+```
+
+- [ ] **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
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.montant }} €
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# 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
+- `` autour de la pagination
+- Page courante avec `aria-current="page"`
+- Lignes cliquables : `tabindex="0"` + Enter/Space
+
+
+
+```
+
+- [ ] **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"
+```
diff --git a/docs/superpowers/specs/2026-03-24-datatable-design.md b/docs/superpowers/specs/2026-03-24-datatable-design.md
new file mode 100644
index 0000000..db25b8d
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-24-datatable-design.md
@@ -0,0 +1,192 @@
+# MalioDataTable — Design Spec
+
+Composant de tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
+
+**Ticket :** MUI-22
+**Branche :** `feature/MUI-22-developper-le-composant-datatable`
+
+## Architecture
+
+Composant unique `MalioDataTable` dans `app/components/malio/datatable/DataTable.vue`. Pas de décomposition — la pagination est intégrée dans le composant.
+
+Le composant est **presentational** : il ne fait aucun fetch. Le parent fournit les données (`items`) et le total (`totalItems`), et réagit aux events de pagination/filtre pour relancer ses propres requêtes API.
+
+## Props
+
+| Prop | Type | Défaut | Description |
+|------|------|--------|-------------|
+| `id` | `string` | auto-généré | Identifiant HTML du wrapper |
+| `columns` | `Column[]` | **requis** | Définition des colonnes |
+| `items` | `Record[]` | **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 `` (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 (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` | Clic sur une ligne (l'item de la ligne) |
+
+## Slots
+
+| Slot | Scope | Description |
+|------|-------|-------------|
+| `#header-{key}` | `{ column }` | Contenu du `` — filtre (input, select…). Si absent, affiche `column.label` en texte |
+| `#cell-{key}` | `{ item, column }` | Contenu du ` `. Si absent, affiche `item[column.key]` en texte |
+| `#empty` | — | Contenu affiché quand `items` est vide. Si absent, affiche `emptyMessage` |
+
+## Structure HTML
+
+```
+ ← wrapper
+
+
+
+ ← une seule ligne d'en-tête
+ slot #header-{key} ← filtre (placeholder = nom colonne)
+ OU label texte ← si pas de slot
+
+
+
+
+
+
+ slot #cell-{key} ← contenu custom
+ OU item[col.key] ← texte brut
+
+
+ ← état vide
+
+ slot #empty OU emptyMessage
+
+
+
+
+
← barre de pagination (masquée si aucune donnée)
+ ← sélecteur nb lignes (options mappées depuis perPageOptions)
+ ← numéros de page + Prev/Next
+ ← disabled si page 1
+ pour chaque numéro de page ← éléments
+ … ← ellipsis
+ ← disabled si dernière page
+
+
+
+```
+
+## 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 ` `
+
+Chaque ` ` 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.montant }} €
+
+
+```
+
+## Accessibilité
+
+- `` élément natif (sémantique table implicite)
+- `` sur chaque en-tête
+- Pagination dans un ``
+- Numéros de page : éléments ``, page courante avec `aria-current="page"`
+- Ellipsis `…` : `` (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 `` 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`, `` avec `colspan` sur toute la largeur