fix : composant TabList + client playground
This commit is contained in:
@@ -77,7 +77,7 @@
|
|||||||
<template #information>
|
<template #information>
|
||||||
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||||
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
|
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
|
||||||
<MalioInputText label="Concurrent"/>
|
<MalioInputText v-model="concurrent" label="Concurrent"/>
|
||||||
<MalioInputText label="Date création"/>
|
<MalioInputText label="Date création"/>
|
||||||
<MalioInputText label="Nombre de salariés" />
|
<MalioInputText label="Nombre de salariés" />
|
||||||
<MalioInputAmount label="CA"/>
|
<MalioInputAmount label="CA"/>
|
||||||
@@ -278,12 +278,40 @@ const onSearchAdresse = async (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsValue = ref('information')
|
const tabsValue = ref('information')
|
||||||
|
const concurrent = ref('')
|
||||||
|
|
||||||
const tabs = [
|
const informationValid = computed(() => concurrent.value.trim().length > 0)
|
||||||
{ key: 'information', label: 'Information', icon: 'mdi:account-outline' },
|
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
|
||||||
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-plus-outline' },
|
|
||||||
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
const tabs = computed(() => [
|
||||||
{ key: 'transport', label: 'Transport', icon: 'mdi:truck-delivery-outline' },
|
{
|
||||||
{ key: 'comptabilité', label: 'Comptabilité', icon: 'mdi:bank-circle-outline' },
|
key: 'information',
|
||||||
]
|
label: 'Information',
|
||||||
|
icon: 'mdi:account-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contacts',
|
||||||
|
label: 'Contacts',
|
||||||
|
icon: 'mdi:account-box-plus-outline',
|
||||||
|
disabled: !informationValid.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adresses',
|
||||||
|
label: 'Adresses',
|
||||||
|
icon: 'mdi:map-marker-outline',
|
||||||
|
disabled: !informationValid.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transport',
|
||||||
|
label: 'Transport',
|
||||||
|
icon: 'mdi:truck-delivery-outline',
|
||||||
|
disabled: !informationValid.value || !adressesValid.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comptabilité',
|
||||||
|
label: 'Comptabilité',
|
||||||
|
icon: 'mdi:bank-circle-outline',
|
||||||
|
disabled: !informationValid.value || !adressesValid.value,
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -519,18 +519,42 @@ Navigation par onglets avec contenu dynamique.
|
|||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
|
||||||
| `tabs` | `{ key: string, label: string, icon?: string }[]` | **requis** | Liste des onglets |
|
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
Type `Tab` :
|
||||||
|
|
||||||
|
| Propriété | Type | Défaut | Description |
|
||||||
|
|-----------|------|--------|-------------|
|
||||||
|
| `key` | `string` | — | Identifiant unique (utilisé pour le slot et le v-model) |
|
||||||
|
| `label` | `string` | — | Texte de l'onglet |
|
||||||
|
| `icon` | `string` | — | Nom Iconify (optionnel) |
|
||||||
|
| `iconSize` | `string` | `24` | Taille de l'icône |
|
||||||
|
| `disabled` | `boolean` | `false` | Onglet désactivé : grisé et non cliquable. Le parent calcule cet état selon sa logique de validation |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string)` — émis uniquement quand l'onglet cible n'est pas `disabled`
|
||||||
**Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet
|
**Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioTabList v-model="activeTab" :tabs="[{ key: 'infos', label: 'Informations' }, { key: 'docs', label: 'Documents', icon: 'mdi:file' }]">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
<template #infos>Contenu infos</template>
|
<template #infos>Contenu infos</template>
|
||||||
<template #docs>Contenu docs</template>
|
<template #docs>Contenu docs</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Pattern de gating progressif** (déverrouille les onglets quand les précédents sont valides) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const informationValid = computed(() => name.value && email.value)
|
||||||
|
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ key: 'information', label: 'Information' },
|
||||||
|
{ key: 'contacts', label: 'Contacts', disabled: !informationValid.value },
|
||||||
|
{ key: 'adresses', label: 'Adresses', disabled: !informationValid.value },
|
||||||
|
{ key: 'transport', label: 'Transport', disabled: !informationValid.value || !adressesValid.value },
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MalioSidebar
|
## MalioSidebar
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Tab = {
|
|||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
icon?: string
|
icon?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabListProps = {
|
type TabListProps = {
|
||||||
@@ -134,4 +135,53 @@ describe('MalioTabList', () => {
|
|||||||
expect(icons[0].props('icon')).toBe('mdi:home')
|
expect(icons[0].props('icon')).toBe('mdi:home')
|
||||||
expect(icons[1].props('icon')).toBe('mdi:account')
|
expect(icons[1].props('icon')).toBe('mdi:account')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sets disabled attribute and aria-disabled on disabled tabs', () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(buttons[1].attributes('disabled')).toBeDefined()
|
||||||
|
expect(buttons[1].attributes('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies cursor-not-allowed on disabled tabs', () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(buttons[1].classes()).toContain('cursor-not-allowed')
|
||||||
|
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit update:modelValue when clicking a disabled tab', async () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs, modelValue: 'a'})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
|
||||||
|
await buttons[1].trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not change active tab in uncontrolled mode when clicking disabled tab', async () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
|
||||||
|
await buttons[1].trigger('click')
|
||||||
|
|
||||||
|
expect(buttons[0].attributes('aria-selected')).toBe('true')
|
||||||
|
expect(buttons[1].attributes('aria-selected')).toBe('false')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,12 +12,16 @@
|
|||||||
type="button"
|
type="button"
|
||||||
:aria-selected="activeTab === tab.key"
|
:aria-selected="activeTab === tab.key"
|
||||||
:aria-controls="`${componentId}-panel-${tab.key}`"
|
:aria-controls="`${componentId}-panel-${tab.key}`"
|
||||||
|
:aria-disabled="!!tab.disabled"
|
||||||
:tabindex="activeTab === tab.key ? 0 : -1"
|
:tabindex="activeTab === tab.key ? 0 : -1"
|
||||||
|
:disabled="tab.disabled"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-[18px] text-[24px] font-[600] transition-colors cursor-pointer',
|
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'border-b-2 border-m-primary text-m-primary outline-b'
|
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
|
||||||
: 'border-transparent text-m-primary/50 hover:text-m-primary/70',
|
: tab.disabled
|
||||||
|
? 'cursor-not-allowed text-m-primary/50'
|
||||||
|
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
|
||||||
]"
|
]"
|
||||||
@click="selectTab(tab.key)"
|
@click="selectTab(tab.key)"
|
||||||
>
|
>
|
||||||
@@ -54,6 +58,7 @@ type Tab = {
|
|||||||
label: string
|
label: string
|
||||||
icon?: string
|
icon?: string
|
||||||
iconSize?: string
|
iconSize?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -80,6 +85,8 @@ const activeTab = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function selectTab(key: string) {
|
function selectTab(key: string) {
|
||||||
|
const tab = props.tabs.find(t => t.key === key)
|
||||||
|
if (tab?.disabled) return
|
||||||
if (!isControlled.value) {
|
if (!isControlled.value) {
|
||||||
localValue.value = key
|
localValue.value = key
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user