# 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: '',
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
- ` | |