# 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 ``` - [ ] **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 ` ``` - [ ] **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 # 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 - `