| 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>
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 conteneurrole="tab"avecaria-selected,aria-controls,tabindexsur chaque boutonrole="tabpanel"avecaria-labelledbysur 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"