[#MUI-11] Création d'un composant navigation par onglets #16

Merged
tristan merged 1 commits from feature/MUI-11-developper-le-composant-navigation-par-onglet into develop 2026-03-23 07:48:56 +00:00
7 changed files with 912 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
<template>
<div class="grid grid-cols-1 items-start gap-6">
<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>

View File

@@ -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

View File

@@ -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<TabListProps>
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<string, string>) {
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: '<p>Home content</p>',
settings: '<p>Settings content</p>',
profile: '<p>Profile content</p>',
})
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')
})
})

View File

@@ -0,0 +1,87 @@
<template>
<div v-bind="$attrs">
<div
role="tablist"
class="flex justify-center gap-[60px] border-b border-m-border"
>
<button
v-for="tab in tabs"
:id="`${componentId}-tab-${tab.key}`"
:key="tab.key"
role="tab"
type="button"
:aria-selected="activeTab === tab.key"
:aria-controls="`${componentId}-panel-${tab.key}`"
:tabindex="activeTab === tab.key ? 0 : -1"
:class="[
'flex items-center gap-[18px] text-[24px] font-medium transition-colors cursor-pointer',
activeTab === tab.key
? 'border-b-2 border-m-primary text-m-primary font-bold outline-b'
: '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"
v-show="activeTab === tab.key"
:id="`${componentId}-panel-${tab.key}`"
:key="tab.key"
role="tabpanel"
:aria-labelledby="`${componentId}-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})
type Tab = {
key: string
label: string
icon?: string
}
const props = withDefaults(defineProps<{
tabs: Tab[]
modelValue?: string
id?: string
}>(), {
modelValue: undefined,
id: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-tab-list-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(props.tabs.length > 0 ? props.tabs[0].key : '')
const activeTab = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
function selectTab(key: string) {
if (!isControlled.value) {
localValue.value = key
}
emit('update:modelValue', key)
}
</script>

View File

@@ -0,0 +1,109 @@
<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>

View File

@@ -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
<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"
```

View File

@@ -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',
],