| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #16 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
512 lines
16 KiB
Markdown
512 lines
16 KiB
Markdown
# 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
|
|
<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**
|
|
|
|
```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<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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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é
|
|
</docs>
|
|
|
|
<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"
|
|
```
|