# 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** ```vue ``` - [ ] **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** ```ts 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 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) => mount(TabListForTest, { props, slots, global: { stubs: { IconifyIcon: { template: '', }, }, }, }) 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: '

Contenu Qualimat

', adresses: '

Contenu Adresses

' }, ) 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: '

Contenu Q

', adresses: '

Contenu A

' }, ) 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** ```vue ``` --- ### Task 4: Créer la story Histoire **Files:** - Create: `app/story/tab/tabList.story.vue` - [ ] **Step 1: Créer la story** ```vue # 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 ``` --- ## 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é ``` --- ### 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" ```