diff --git a/.playground/pages/composant/tab/tabList.vue b/.playground/pages/composant/tab/tabList.vue new file mode 100644 index 0000000..f48d61f --- /dev/null +++ b/.playground/pages/composant/tab/tabList.vue @@ -0,0 +1,66 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index f89f059..46ccaa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-8] Création d'un composant mot de passe * [#MUI-9] Création d'un composant upload * [#MUI-14] Création d'un composant bouton icône +* [#MUI-11] Création d'un composant navigation par onglets ### Changed diff --git a/app/components/malio/tab/TabList.test.ts b/app/components/malio/tab/TabList.test.ts new file mode 100644 index 0000000..cbefe27 --- /dev/null +++ b/app/components/malio/tab/TabList.test.ts @@ -0,0 +1,137 @@ +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: 'home', label: 'Accueil', icon: 'mdi:home'}, + {key: 'settings', label: 'Paramètres'}, + {key: 'profile', label: 'Profil', icon: 'mdi:account'}, +] + +function mountComponent(props: TabListProps, slots?: Record) { + return mount(TabListForTest, { + props, + slots, + }) +} + +describe('MalioTabList', () => { + it('renders all tab buttons with correct labels', () => { + const wrapper = mountComponent({tabs}) + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons).toHaveLength(3) + expect(buttons[0].text()).toContain('Accueil') + expect(buttons[1].text()).toContain('Paramètres') + expect(buttons[2].text()).toContain('Profil') + }) + + it('renders icons for tabs that have one', () => { + const wrapper = mountComponent({tabs}) + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons[0].find('svg').exists()).toBe(true) + expect(buttons[2].find('svg').exists()).toBe(true) + }) + + it('does not render icon when tab has no icon', () => { + const wrapper = mountComponent({tabs}) + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons[1].find('svg').exists()).toBe(false) + }) + + it('first tab is active by default in uncontrolled mode', () => { + const wrapper = mountComponent({tabs}) + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons[0].attributes('aria-selected')).toBe('true') + expect(buttons[1].attributes('aria-selected')).toBe('false') + expect(buttons[2].attributes('aria-selected')).toBe('false') + }) + + it('shows the panel content for the active tab (v-show)', () => { + const wrapper = mountComponent({tabs}, { + home: '

Home content

', + settings: '

Settings content

', + profile: '

Profile content

', + }) + const panels = wrapper.findAll('[role="tabpanel"]') + expect(panels[0].attributes('style')).toBeUndefined() + expect(panels[1].attributes('style')).toContain('display: none') + expect(panels[2].attributes('style')).toContain('display: none') + }) + + it('switches tab on click in uncontrolled mode', async () => { + const wrapper = mountComponent({tabs}) + const buttons = wrapper.findAll('[role="tab"]') + + await buttons[1].trigger('click') + + expect(buttons[1].attributes('aria-selected')).toBe('true') + expect(buttons[0].attributes('aria-selected')).toBe('false') + }) + + it('emits update:modelValue on click in controlled mode', async () => { + const wrapper = mountComponent({tabs, modelValue: 'home'}) + const buttons = wrapper.findAll('[role="tab"]') + + await buttons[2].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['profile']) + }) + + it('respects modelValue for active tab in controlled mode', () => { + const wrapper = mountComponent({tabs, modelValue: 'settings'}) + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons[0].attributes('aria-selected')).toBe('false') + expect(buttons[1].attributes('aria-selected')).toBe('true') + expect(buttons[2].attributes('aria-selected')).toBe('false') + }) + + it('sets correct aria-controls and aria-labelledby', () => { + const wrapper = mountComponent({tabs, id: 'test'}) + const buttons = wrapper.findAll('[role="tab"]') + const panels = wrapper.findAll('[role="tabpanel"]') + + expect(buttons[0].attributes('aria-controls')).toBe('test-panel-home') + expect(buttons[1].attributes('aria-controls')).toBe('test-panel-settings') + expect(panels[0].attributes('aria-labelledby')).toBe('test-tab-home') + expect(panels[1].attributes('aria-labelledby')).toBe('test-tab-settings') + }) + + 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}) + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons[0].attributes('tabindex')).toBe('0') + expect(buttons[1].attributes('tabindex')).toBe('-1') + expect(buttons[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).toHaveLength(2) + expect(icons[0].props('icon')).toBe('mdi:home') + expect(icons[1].props('icon')).toBe('mdi:account') + }) +}) diff --git a/app/components/malio/tab/TabList.vue b/app/components/malio/tab/TabList.vue new file mode 100644 index 0000000..6293880 --- /dev/null +++ b/app/components/malio/tab/TabList.vue @@ -0,0 +1,87 @@ + + + diff --git a/app/story/tab/tabList.story.vue b/app/story/tab/tabList.story.vue new file mode 100644 index 0000000..e62c6e0 --- /dev/null +++ b/app/story/tab/tabList.story.vue @@ -0,0 +1,109 @@ + + + +# 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é + + + diff --git a/docs/superpowers/plans/2026-03-20-tab-list.md b/docs/superpowers/plans/2026-03-20-tab-list.md new file mode 100644 index 0000000..52c2216 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-tab-list.md @@ -0,0 +1,511 @@ +# 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" +``` diff --git a/tailwind.config.ts b/tailwind.config.ts index 4d289d1..b805439 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -4,6 +4,7 @@ export default { content: [ './app/**/*.{vue,js,ts}', './app/**/*.story.{vue,js,ts}', + './.playground/**/*.{vue,js,ts}', './histoire.setup.ts', './histoire.config.ts', ],