Files
malio-layer-ui/docs/superpowers/plans/2026-03-24-datatable.md
tristan 6720e3062a
All checks were successful
Release / release (push) Successful in 1m5s
feat: ajout du composant datatable (#28)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #28
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-16 07:06:50 +00:00

30 KiB

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.

<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

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>:

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>:

<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:

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

<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

<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
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"