Files
malio-layer-ui/docs/superpowers/plans/2026-03-20-tab-list.md
tristan 82c4cfaa90
All checks were successful
Release / release (push) Successful in 1m14s
feat: Ajout de composant (#23)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] 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: #23
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-26 07:40:04 +00:00

16 KiB

TabList 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: Créer un composant MalioTabList — barre d'onglets horizontale avec icônes, gestion show/hide des panneaux via slots nommés, pattern contrôlé/non-contrôlé.

Architecture: Composant unique TabList.vue dans app/components/malio/tab/. Props tabs (tableau {key, label, icon?}) + modelValue (clé active). Slots nommés par tab.key pour le contenu des panneaux. Couleur active text-m-primary, inactif text-m-primary/50 (50% opacité). Bordure active border-m-primary, bordure commune border-m-border.

Tech Stack: Vue 3 Composition API, TypeScript, Tailwind CSS, @iconify/vue, Vitest + @vue/test-utils

Ticket: MUI-11


File Structure

Fichier Responsabilité
app/components/malio/tab/TabList.vue Composant principal
app/components/malio/tab/TabList.test.ts Tests unitaires
.playground/pages/composant/tab/tabList.vue Page playground
app/story/tab/tabList.story.vue Story Histoire
CHANGELOG.md Ajout ligne MUI-11

Task 1: Créer le composant TabList.vue

Files:

  • Create: app/components/malio/tab/TabList.vue

  • Step 1: Créer le fichier composant

<template>
  <div>
    <div
      role="tablist"
      class="flex border-b border-m-border"
    >
      <button
        v-for="tab in tabs"
        :id="`${prefix}-tab-${tab.key}`"
        :key="tab.key"
        role="tab"
        type="button"
        :aria-selected="activeTab === tab.key"
        :aria-controls="`${prefix}-panel-${tab.key}`"
        :tabindex="activeTab === tab.key ? 0 : -1"
        class="flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px cursor-pointer"
        :class="activeTab === tab.key
          ? 'border-m-primary text-m-primary font-bold'
          : 'border-transparent text-m-primary/50 hover:text-m-primary/70'"
        @click="selectTab(tab.key)"
      >
        <IconifyIcon
          v-if="tab.icon"
          :icon="tab.icon"
          width="20"
        />
        {{ tab.label }}
      </button>
    </div>

    <div
      v-for="tab in tabs"
      :id="`${prefix}-panel-${tab.key}`"
      :key="tab.key"
      v-show="activeTab === tab.key"
      role="tabpanel"
      :aria-labelledby="`${prefix}-tab-${tab.key}`"
    >
      <slot :name="tab.key" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, useId } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'

defineOptions({ name: 'MalioTabList', inheritAttrs: false })

export type Tab = {
  key: string
  label: string
  icon?: string
}

const props = withDefaults(defineProps<{
  tabs: Tab[]
  modelValue?: string
  id?: string
}>(), {
  modelValue: undefined,
  id: undefined,
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()

const generatedId = useId()
const prefix = computed(() => props.id ?? `malio-tab-list-${generatedId}`)

const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(props.tabs[0]?.key)

const activeTab = computed(() =>
  isControlled.value ? props.modelValue : localValue.value,
)

function selectTab(key: string) {
  if (isControlled.value) {
    emit('update:modelValue', key)
  } else {
    localValue.value = key
  }
}
</script>
  • Step 2: Vérifier que le fichier compile

Run: npm run dev:prepare Expected: pas d'erreur


Task 2: Créer les tests TabList.test.ts

Files:

  • Create: app/components/malio/tab/TabList.test.ts

  • Step 1: Écrire les tests

import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import TabList from './TabList.vue'

type Tab = {
  key: string
  label: string
  icon?: string
}

type TabListProps = {
  tabs: Tab[]
  modelValue?: string
  id?: string
}

const TabListForTest = TabList as DefineComponent<TabListProps>

const tabs: Tab[] = [
  { key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' },
  { key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
  { key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
]

const mountComponent = (props: TabListProps, slots?: Record<string, string>) =>
  mount(TabListForTest, {
    props,
    slots,
    global: {
      stubs: {
        IconifyIcon: {
          template: '<span data-test="icon" v-bind="$attrs" />',
        },
      },
    },
  })

describe('MalioTabList', () => {
  it('renders all tab buttons', () => {
    const wrapper = mountComponent({ tabs })
    const buttons = wrapper.findAll('[role="tab"]')
    expect(buttons).toHaveLength(3)
    expect(buttons[0].text()).toContain('Qualimat')
    expect(buttons[1].text()).toContain('Adresses')
    expect(buttons[2].text()).toContain('Contacts')
  })

  it('renders icons for tabs that have one', () => {
    const wrapper = mountComponent({ tabs })
    const icons = wrapper.findAll('[data-test="icon"]')
    expect(icons).toHaveLength(3)
  })

  it('does not render icon when tab has no icon', () => {
    const tabsNoIcon: Tab[] = [
      { key: 'a', label: 'A' },
      { key: 'b', label: 'B' },
    ]
    const wrapper = mountComponent({ tabs: tabsNoIcon })
    const icons = wrapper.findAll('[data-test="icon"]')
    expect(icons).toHaveLength(0)
  })

  it('first tab is active by default in uncontrolled mode', () => {
    const wrapper = mountComponent({ tabs })
    const firstTab = wrapper.findAll('[role="tab"]')[0]
    expect(firstTab.attributes('aria-selected')).toBe('true')
  })

  it('shows the panel content for the active tab', () => {
    const wrapper = mountComponent(
      { tabs },
      { qualimat: '<p>Contenu Qualimat</p>', adresses: '<p>Contenu Adresses</p>' },
    )
    const panels = wrapper.findAll('[role="tabpanel"]')
    const qualimatPanel = panels.find(p => p.attributes('aria-labelledby')?.includes('qualimat'))
    const adressesPanel = panels.find(p => p.attributes('aria-labelledby')?.includes('adresses'))
    expect(qualimatPanel?.isVisible()).toBe(true)
    expect(adressesPanel?.isVisible()).toBe(false)
  })

  it('switches tab on click in uncontrolled mode', async () => {
    const wrapper = mountComponent(
      { tabs },
      { qualimat: '<p>Contenu Q</p>', adresses: '<p>Contenu A</p>' },
    )
    const tabButtons = wrapper.findAll('[role="tab"]')
    await tabButtons[1].trigger('click')

    const panels = wrapper.findAll('[role="tabpanel"]')
    const qualimatPanel = panels.find(p => p.attributes('aria-labelledby')?.includes('qualimat'))
    const adressesPanel = panels.find(p => p.attributes('aria-labelledby')?.includes('adresses'))
    expect(qualimatPanel?.isVisible()).toBe(false)
    expect(adressesPanel?.isVisible()).toBe(true)
  })

  it('emits update:modelValue on click in controlled mode', async () => {
    const wrapper = mountComponent({ tabs, modelValue: 'qualimat' })
    const tabButtons = wrapper.findAll('[role="tab"]')
    await tabButtons[1].trigger('click')

    expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['adresses'])
  })

  it('respects modelValue for active tab in controlled mode', () => {
    const wrapper = mountComponent({ tabs, modelValue: 'adresses' })
    const tabButtons = wrapper.findAll('[role="tab"]')
    expect(tabButtons[1].attributes('aria-selected')).toBe('true')
    expect(tabButtons[0].attributes('aria-selected')).toBe('false')
  })

  it('sets correct aria-controls and aria-labelledby', () => {
    const wrapper = mountComponent({ tabs, id: 'test' })
    const firstTab = wrapper.findAll('[role="tab"]')[0]
    const firstPanel = wrapper.findAll('[role="tabpanel"]')[0]
    expect(firstTab.attributes('aria-controls')).toBe('test-panel-qualimat')
    expect(firstPanel.attributes('aria-labelledby')).toBe('test-tab-qualimat')
    expect(firstPanel.attributes('id')).toBe('test-panel-qualimat')
  })

  it('has role="tablist" on the tab container', () => {
    const wrapper = mountComponent({ tabs })
    expect(wrapper.find('[role="tablist"]').exists()).toBe(true)
  })

  it('active tab has tabindex 0, others have -1', () => {
    const wrapper = mountComponent({ tabs, modelValue: 'adresses' })
    const tabButtons = wrapper.findAll('[role="tab"]')
    expect(tabButtons[0].attributes('tabindex')).toBe('-1')
    expect(tabButtons[1].attributes('tabindex')).toBe('0')
    expect(tabButtons[2].attributes('tabindex')).toBe('-1')
  })

  it('renders icon props correctly via findComponent', () => {
    const wrapper = mount(TabListForTest, {
      props: { tabs },
    })
    const icons = wrapper.findAllComponents(IconifyIcon)
    expect(icons[0].props('icon')).toBe('mdi:certificate-outline')
  })
})
  • Step 2: Lancer les tests

Run: npm run test Expected: tous les tests passent

  • Step 3: Lancer le lint

Run: npm run lint Expected: pas d'erreur


Task 3: Créer la page playground

Files:

  • Create: .playground/pages/composant/tab/tabList.vue

  • Step 1: Créer la page

<template>
  <div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
    <div class="rounded-lg border p-4">
      <h2 class="mb-4 text-xl font-bold">Simple</h2>
      <MalioTabList v-model="simpleValue" :tabs="tabs">
        <template #qualimat><p class="p-4">Contenu Qualimat</p></template>
        <template #adresses><p class="p-4">Contenu Adresses</p></template>
        <template #contacts><p class="p-4">Contenu Contacts</p></template>
        <template #comptabilite><p class="p-4">Contenu Comptabilité</p></template>
      </MalioTabList>
    </div>

    <div class="rounded-lg border p-4">
      <h2 class="mb-4 text-xl font-bold">Sans icônes</h2>
      <MalioTabList v-model="noIconValue" :tabs="tabsNoIcon">
        <template #tab1><p class="p-4">Contenu onglet 1</p></template>
        <template #tab2><p class="p-4">Contenu onglet 2</p></template>
        <template #tab3><p class="p-4">Contenu onglet 3</p></template>
      </MalioTabList>
    </div>

    <div class="rounded-lg border p-4">
      <h2 class="mb-4 text-xl font-bold">Non contrôlé (sans v-model)</h2>
      <MalioTabList :tabs="tabs">
        <template #qualimat><p class="p-4">Contenu Qualimat</p></template>
        <template #adresses><p class="p-4">Contenu Adresses</p></template>
        <template #contacts><p class="p-4">Contenu Contacts</p></template>
        <template #comptabilite><p class="p-4">Contenu Comptabilité</p></template>
      </MalioTabList>
    </div>

    <div class="rounded-lg border p-4">
      <h2 class="mb-4 text-xl font-bold">Deux onglets</h2>
      <MalioTabList v-model="twoTabValue" :tabs="tabsTwo">
        <template #general><p class="p-4">Informations générales</p></template>
        <template #details><p class="p-4">Détails avancés</p></template>
      </MalioTabList>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const tabs = [
  { key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' },
  { key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
  { key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
  { key: 'comptabilite', label: 'Comptabilité', icon: 'mdi:web' },
]

const tabsNoIcon = [
  { key: 'tab1', label: 'Onglet 1' },
  { key: 'tab2', label: 'Onglet 2' },
  { key: 'tab3', label: 'Onglet 3' },
]

const tabsTwo = [
  { key: 'general', label: 'Général', icon: 'mdi:information-outline' },
  { key: 'details', label: 'Détails', icon: 'mdi:cog-outline' },
]

const simpleValue = ref('qualimat')
const noIconValue = ref('tab1')
const twoTabValue = ref('general')
</script>

Task 4: Créer la story Histoire

Files:

  • Create: app/story/tab/tabList.story.vue

  • Step 1: Créer la story

<template>
  <Story title="Tab/List">
    <div class="grid grid-cols-1 gap-6">
      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Avec icônes</h2>
        <MalioTabList v-model="withIcons" :tabs="tabs">
          <template #qualimat><p class="p-4">Contenu onglet Qualimat</p></template>
          <template #adresses><p class="p-4">Contenu onglet Adresses</p></template>
          <template #contacts><p class="p-4">Contenu onglet Contacts</p></template>
          <template #comptabilite><p class="p-4">Contenu onglet Comptabilité</p></template>
        </MalioTabList>
      </div>

      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Sans icônes</h2>
        <MalioTabList v-model="withoutIcons" :tabs="tabsNoIcon">
          <template #tab1><p class="p-4">Contenu onglet 1</p></template>
          <template #tab2><p class="p-4">Contenu onglet 2</p></template>
        </MalioTabList>
      </div>

      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Deuxième onglet actif par défaut</h2>
        <MalioTabList v-model="secondActive" :tabs="tabs">
          <template #qualimat><p class="p-4">Contenu Qualimat</p></template>
          <template #adresses><p class="p-4">Contenu Adresses (actif par défaut)</p></template>
          <template #contacts><p class="p-4">Contenu Contacts</p></template>
          <template #comptabilite><p class="p-4">Contenu Comptabilité</p></template>
        </MalioTabList>
      </div>
    </div>
  </Story>
</template>

<docs lang="md">
# MalioTabList

Navigation par onglets avec icônes optionnelles et gestion show/hide des panneaux via slots nommés.

---

## Props détaillées

### tabs

- Type: `Array<{ key: string; label: string; icon?: string }>`
- Requis: oui
- Description: Définit les onglets. Chaque entrée correspond à un slot nommé par `key`.

### modelValue

- Type: `string`
- Description: Clé de l'onglet actif. Sans v-model, le premier onglet est actif par défaut (mode non contrôlé).

### id

- Type: `string`
- Description: Préfixe pour les IDs d'accessibilité. Auto-généré si absent.

---

## Slots

Un slot nommé par `tab.key` pour chaque onglet. Le contenu du slot est affiché/masqué automatiquement.

```html
<MalioTabList :tabs="[{ key: 'foo', label: 'Foo' }]">
  <template #foo>Contenu de Foo</template>
</MalioTabList>

Accessibilité

  • role="tablist" sur le conteneur
  • role="tab" avec aria-selected, aria-controls, tabindex sur chaque bouton
  • role="tabpanel" avec aria-labelledby sur chaque panneau

Events

update:modelValue

  • Émis au clic sur un onglet
  • Retourne la clé (string) de l'onglet sélectionné
<script setup lang="ts"> import { ref } from 'vue' import MalioTabList from '../../components/malio/tab/TabList.vue' const tabs = [ { key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' }, { key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' }, { key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' }, { key: 'comptabilite', label: 'Comptabilité', icon: 'mdi:web' }, ] const tabsNoIcon = [ { key: 'tab1', label: 'Onglet 1' }, { key: 'tab2', label: 'Onglet 2' }, ] const withIcons = ref('qualimat') const withoutIcons = ref('tab1') const secondActive = ref('adresses') </script>

---

### Task 5: Mettre à jour le CHANGELOG

**Files:**
- Modify: `CHANGELOG.md:9`

- [ ] **Step 1: Ajouter la ligne**

Ajouter après la dernière entrée `### Added` :
  • [#MUI-11] Création d'un composant navigation par onglets

- [ ] **Step 2: Commit**

```bash
git add app/components/malio/tab/TabList.vue app/components/malio/tab/TabList.test.ts .playground/pages/composant/tab/tabList.vue app/story/tab/tabList.story.vue CHANGELOG.md
git commit -m "feat: [#MUI-11] création du composant TabList"