Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5bebe3a3c | |||
| 28705c8285 |
@@ -68,24 +68,6 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||||
<MalioRadioGroup
|
|
||||||
v-model="prestationChoice"
|
|
||||||
:options="prestationOptions"
|
|
||||||
inline
|
|
||||||
required
|
|
||||||
content-class="justify-between"
|
|
||||||
error="Sélection requise"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="fournisseur"
|
|
||||||
value=""
|
|
||||||
label="Fournisseur"
|
|
||||||
error="Sélection requise"
|
|
||||||
:options="[
|
|
||||||
{label: 'Fournisseur 1', value: 'Fournisseur 1'},
|
|
||||||
{label: 'Fournisseur 2', value: 'Fournisseur 2'},
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-12 flex justify-center">
|
<div class="mt-12 flex justify-center">
|
||||||
@@ -206,12 +188,6 @@ const distributeur = ref<string>('')
|
|||||||
const phones = ref<string[]>([''])
|
const phones = ref<string[]>([''])
|
||||||
const nomDistributeur = ref<string>('')
|
const nomDistributeur = ref<string>('')
|
||||||
const nomCourtier = ref<string>('')
|
const nomCourtier = ref<string>('')
|
||||||
const fournisseur = ref<string>('')
|
|
||||||
const prestationChoice = ref<string | null>(null)
|
|
||||||
const prestationOptions = [
|
|
||||||
{label: 'Fond mouvant', value: 'fond-mouvant'},
|
|
||||||
{label: 'Benne', value: 'benne'},
|
|
||||||
]
|
|
||||||
|
|
||||||
function addPhoneInput() {
|
function addPhoneInput() {
|
||||||
phones.value.push('')
|
phones.value.push('')
|
||||||
|
|||||||
@@ -47,6 +47,31 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border-2 border-m-primary p-4 md:col-span-2">
|
||||||
|
<h2 class="mb-1 text-xl font-bold">allowCreate + BAN (test MUI-48)</h2>
|
||||||
|
<p class="mb-3 text-sm text-m-muted">
|
||||||
|
Tapez au moins 3 caractères → suggestions de la Base Adresse Nationale.
|
||||||
|
<strong>Repro :</strong> sélectionnez une adresse dans la liste, puis
|
||||||
|
<kbd>Ctrl</kbd>+<kbd>A</kbd> et <kbd>Ctrl</kbd>+<kbd>V</kbd> pour coller une autre valeur
|
||||||
|
par-dessus. La valeur collée doit rester (le champ ne doit ni se vider, ni faire redescendre le label).
|
||||||
|
</p>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="banValue"
|
||||||
|
label="Adresse"
|
||||||
|
:options="banOptions"
|
||||||
|
:loading="banLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
allow-create
|
||||||
|
icon-name="mdi:map-marker-outline"
|
||||||
|
icon-position="left"
|
||||||
|
@search="onSearchBan"
|
||||||
|
@create="onCreateBan"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-m-muted">
|
||||||
|
v-model : <code>{{ banValue ?? 'null' }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2>
|
||||||
<MalioInputAutocomplete
|
<MalioInputAutocomplete
|
||||||
@@ -199,4 +224,40 @@ const onSearchApi = async (query: string) => {
|
|||||||
const onSelectApi = (option: Option | null) => {
|
const onSelectApi = (option: Option | null) => {
|
||||||
apiSelected.value = option
|
apiSelected.value = option
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowCreate + BAN (test MUI-48) : recherche nationale sur la Base Adresse Nationale.
|
||||||
|
const banValue = ref<string | number | null>(null)
|
||||||
|
const banOptions = ref<Option[]>([])
|
||||||
|
const banLoading = ref(false)
|
||||||
|
let banFetchId = 0
|
||||||
|
|
||||||
|
const onSearchBan = async (query: string) => {
|
||||||
|
if (query.length < 3) {
|
||||||
|
banOptions.value = []
|
||||||
|
banLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const requestId = ++banFetchId
|
||||||
|
banLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({q: query, limit: '8'})
|
||||||
|
const response = await fetch(`https://api-adresse.data.gouv.fr/search/?${params.toString()}`)
|
||||||
|
const data = await response.json() as {features: {properties: {label: string}}[]}
|
||||||
|
if (requestId !== banFetchId) return
|
||||||
|
banOptions.value = data.features.map(f => ({
|
||||||
|
label: f.properties.label,
|
||||||
|
value: f.properties.label,
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
if (requestId !== banFetchId) return
|
||||||
|
banOptions.value = []
|
||||||
|
console.error('Erreur lors du chargement des adresses BAN', err)
|
||||||
|
} finally {
|
||||||
|
if (requestId === banFetchId) banLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCreateBan = (value: string) => {
|
||||||
|
console.log('BAN — valeur libre créée :', value)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-8 p-8">
|
|
||||||
<section>
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Aligné avec un select (en ligne, erreur)</h2>
|
|
||||||
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
|
|
||||||
<MalioRadioGroup v-model="prestation" :options="yesNo" inline error="Sélection requise" />
|
|
||||||
<MalioSelect
|
|
||||||
v-model="fournisseur"
|
|
||||||
label="Fournisseur"
|
|
||||||
error="Sélection requise"
|
|
||||||
:options="[
|
|
||||||
{label: 'Fournisseur 1', value: 'Fournisseur 1'},
|
|
||||||
{label: 'Fournisseur 2', value: 'Fournisseur 2'},
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Empilé avec label</h2>
|
|
||||||
<MalioRadioGroup v-model="categorie" :options="categories" label="Catégorie" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Slot custom + requis</h2>
|
|
||||||
<MalioRadioGroup v-model="civilite" inline required label="Civilité">
|
|
||||||
<MalioRadioButton value="M" label="Monsieur" />
|
|
||||||
<MalioRadioButton value="Mme" label="Madame" />
|
|
||||||
</MalioRadioGroup>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref} from 'vue'
|
|
||||||
|
|
||||||
const yesNo = [
|
|
||||||
{label: 'Oui', value: 'oui'},
|
|
||||||
{label: 'Non', value: 'non'},
|
|
||||||
]
|
|
||||||
const categories = [
|
|
||||||
{label: 'Catégorie 1', value: 'cat1'},
|
|
||||||
{label: 'Catégorie 2', value: 'cat2'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const prestation = ref<string | null>(null)
|
|
||||||
const fournisseur = ref<string>('')
|
|
||||||
const categorie = ref<string | null>(null)
|
|
||||||
const civilite = ref<string | null>(null)
|
|
||||||
</script>
|
|
||||||
@@ -10,6 +10,18 @@
|
|||||||
<template #logo-collapsed>
|
<template #logo-collapsed>
|
||||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
|
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
|
||||||
</template>
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon name="mdi:account-circle" size="32" class="text-m-primary" />
|
||||||
|
<div class="leading-tight">
|
||||||
|
<p class="text-[14px] font-semibold text-m-text">Tristan</p>
|
||||||
|
<p class="text-[12px] text-m-muted">Administrateur</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer-collapsed>
|
||||||
|
<Icon name="mdi:account-circle" size="28" class="mx-auto block text-m-primary" />
|
||||||
|
</template>
|
||||||
</MalioSidebar>
|
</MalioSidebar>
|
||||||
|
|
||||||
<MalioSidebar
|
<MalioSidebar
|
||||||
@@ -22,6 +34,18 @@
|
|||||||
<template #logo-collapsed>
|
<template #logo-collapsed>
|
||||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
|
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
|
||||||
</template>
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 text-[15px] text-m-text hover:text-m-primary"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:logout" size="20" />
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #footer-collapsed>
|
||||||
|
<Icon name="mdi:logout" size="20" class="mx-auto block text-m-text" />
|
||||||
|
</template>
|
||||||
</MalioSidebar>
|
</MalioSidebar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const navSections: SidebarSection[] = [
|
|||||||
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
||||||
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
||||||
{label: 'Radio', to: '/composant/radio/radioButton'},
|
{label: 'Radio', to: '/composant/radio/radioButton'},
|
||||||
{label: 'Radio (groupe)', to: '/composant/radio/radioGroup'},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-1
@@ -57,7 +57,7 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
|
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
|
||||||
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
|
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
|
||||||
* Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau de navigation — jours → mois → années) et grisage des mois et années hors `min`/`max`.
|
* Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau de navigation — jours → mois → années) et grisage des mois et années hors `min`/`max`.
|
||||||
* [#MUI-radio-group] Création d'un composant radio group (message unique, alignement select)
|
* MalioSidebar : slots `footer` / `footer-collapsed` pour ajouter un contenu en bas de la sidebar (profil, déconnexion, version…). Toujours collé en bas (la nav `flex-1` le pousse), reste visible quand la liste de liens scrolle ; bordure haute `m-primary` en mode déplié, à l'image du bloc logo.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
|
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
|
||||||
@@ -72,6 +72,8 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
|
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
* [#MUI-48] InputAutocomplete : après avoir **sélectionné une suggestion** dans la liste, un **collage qui remplace tout** (Ctrl+A puis Ctrl+V, sans re-cliquer dans le champ) **vidait le champ** au lieu de prendre la valeur collée (label qui redescend, dropdown « Tapez pour rechercher »). Cause : `onSelect` repassait `isFocused` à `false` alors que l'input gardait le focus DOM (option cliquée en `mousedown.prevent`) ; le `watch` de synchronisation, non protégé, remettait `inputValue` à `''`. `onInput` resynchronise désormais `isFocused`. La sélection à la souris n'était pas affectée (re-clic réalignant l'état).
|
||||||
|
* [#MUI-47] Sidebar : la **bande de ~4px en haut/bas d'un lien** (padding du `<li>` qui porte le fond de hover) était survolée mais **non cliquable**. Le padding vertical passe du `<li>` à l'`<a>` (`py-1`), si bien que toute la zone survolée devient cliquable — sans changement visuel. Les côtés n'étaient pas affectés (`<a>` en `block`, pas de padding horizontal sur le `<li>`).
|
||||||
* Sidebar : le **lien actif** reste actif sur les **sous-routes** (match par préfixe via `useRoute().path` au lieu de l'`active-class` de NuxtLink qui dépendait de l'imbrication des routes) — ex. `/supplier` reste surligné sur `/supplier/1/edit`. Nouvelle option `exact: true` par item pour forcer le match strict.
|
* Sidebar : le **lien actif** reste actif sur les **sous-routes** (match par préfixe via `useRoute().path` au lieu de l'`active-class` de NuxtLink qui dépendait de l'imbrication des routes) — ex. `/supplier` reste surligné sur `/supplier/1/edit`. Nouvelle option `exact: true` par item pour forcer le match strict.
|
||||||
* Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir).
|
* Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir).
|
||||||
* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit.
|
* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit.
|
||||||
|
|||||||
+5
-42
@@ -499,47 +499,6 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MalioRadioGroup
|
|
||||||
|
|
||||||
Groupe de boutons radio : possède la valeur, le `name` partagé et **un seul** message (erreur/succès/aide) avec espace réservé comme les autres champs — un groupe en ligne s'aligne donc avec un `MalioSelect` voisin. Les options sont déclarées via `:options` ou via le slot par défaut (`<MalioRadioButton>`).
|
|
||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
|
||||||
|------|------|--------|-------------|
|
|
||||||
| `modelValue` | `string \| number \| boolean \| null` | `undefined` | Valeur sélectionnée (v-model) |
|
|
||||||
| `options` | `{label, value, disabled?}[]` | `[]` | Options déclaratives |
|
|
||||||
| `label` | `string` | `''` | Label de groupe (legend, lié par `aria-labelledby`) |
|
|
||||||
| `name` | `string` | auto | Nom natif partagé des radios |
|
|
||||||
| `inline` | `boolean` | `false` | Disposition horizontale |
|
|
||||||
| `disabled` | `boolean` | `false` | Désactive tout le groupe |
|
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
|
||||||
| `required` | `boolean` | `false` | Champ requis (astérisque dans la legend) |
|
|
||||||
| `hint` / `error` / `success` | `string` | `''` | Message unique du groupe |
|
|
||||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve la ligne de message (alignement) |
|
|
||||||
| `groupClass` | `string` | `''` | Override `twMerge` du conteneur du groupe |
|
|
||||||
| `contentClass` | `string` | `''` | Override `twMerge` de la zone des radios (ex. `justify-between`) |
|
|
||||||
| `inputClass` | `string` | `''` | Override `twMerge` propagé à l'`input` de chaque radio |
|
|
||||||
| `labelClass` | `string` | `''` | Override `twMerge` du **label de groupe** (legend), pas des labels d'options |
|
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string | number | boolean | null)`
|
|
||||||
|
|
||||||
**Accessibilité :** conteneur `role="radiogroup"`, `aria-labelledby` (si `label`), `aria-invalid` et `aria-describedby` sur le message unique. Les radios enfants héritent de l'état d'erreur/désactivé du groupe.
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<MalioRadioGroup
|
|
||||||
v-model="prestation"
|
|
||||||
:options="[{label: 'Oui', value: 'oui'}, {label: 'Non', value: 'non'}]"
|
|
||||||
inline
|
|
||||||
error="Sélection requise"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MalioRadioGroup v-model="civilite" label="Civilité" inline>
|
|
||||||
<MalioRadioButton value="M" label="Monsieur" />
|
|
||||||
<MalioRadioButton value="Mme" label="Madame" />
|
|
||||||
</MalioRadioGroup>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MalioDate
|
## MalioDate
|
||||||
|
|
||||||
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
||||||
@@ -934,12 +893,16 @@ Barre latérale de navigation rétractable.
|
|||||||
**Lien actif :** un lien est marqué actif (texte `m-primary` + semi-bold) quand la route courante **est ce lien ou une de ses sous-routes** (match par préfixe) — ex. `/supplier` reste actif sur `/supplier/1/edit`. Mettre `exact: true` sur l'item force le match strict (actif uniquement sur la route exacte). Indépendant de l'imbrication des routes côté consommateur.
|
**Lien actif :** un lien est marqué actif (texte `m-primary` + semi-bold) quand la route courante **est ce lien ou une de ses sous-routes** (match par préfixe) — ex. `/supplier` reste actif sur `/supplier/1/edit`. Mettre `exact: true` sur l'item force le match strict (actif uniquement sur la route exacte). Indépendant de l'imbrication des routes côté consommateur.
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: boolean)`
|
**Events :** `update:modelValue(value: boolean)`
|
||||||
**Slots :** `logo` (sidebar ouverte), `logo-collapsed` (sidebar fermée)
|
**Slots :** `logo` (sidebar ouverte), `logo-collapsed` (sidebar fermée), `footer` (bas, sidebar ouverte), `footer-collapsed` (bas, sidebar fermée)
|
||||||
|
|
||||||
|
Le footer est **toujours collé en bas** : la nav occupe l'espace restant (`flex-1`) et pousse le footer vers le bas, qui reste visible même quand la liste de liens scrolle.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioSidebar v-model="isOpen" :sections="menuSections">
|
<MalioSidebar v-model="isOpen" :sections="menuSections">
|
||||||
<template #logo><img src="/logo.png" /></template>
|
<template #logo><img src="/logo.png" /></template>
|
||||||
<template #logo-collapsed><img src="/logo-small.png" /></template>
|
<template #logo-collapsed><img src="/logo-small.png" /></template>
|
||||||
|
<template #footer><UserProfile /></template>
|
||||||
|
<template #footer-collapsed><Icon name="mdi:account" /></template>
|
||||||
</MalioSidebar>
|
</MalioSidebar>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {describe, expect, it, vi} from 'vitest'
|
import {describe, expect, it, vi} from 'vitest'
|
||||||
import {mount} from '@vue/test-utils'
|
import {mount} from '@vue/test-utils'
|
||||||
import type {DefineComponent} from 'vue'
|
import {defineComponent, nextTick, ref, type DefineComponent} from 'vue'
|
||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
import InputAutocomplete from './InputAutocomplete.vue'
|
import InputAutocomplete from './InputAutocomplete.vue'
|
||||||
|
|
||||||
@@ -569,4 +569,40 @@ describe('MalioInputAutocomplete', () => {
|
|||||||
expect(msg.exists()).toBe(true)
|
expect(msg.exists()).toBe(true)
|
||||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// MUI-48 : après avoir sélectionné une option dans la liste, le champ garde le focus DOM
|
||||||
|
// mais isFocused interne passe à false (clic option en mousedown.prevent). Un collage qui
|
||||||
|
// remplace tout (Ctrl+A puis Ctrl+V) déclenche update:modelValue(null) ; le watch ne doit
|
||||||
|
// PAS vider la valeur collée. Régression : le champ se vidait au lieu de prendre le texte collé.
|
||||||
|
it('MUI-48 : un collage après sélection dans la liste remplace la valeur (ne la vide pas)', async () => {
|
||||||
|
const Harness = defineComponent({
|
||||||
|
components: {InputAutocomplete},
|
||||||
|
setup() {
|
||||||
|
const val = ref<string | number | null>(null)
|
||||||
|
const opts = ref([{label: '10 Rue de la Paix', value: '10 Rue de la Paix'}])
|
||||||
|
return {val, opts}
|
||||||
|
},
|
||||||
|
template: '<InputAutocomplete v-model="val" :options="opts" allow-create />',
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(Harness, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {stubs: {IconifyIcon: {template: '<span data-test="icon" v-bind="$attrs" />'}}},
|
||||||
|
})
|
||||||
|
const input = wrapper.get('input')
|
||||||
|
|
||||||
|
// saisie puis sélection d'une suggestion (commit, focus DOM conservé)
|
||||||
|
await input.trigger('focus')
|
||||||
|
await input.setValue('10')
|
||||||
|
await wrapper.findAll('[data-test="option"]')[0].trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(input.element.value).toBe('10 Rue de la Paix')
|
||||||
|
|
||||||
|
// Ctrl+A puis Ctrl+V : input toujours focalisé DOM, aucun nouvel évènement focus
|
||||||
|
await input.setValue('25 Avenue Victor Hugo')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(input.element.value).toBe('25 Avenue Victor Hugo')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -393,6 +393,11 @@ const scheduleSearch = () => {
|
|||||||
|
|
||||||
const onInput = (event: Event) => {
|
const onInput = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
|
// Un évènement input prouve que le champ est en cours d'édition : on resynchronise
|
||||||
|
// isFocused, qu'une sélection précédente (onSelect) a pu passer à false tout en gardant
|
||||||
|
// le focus DOM (clic option en mousedown.prevent). Sans ça, le watch ci-dessous remettrait
|
||||||
|
// inputValue à '' au collage et la valeur collée serait perdue (MUI-48).
|
||||||
|
isFocused.value = true
|
||||||
inputValue.value = target.value
|
inputValue.value = target.value
|
||||||
if (!isOpen.value) isOpen.value = true
|
if (!isOpen.value) isOpen.value = true
|
||||||
activeIndex.value = -1
|
activeIndex.value = -1
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {computed, ref} from 'vue'
|
import {describe, expect, it} from 'vitest'
|
||||||
import {describe, expect, it, vi} from 'vitest'
|
|
||||||
import {mount} from '@vue/test-utils'
|
import {mount} from '@vue/test-utils'
|
||||||
import type {DefineComponent} from 'vue'
|
import type {DefineComponent} from 'vue'
|
||||||
import RadioButton from './RadioButton.vue'
|
import RadioButton from './RadioButton.vue'
|
||||||
import {radioGroupContextKey, type RadioGroupContext, type RadioValue} from './context'
|
|
||||||
|
|
||||||
type RadioButtonProps = {
|
type RadioButtonProps = {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -195,67 +193,3 @@ describe('MalioRadioButton', () => {
|
|||||||
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const makeGroupCtx = (over: Partial<{
|
|
||||||
selected: RadioValue; error: boolean; success: boolean
|
|
||||||
disabled: boolean; readonly: boolean; required: boolean
|
|
||||||
}> = {}) => {
|
|
||||||
const selected = ref<RadioValue>(over.selected ?? null)
|
|
||||||
const select = vi.fn((v: RadioValue) => { selected.value = v })
|
|
||||||
const ctx: RadioGroupContext = {
|
|
||||||
name: computed(() => 'grp'),
|
|
||||||
isSelected: (v) => selected.value === v,
|
|
||||||
select,
|
|
||||||
hasError: computed(() => !!over.error),
|
|
||||||
hasSuccess: computed(() => !!over.success),
|
|
||||||
disabled: computed(() => !!over.disabled),
|
|
||||||
readonly: computed(() => !!over.readonly),
|
|
||||||
required: computed(() => !!over.required),
|
|
||||||
describedBy: computed(() => 'grp-describedby'),
|
|
||||||
}
|
|
||||||
return {ctx, select, selected}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mountInGroup = (props: RadioButtonProps, ctx: RadioGroupContext) =>
|
|
||||||
mount(RadioButtonForTest, {
|
|
||||||
props,
|
|
||||||
global: {provide: {[radioGroupContextKey as symbol]: ctx}},
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('MalioRadioButton dans un groupe', () => {
|
|
||||||
it('hérite du name du groupe', () => {
|
|
||||||
const {ctx} = makeGroupCtx()
|
|
||||||
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
|
||||||
expect(wrapper.get('input').attributes('name')).toBe('grp')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('coché selon isSelected du groupe', () => {
|
|
||||||
const {ctx} = makeGroupCtx({selected: 'a'})
|
|
||||||
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
|
||||||
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('appelle ctx.select au change au lieu d\'émettre', async () => {
|
|
||||||
const {ctx, select} = makeGroupCtx()
|
|
||||||
const wrapper = mountInGroup({value: 'b', label: 'B'}, ctx)
|
|
||||||
await wrapper.get('input').trigger('change')
|
|
||||||
expect(select).toHaveBeenCalledWith('b')
|
|
||||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reflète l\'erreur du groupe et ne rend aucun message propre', () => {
|
|
||||||
const {ctx} = makeGroupCtx({error: true})
|
|
||||||
const wrapper = mountInGroup({value: 'a', label: 'A', hint: 'ignoré'}, ctx)
|
|
||||||
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
|
|
||||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
|
||||||
expect(wrapper.find('.radio-message').exists()).toBe(false)
|
|
||||||
expect(wrapper.get('input').attributes('aria-describedby')).toBe('grp-describedby')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hérite de disabled/required du groupe', () => {
|
|
||||||
const {ctx} = makeGroupCtx({disabled: true, required: true})
|
|
||||||
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
|
||||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
|
||||||
expect(wrapper.get('input').attributes('required')).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="mergedGroupClass">
|
<div :class="mergedGroupClass">
|
||||||
<div :class="mergedControlClass">
|
<div :class="mergedControlClass">
|
||||||
<label :for="inputId" :class="indicatorClass">
|
<label :for="inputId" class="radio-indicator relative flex cursor-pointer items-center p-3">
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
:name="resolvedName"
|
:name="name"
|
||||||
:value="value"
|
:value="value"
|
||||||
:checked="isChecked"
|
:checked="isChecked"
|
||||||
:required="resolvedRequired"
|
:required="required"
|
||||||
:disabled="resolvedDisabled"
|
:disabled="disabled"
|
||||||
:aria-invalid="hasError"
|
:aria-invalid="!!error"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
:class="mergedInputClass"
|
:class="mergedInputClass"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}<MalioRequiredMark v-if="resolvedRequired" />
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,10 +44,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, inject, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} from 'vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
import {radioGroupContextKey, type RadioValue} from './context'
|
|
||||||
|
|
||||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -56,8 +55,8 @@ const props = withDefaults(
|
|||||||
id?: string
|
id?: string
|
||||||
label?: string
|
label?: string
|
||||||
name?: string
|
name?: string
|
||||||
modelValue?: RadioValue
|
modelValue?: string | number | boolean | null | undefined
|
||||||
value?: RadioValue
|
value?: string | number | boolean | null | undefined
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
@@ -88,45 +87,27 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
const localValue = ref<RadioValue>(undefined)
|
const localValue = ref<string | number | boolean | null | undefined>(undefined)
|
||||||
const group = inject(radioGroupContextKey, null)
|
|
||||||
|
|
||||||
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const isChecked = computed(() =>
|
||||||
const resolvedName = computed(() => (group ? group.name.value : props.name))
|
isControlled.value ? props.modelValue === props.value : localValue.value === props.value,
|
||||||
const resolvedDisabled = computed(() => props.disabled || (group?.disabled.value ?? false))
|
|
||||||
const resolvedReadonly = computed(() => props.readonly || (group?.readonly.value ?? false))
|
|
||||||
const resolvedRequired = computed(() => props.required || (group?.required.value ?? false))
|
|
||||||
|
|
||||||
const isChecked = computed(() => {
|
|
||||||
if (group) return group.isSelected(props.value)
|
|
||||||
return isControlled.value ? props.modelValue === props.value : localValue.value === props.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasError = computed(() => (group ? group.hasError.value : !!props.error))
|
|
||||||
const hasSuccess = computed(() =>
|
|
||||||
group ? group.hasSuccess.value : !!props.success && !hasError.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
const shouldShowMessage = computed(
|
|
||||||
() => !group && !!(props.hint || hasError.value || hasSuccess.value),
|
|
||||||
)
|
)
|
||||||
|
const hasError = computed(() => !!props.error)
|
||||||
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
|
const disabled = computed(() => props.disabled)
|
||||||
|
const shouldShowMessage = computed(() => !!(props.hint || hasError.value || hasSuccess.value))
|
||||||
|
|
||||||
const describedBy = computed(() => {
|
const describedBy = computed(() => {
|
||||||
if (group) return group.describedBy.value
|
|
||||||
if (!shouldShowMessage.value) return undefined
|
if (!shouldShowMessage.value) return undefined
|
||||||
return `${inputId.value}-describedby`
|
return `${inputId.value}-describedby`
|
||||||
})
|
})
|
||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(group ? 'radio-item w-auto' : 'radio-item mt-4 w-full', props.groupClass),
|
|
||||||
)
|
|
||||||
|
|
||||||
const indicatorClass = computed(() =>
|
|
||||||
twMerge(
|
twMerge(
|
||||||
'radio-indicator relative flex cursor-pointer items-center',
|
'radio-item mt-4 w-full',
|
||||||
group ? 'px-3 py-2.5' : 'p-3',
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,7 +116,7 @@ const mergedControlClass = computed(() =>
|
|||||||
'radio-control flex items-center',
|
'radio-control flex items-center',
|
||||||
hasError.value ? 'is-error' : '',
|
hasError.value ? 'is-error' : '',
|
||||||
hasSuccess.value ? 'is-success' : '',
|
hasSuccess.value ? 'is-success' : '',
|
||||||
resolvedDisabled.value ? 'is-disabled' : '',
|
disabled.value ? 'is-disabled' : '',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,7 +133,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
isChecked.value ? 'text-black' : 'text-m-muted',
|
isChecked.value ? 'text-black' : 'text-m-muted',
|
||||||
hasError.value ? 'text-m-danger' : '',
|
hasError.value ? 'text-m-danger' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
resolvedDisabled.value ? 'cursor-not-allowed text-black/60' : '',
|
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -169,27 +150,22 @@ const mergedMessageClass = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update:modelValue', value: RadioValue): void
|
(event: 'update:modelValue', value: string | number | boolean | null | undefined): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const onClick = (event: MouseEvent) => {
|
const onClick = (event: MouseEvent) => {
|
||||||
if (!resolvedReadonly.value) return
|
if (!props.readonly) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChange = (event: Event) => {
|
const onChange = (event: Event) => {
|
||||||
if (resolvedReadonly.value) {
|
if (props.readonly) {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
target.checked = isChecked.value
|
target.checked = isChecked.value
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group) {
|
|
||||||
group.select(props.value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isControlled.value) {
|
if (!isControlled.value) {
|
||||||
localValue.value = props.value
|
localValue.value = props.value
|
||||||
}
|
}
|
||||||
@@ -229,4 +205,8 @@ const onChange = (event: Event) => {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radio-item:has(+ .radio-item) .radio-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import {describe, expect, it} from 'vitest'
|
|
||||||
import {h} from 'vue'
|
|
||||||
import {mount} from '@vue/test-utils'
|
|
||||||
import type {DefineComponent} from 'vue'
|
|
||||||
import RadioGroup from './RadioGroup.vue'
|
|
||||||
import RadioButton from './RadioButton.vue'
|
|
||||||
|
|
||||||
type Opt = {label: string; value: string; disabled?: boolean}
|
|
||||||
type RadioGroupProps = {
|
|
||||||
modelValue?: string | number | boolean | null
|
|
||||||
options?: Opt[]
|
|
||||||
label?: string
|
|
||||||
name?: string
|
|
||||||
inline?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
readonly?: boolean
|
|
||||||
required?: boolean
|
|
||||||
hint?: string
|
|
||||||
error?: string
|
|
||||||
success?: string
|
|
||||||
reserveMessageSpace?: boolean
|
|
||||||
groupClass?: string
|
|
||||||
inputClass?: string
|
|
||||||
labelClass?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const RadioGroupForTest = RadioGroup as DefineComponent<RadioGroupProps>
|
|
||||||
|
|
||||||
const options: Opt[] = [
|
|
||||||
{label: 'Oui', value: 'oui'},
|
|
||||||
{label: 'Non', value: 'non'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const mountGroup = (props: RadioGroupProps = {}) =>
|
|
||||||
mount(RadioGroupForTest, {props: {options, ...props}})
|
|
||||||
|
|
||||||
describe('MalioRadioGroup', () => {
|
|
||||||
it('rend une option par entrée et un seul role=radiogroup', () => {
|
|
||||||
const wrapper = mountGroup()
|
|
||||||
expect(wrapper.findAll('input[type="radio"]')).toHaveLength(2)
|
|
||||||
expect(wrapper.findAll('[role="radiogroup"]')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('partage le même name natif entre les radios', () => {
|
|
||||||
const wrapper = mountGroup({name: 'prestation'})
|
|
||||||
const names = wrapper.findAll('input').map(i => i.attributes('name'))
|
|
||||||
expect(names).toEqual(['prestation', 'prestation'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('coche selon modelValue', () => {
|
|
||||||
const wrapper = mountGroup({modelValue: 'non'})
|
|
||||||
const inputs = wrapper.findAll('input')
|
|
||||||
expect((inputs[1].element as HTMLInputElement).checked).toBe(true)
|
|
||||||
expect((inputs[0].element as HTMLInputElement).checked).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('émet update:modelValue au clic sur une option', async () => {
|
|
||||||
const wrapper = mountGroup()
|
|
||||||
await wrapper.findAll('input')[1].trigger('change')
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['non'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('affiche UN seul message d\'erreur réservant l\'espace', () => {
|
|
||||||
const wrapper = mountGroup({error: 'Sélection requise'})
|
|
||||||
const msgs = wrapper.findAll('[id$="-describedby"]')
|
|
||||||
expect(msgs).toHaveLength(1)
|
|
||||||
expect(msgs[0].text()).toBe('Sélection requise')
|
|
||||||
expect(msgs[0].classes()).toContain('min-h-[1rem]')
|
|
||||||
expect(msgs[0].classes()).toContain('text-m-danger')
|
|
||||||
expect(wrapper.find('.radio-message').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('propage l\'erreur aux radios enfants', () => {
|
|
||||||
const wrapper = mountGroup({error: 'Sélection requise'})
|
|
||||||
expect(wrapper.findAll('.radio-control.is-error')).toHaveLength(2)
|
|
||||||
expect(wrapper.findAll('input').every(i => i.attributes('aria-invalid') === 'true')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reserveMessageSpace=false sans message : aucune ligne réservée', () => {
|
|
||||||
const wrapper = mountGroup({reserveMessageSpace: false})
|
|
||||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rend la legend et la lie via aria-labelledby', () => {
|
|
||||||
const wrapper = mountGroup({label: 'Prestation'})
|
|
||||||
const legendId = wrapper.find('[id$="-label"]').attributes('id')
|
|
||||||
expect(wrapper.get('[id$="-label"]').text()).toContain('Prestation')
|
|
||||||
expect(wrapper.get('[role="radiogroup"]').attributes('aria-labelledby')).toBe(legendId)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inline : la zone radios réserve la hauteur d\'un champ (h-12 du select)', () => {
|
|
||||||
const wrapper = mountGroup({inline: true})
|
|
||||||
expect(wrapper.get('[role="radiogroup"]').classes()).toContain('min-h-[3rem]')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('contentClass est fusionné sur la zone des radios', () => {
|
|
||||||
const wrapper = mountGroup({inline: true, contentClass: 'justify-between'})
|
|
||||||
expect(wrapper.get('[role="radiogroup"]').classes()).toContain('justify-between')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepte des radios via le slot par défaut', () => {
|
|
||||||
const wrapper = mount(RadioGroupForTest, {
|
|
||||||
props: {modelValue: 'b'},
|
|
||||||
slots: {
|
|
||||||
default: () => [
|
|
||||||
h(RadioButton, {value: 'a', label: 'A'}),
|
|
||||||
h(RadioButton, {value: 'b', label: 'B'}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(wrapper.findAll('input')).toHaveLength(2)
|
|
||||||
expect((wrapper.findAll('input')[1].element as HTMLInputElement).checked).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div :class="mergedGroupClass">
|
|
||||||
<span
|
|
||||||
v-if="label"
|
|
||||||
:id="`${groupId}-label`"
|
|
||||||
:class="mergedLabelClass"
|
|
||||||
>
|
|
||||||
{{ label }}<MalioRequiredMark v-if="required" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
:aria-labelledby="label ? `${groupId}-label` : undefined"
|
|
||||||
:aria-invalid="hasError || undefined"
|
|
||||||
:aria-describedby="describedBy"
|
|
||||||
:class="contentZoneClass"
|
|
||||||
>
|
|
||||||
<MalioRadioButton
|
|
||||||
v-for="(option, index) in options"
|
|
||||||
:key="`${groupId}-opt-${index}`"
|
|
||||||
:label="option.label"
|
|
||||||
:value="option.value"
|
|
||||||
:disabled="option.disabled"
|
|
||||||
:input-class="inputClass"
|
|
||||||
/>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="reserveMessageSpace || hint || error || success"
|
|
||||||
:id="`${groupId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success'
|
|
||||||
: 'text-m-muted',
|
|
||||||
'mt-1 ml-[2px] text-xs',
|
|
||||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ error || success || hint }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {computed, provide, ref, useId} from 'vue'
|
|
||||||
import {twMerge} from 'tailwind-merge'
|
|
||||||
import MalioRadioButton from './RadioButton.vue'
|
|
||||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
|
||||||
import {radioGroupContextKey, type RadioValue} from './context'
|
|
||||||
|
|
||||||
defineOptions({name: 'MalioRadioGroup', inheritAttrs: false})
|
|
||||||
|
|
||||||
interface RadioOption {
|
|
||||||
label: string
|
|
||||||
value: RadioValue
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
modelValue?: RadioValue
|
|
||||||
options?: RadioOption[]
|
|
||||||
label?: string
|
|
||||||
name?: string
|
|
||||||
inline?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
readonly?: boolean
|
|
||||||
required?: boolean
|
|
||||||
hint?: string
|
|
||||||
error?: string
|
|
||||||
success?: string
|
|
||||||
reserveMessageSpace?: boolean
|
|
||||||
groupClass?: string
|
|
||||||
contentClass?: string
|
|
||||||
inputClass?: string
|
|
||||||
labelClass?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
modelValue: undefined,
|
|
||||||
options: () => [],
|
|
||||||
label: '',
|
|
||||||
name: '',
|
|
||||||
inline: false,
|
|
||||||
disabled: false,
|
|
||||||
readonly: false,
|
|
||||||
required: false,
|
|
||||||
hint: '',
|
|
||||||
error: '',
|
|
||||||
success: '',
|
|
||||||
reserveMessageSpace: true,
|
|
||||||
groupClass: '',
|
|
||||||
contentClass: '',
|
|
||||||
inputClass: '',
|
|
||||||
labelClass: '',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'update:modelValue', value: RadioValue): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const generatedId = useId()
|
|
||||||
const groupId = computed(() => props.name || `malio-radio-group-${generatedId}`)
|
|
||||||
|
|
||||||
const localValue = ref<RadioValue>(undefined)
|
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
|
||||||
const selectedValue = computed(() =>
|
|
||||||
isControlled.value ? props.modelValue : localValue.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasError = computed(() => !!props.error)
|
|
||||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
|
||||||
const shouldShowMessage = computed(() => !!(props.hint || hasError.value || hasSuccess.value))
|
|
||||||
const describedBy = computed(() =>
|
|
||||||
props.reserveMessageSpace || shouldShowMessage.value
|
|
||||||
? `${groupId.value}-describedby`
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
const select = (value: RadioValue) => {
|
|
||||||
if (props.readonly || props.disabled) return
|
|
||||||
if (!isControlled.value) localValue.value = value
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
provide(radioGroupContextKey, {
|
|
||||||
name: computed(() => groupId.value),
|
|
||||||
isSelected: (value: RadioValue) => selectedValue.value === value,
|
|
||||||
select,
|
|
||||||
hasError,
|
|
||||||
hasSuccess,
|
|
||||||
disabled: computed(() => props.disabled),
|
|
||||||
readonly: computed(() => props.readonly),
|
|
||||||
required: computed(() => props.required),
|
|
||||||
describedBy,
|
|
||||||
})
|
|
||||||
|
|
||||||
const contentZoneClass = computed(() =>
|
|
||||||
twMerge(
|
|
||||||
props.inline
|
|
||||||
? 'flex flex-wrap items-center gap-x-6 min-h-[3rem]'
|
|
||||||
: 'flex flex-col gap-y-1',
|
|
||||||
props.contentClass,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const mergedGroupClass = computed(() => twMerge('w-full', props.groupClass))
|
|
||||||
|
|
||||||
const mergedLabelClass = computed(() =>
|
|
||||||
twMerge(
|
|
||||||
'mb-1 block text-sm text-m-text',
|
|
||||||
hasError.value ? 'text-m-danger' : '',
|
|
||||||
props.labelClass,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type {ComputedRef, InjectionKey} from 'vue'
|
|
||||||
|
|
||||||
export type RadioValue = string | number | boolean | null | undefined
|
|
||||||
|
|
||||||
export interface RadioGroupContext {
|
|
||||||
name: ComputedRef<string>
|
|
||||||
isSelected: (value: RadioValue) => boolean
|
|
||||||
select: (value: RadioValue) => void
|
|
||||||
hasError: ComputedRef<boolean>
|
|
||||||
hasSuccess: ComputedRef<boolean>
|
|
||||||
disabled: ComputedRef<boolean>
|
|
||||||
readonly: ComputedRef<boolean>
|
|
||||||
required: ComputedRef<boolean>
|
|
||||||
describedBy: ComputedRef<string | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const radioGroupContextKey: InjectionKey<RadioGroupContext> = Symbol('MalioRadioGroup')
|
|
||||||
@@ -107,19 +107,24 @@ describe('MalioSidebar', () => {
|
|||||||
expect(links[2].attributes('href')).toBe('/fournisseurs')
|
expect(links[2].attributes('href')).toBe('/fournisseurs')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hover : fond + couleur + semi-bold tous portés par le <li> (texte non figé sur le <a>)', () => {
|
it('hover : fond + couleur + semi-bold portés par le <li>', () => {
|
||||||
const wrapper = mountComponent({sections})
|
const wrapper = mountComponent({sections})
|
||||||
const li = wrapper.find('li')
|
const li = wrapper.find('li')
|
||||||
expect(li.classes()).toContain('hover:bg-m-primary/10')
|
expect(li.classes()).toContain('hover:bg-m-primary/10')
|
||||||
expect(li.classes()).toContain('hover:text-m-primary')
|
expect(li.classes()).toContain('hover:text-m-primary')
|
||||||
expect(li.classes()).toContain('hover:font-semibold')
|
expect(li.classes()).toContain('hover:font-semibold')
|
||||||
expect(li.classes()).toContain('text-black')
|
expect(li.classes()).toContain('text-black')
|
||||||
expect(li.classes()).toContain('pt-1')
|
})
|
||||||
expect(li.classes()).toContain('pb-1')
|
|
||||||
// Le <a> ne fige PAS sa couleur (sinon le texte resterait noir sur les bandes
|
it('zone cliquable : le padding vertical est sur le <a>, pas sur le <li> (pas de bande morte au survol)', () => {
|
||||||
// pt-1/pb-1 hors du <a> alors que le fond du <li> est bleu).
|
const wrapper = mountComponent({sections})
|
||||||
expect(wrapper.find('a').classes()).not.toContain('text-black')
|
// Le padding vertical doit appartenir à la cible de clic (<a>) pour que toute
|
||||||
expect(wrapper.find('a').classes()).not.toContain('hover:text-m-primary')
|
// la bande survolée soit cliquable — sinon pt-1/pb-1 sur le <li> crée une
|
||||||
|
// bande colorée mais non cliquable en haut et en bas du lien.
|
||||||
|
const li = wrapper.find('li')
|
||||||
|
expect(li.classes()).not.toContain('pt-1')
|
||||||
|
expect(li.classes()).not.toContain('pb-1')
|
||||||
|
expect(wrapper.find('a').classes()).toContain('py-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('actif : route exacte → lien en primary + semi-bold, sans fond', () => {
|
it('actif : route exacte → lien en primary + semi-bold, sans fond', () => {
|
||||||
@@ -239,6 +244,43 @@ describe('MalioSidebar', () => {
|
|||||||
expect(wrapper.find('img[alt="M"]').exists()).toBe(true)
|
expect(wrapper.find('img[alt="M"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders footer slot when expanded', () => {
|
||||||
|
const wrapper = mountComponent({sections}, {
|
||||||
|
footer: '<a href="/logout">Déconnexion</a>',
|
||||||
|
})
|
||||||
|
expect(wrapper.find('a[href="/logout"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('Déconnexion')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders footer-collapsed slot when collapsed', async () => {
|
||||||
|
const wrapper = mountComponent({sections}, {
|
||||||
|
'footer-collapsed': '<span>FC</span>',
|
||||||
|
})
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('FC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('footer is rendered after the nav (pushed to the bottom)', () => {
|
||||||
|
const wrapper = mountComponent({sections}, {
|
||||||
|
footer: '<span class="ft">Footer</span>',
|
||||||
|
})
|
||||||
|
const children = wrapper.find('aside').element.children
|
||||||
|
const navIndex = Array.from(children).findIndex(el => el.tagName === 'NAV')
|
||||||
|
const footerEl = wrapper.find('.ft').element
|
||||||
|
const footerWrapperIndex = Array.from(children).findIndex(el => el.contains(footerEl))
|
||||||
|
expect(footerWrapperIndex).toBeGreaterThan(navIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render a footer container when no footer slot is provided', () => {
|
||||||
|
const wrapper = mountComponent({sections})
|
||||||
|
// Seuls le bloc logo et le <nav> sont des conteneurs (+ le bouton toggle).
|
||||||
|
// Aucun div de footer ne doit apparaître après le <nav>.
|
||||||
|
const children = Array.from(wrapper.find('aside').element.children)
|
||||||
|
const navIndex = children.findIndex(el => el.tagName === 'NAV')
|
||||||
|
const after = children.slice(navIndex + 1)
|
||||||
|
expect(after.some(el => el.tagName === 'DIV')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses custom id when provided', () => {
|
it('uses custom id when provided', () => {
|
||||||
const wrapper = mountComponent({sections, id: 'my-sidebar'})
|
const wrapper = mountComponent({sections, id: 'my-sidebar'})
|
||||||
expect(wrapper.find('aside').attributes('id')).toBe('my-sidebar')
|
expect(wrapper.find('aside').attributes('id')).toBe('my-sidebar')
|
||||||
|
|||||||
@@ -49,14 +49,14 @@
|
|||||||
<li
|
<li
|
||||||
v-for="item in section.items"
|
v-for="item in section.items"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:class="collapsed ? '' : 'text-black hover:bg-m-primary/10 hover:font-semibold hover:text-m-primary pt-1 pb-1'"
|
:class="collapsed ? '' : 'text-black hover:bg-m-primary/10 hover:font-semibold hover:text-m-primary'"
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
active-class="!text-m-primary font-semibold"
|
active-class="!text-m-primary font-semibold"
|
||||||
:class="twMerge(
|
:class="twMerge(
|
||||||
'block truncate text-[15px] leading-[150%]',
|
'block truncate text-[15px] leading-[150%]',
|
||||||
collapsed ? 'px-3 text-center' : 'pl-[32px]',
|
collapsed ? 'px-3 text-center' : 'pl-[32px] py-1',
|
||||||
isActive(item) ? '!text-m-primary font-semibold' : '',
|
isActive(item) ? '!text-m-primary font-semibold' : '',
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
@@ -67,6 +67,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer || $slots['footer-collapsed']"
|
||||||
|
:class="['px-[20px] py-[14px]', collapsed ? '' : 'mx-[10px] border-t-2 border-m-primary']"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
v-if="collapsed"
|
||||||
|
name="footer-collapsed"
|
||||||
|
/>
|
||||||
|
<slot
|
||||||
|
v-else
|
||||||
|
name="footer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="collapsed ? 'Déplier le menu' : 'Plier le menu'"
|
:aria-label="collapsed ? 'Déplier le menu' : 'Plier le menu'"
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Story title="Input/RadioGroup">
|
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Empilé</h2>
|
|
||||||
<MalioRadioGroup v-model="stacked" :options="options" label="Catégorie" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">En ligne</h2>
|
|
||||||
<MalioRadioGroup v-model="inline" :options="yesNo" inline label="Prestation" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
|
||||||
<MalioRadioGroup v-model="errored" :options="yesNo" inline error="Sélection requise" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
|
||||||
<MalioRadioGroup v-model="ok" :options="yesNo" inline success="Enregistré" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
|
||||||
<MalioRadioGroup v-model="disabled" :options="options" disabled label="Catégorie" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Requis + slot</h2>
|
|
||||||
<MalioRadioGroup v-model="slotted" required label="Civilité" inline>
|
|
||||||
<MalioRadioButton value="M" label="Monsieur" />
|
|
||||||
<MalioRadioButton value="Mme" label="Madame" />
|
|
||||||
</MalioRadioGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref} from 'vue'
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{label: 'Catégorie 1', value: 'cat1'},
|
|
||||||
{label: 'Catégorie 2', value: 'cat2'},
|
|
||||||
{label: 'Catégorie 3', value: 'cat3'},
|
|
||||||
]
|
|
||||||
const yesNo = [
|
|
||||||
{label: 'Oui', value: 'oui'},
|
|
||||||
{label: 'Non', value: 'non'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const stacked = ref<string | null>(null)
|
|
||||||
const inline = ref<string | null>('oui')
|
|
||||||
const errored = ref<string | null>(null)
|
|
||||||
const ok = ref<string | null>('oui')
|
|
||||||
const disabled = ref<string | null>('cat2')
|
|
||||||
const slotted = ref<string | null>(null)
|
|
||||||
</script>
|
|
||||||
@@ -43,6 +43,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Avec footer (collé en bas)">
|
||||||
|
<div class="flex h-[600px] border rounded-lg overflow-hidden">
|
||||||
|
<MalioSidebar
|
||||||
|
v-model="collapsed3"
|
||||||
|
:sections="sectionsLong"
|
||||||
|
>
|
||||||
|
<template #logo>
|
||||||
|
<span class="text-2xl font-bold text-m-primary">Malio</span>
|
||||||
|
</template>
|
||||||
|
<template #logo-collapsed>
|
||||||
|
<span class="text-2xl font-bold text-m-primary">M</span>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="leading-tight">
|
||||||
|
<p class="text-[14px] font-semibold text-m-text">Tristan</p>
|
||||||
|
<p class="text-[12px] text-m-muted">Administrateur</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer-collapsed>
|
||||||
|
<span class="block text-center text-[14px] font-bold text-m-primary">T</span>
|
||||||
|
</template>
|
||||||
|
</MalioSidebar>
|
||||||
|
|
||||||
|
<div class="flex-1 p-6 bg-white">
|
||||||
|
<p class="text-m-muted">
|
||||||
|
Le footer reste collé en bas même quand la nav scrolle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
</Story>
|
</Story>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -94,6 +125,15 @@ entre les deux états.
|
|||||||
|
|
||||||
- Contenu affiché en haut quand la sidebar est pliée.
|
- Contenu affiché en haut quand la sidebar est pliée.
|
||||||
|
|
||||||
|
### footer
|
||||||
|
|
||||||
|
- Contenu affiché en bas quand la sidebar est dépliée (profil, déconnexion, version…).
|
||||||
|
- Toujours collé en bas : la nav occupe l'espace restant (`flex-1`) et pousse le footer.
|
||||||
|
|
||||||
|
### footer-collapsed
|
||||||
|
|
||||||
|
- Contenu affiché en bas quand la sidebar est pliée.
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
## Comportement
|
## Comportement
|
||||||
@@ -127,6 +167,7 @@ import MalioSidebar from '../../components/malio/sidebar/Sidebar.vue'
|
|||||||
|
|
||||||
const collapsed1 = ref(false)
|
const collapsed1 = ref(false)
|
||||||
const collapsed2 = ref(false)
|
const collapsed2 = ref(false)
|
||||||
|
const collapsed3 = ref(false)
|
||||||
|
|
||||||
const sectionsShort = [
|
const sectionsShort = [
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,170 +0,0 @@
|
|||||||
# MalioRadioGroup — conception
|
|
||||||
|
|
||||||
## Problème
|
|
||||||
|
|
||||||
`RadioButton.vue` porte le message (`error`/`success`/`hint`) sur **chaque** radio et
|
|
||||||
masque tous les messages sauf le dernier via un hack CSS
|
|
||||||
(`.radio-item:has(+ .radio-item) .radio-message { display: none }`). Conséquences :
|
|
||||||
|
|
||||||
- impossible d'aligner un groupe de radios sur un `MalioSelect` (cercles centrés sur la
|
|
||||||
box, message sur la même ligne que le message du select) ;
|
|
||||||
- pas de `reserveMessageSpace` alors que c'est la convention de tous les autres champs
|
|
||||||
(Select, Input*, Date, Time, Checkbox) ;
|
|
||||||
- l'affichage « un seul message » est un effet de bord CSS fragile.
|
|
||||||
|
|
||||||
Toutes les libs matures (MUI `RadioGroup`/`FormHelperText`, Vuetify `v-radio-group`,
|
|
||||||
Element Plus `el-radio-group`, Ant Design `Radio.Group`) résolvent ça avec un **parent
|
|
||||||
de groupe** qui possède la valeur, le `name` partagé et l'unique message. Le codebase a
|
|
||||||
déjà ce précédent : `Accordion` + `AccordionItem` via `provide/inject` et un
|
|
||||||
`context.ts`.
|
|
||||||
|
|
||||||
## Décisions
|
|
||||||
|
|
||||||
1. **Nouveau composant parent `MalioRadioGroup`** (les `RadioButton` deviennent des
|
|
||||||
inputs simples). `RadioButton` reste utilisable seul.
|
|
||||||
2. **API enfants** : prop `:options` (principal, cohérent avec `MalioSelect`) **+ slot
|
|
||||||
par défaut** en repli (cas custom).
|
|
||||||
3. **Label de groupe** : prop `label` **optionnelle**, rendue au-dessus en `<legend>`
|
|
||||||
(accessibilité). Omise → le groupe s'aligne directement avec un select.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Calquée sur `Accordion`/`AccordionItem`.
|
|
||||||
|
|
||||||
### `app/components/malio/radio/context.ts` (nouveau)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type {InjectionKey} from 'vue'
|
|
||||||
|
|
||||||
export type RadioValue = string | number | boolean | null | undefined
|
|
||||||
|
|
||||||
export interface RadioGroupContext {
|
|
||||||
name: ComputedRef<string>
|
|
||||||
selectedValue: ComputedRef<RadioValue>
|
|
||||||
isSelected: (value: RadioValue) => boolean
|
|
||||||
select: (value: RadioValue) => void
|
|
||||||
hasError: ComputedRef<boolean>
|
|
||||||
hasSuccess: ComputedRef<boolean>
|
|
||||||
disabled: ComputedRef<boolean>
|
|
||||||
readonly: ComputedRef<boolean>
|
|
||||||
required: ComputedRef<boolean>
|
|
||||||
describedBy: ComputedRef<string | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const radioGroupContextKey: InjectionKey<RadioGroupContext> =
|
|
||||||
Symbol('MalioRadioGroup')
|
|
||||||
```
|
|
||||||
|
|
||||||
### `app/components/malio/radio/RadioGroup.vue` (nouveau)
|
|
||||||
|
|
||||||
`defineOptions({name: 'MalioRadioGroup', inheritAttrs: false})`.
|
|
||||||
|
|
||||||
**Props**
|
|
||||||
|
|
||||||
| prop | type | défaut | rôle |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `modelValue` | `RadioValue` | `undefined` | valeur sélectionnée (contrôlé/non-contrôlé) |
|
|
||||||
| `options` | `{label, value, disabled?}[]` | `[]` | radios déclaratifs |
|
|
||||||
| `label` | `string` | `''` | legend optionnelle au-dessus |
|
|
||||||
| `name` | `string` | auto (`useId`) | `name` partagé des inputs |
|
|
||||||
| `inline` | `boolean` | `false` | orientation horizontale |
|
|
||||||
| `disabled` / `readonly` / `required` | `boolean` | `false` | propagés au groupe |
|
|
||||||
| `hint` / `error` / `success` | `string` | `''` | message unique |
|
|
||||||
| `reserveMessageSpace` | `boolean` | `true` | réserve `min-h-[1rem]` (comme Select) |
|
|
||||||
| `groupClass` / `inputClass` / `labelClass` | `string` | `''` | overrides `twMerge` |
|
|
||||||
|
|
||||||
**Rendu**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div :class="mergedGroupClass">
|
|
||||||
<span v-if="label" :id="`${groupId}-label`" :class="mergedLabelClass">
|
|
||||||
{{ label }}<MalioRequiredMark v-if="required" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
:aria-labelledby="label ? `${groupId}-label` : undefined"
|
|
||||||
:aria-invalid="hasError || undefined"
|
|
||||||
:aria-describedby="describedBy"
|
|
||||||
:class="contentClass"
|
|
||||||
>
|
|
||||||
<!-- options -->
|
|
||||||
<MalioRadioButton
|
|
||||||
v-for="opt in options" :key="..."
|
|
||||||
:label="opt.label" :value="opt.value" :disabled="opt.disabled"
|
|
||||||
/>
|
|
||||||
<!-- ou slot custom -->
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="reserveMessageSpace || hasError || hasSuccess || hint"
|
|
||||||
:id="`${groupId}-describedby`"
|
|
||||||
:class="messageClass" <!-- identique au Select : 'mt-1 ml-[2px] text-xs' (+min-h-[1rem]) -->
|
|
||||||
>
|
|
||||||
{{ error || success || hint }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `contentClass` : `inline` → `flex flex-wrap items-center gap-x-6 min-h-10` ;
|
|
||||||
empilé → `flex flex-col`. Le `min-h-10` fait coïncider la rangée de radios avec la box
|
|
||||||
d'un `MalioSelect` (h-10), donc les cercles se centrent sur la box du select.
|
|
||||||
- `messageClass` reprend **exactement** le markup du message de `Select.vue`
|
|
||||||
(`mt-1 ml-[2px] text-xs`, couleur `text-m-danger`/`text-m-success`/`text-m-muted`,
|
|
||||||
`min-h-[1rem]` si `reserveMessageSpace`) → alignement automatique avec le message du
|
|
||||||
select voisin.
|
|
||||||
- `v-model` géré ici : `select(value)` émet `update:modelValue` (et tient `localValue`
|
|
||||||
en non-contrôlé).
|
|
||||||
- `provide(radioGroupContextKey, …)`.
|
|
||||||
|
|
||||||
### `app/components/malio/radio/RadioButton.vue` (modifié)
|
|
||||||
|
|
||||||
- `const group = inject(radioGroupContextKey, null)`.
|
|
||||||
- **Dans un groupe** :
|
|
||||||
- `name` = `group.name` ; `isChecked` = `group.isSelected(value)` ;
|
|
||||||
- styling erreur/succès (cercles rouges/verts) piloté par `group.hasError`/`hasSuccess` ;
|
|
||||||
- `disabled`/`readonly`/`required` = groupe **OU** prop locale ;
|
|
||||||
- au `change` → `group.select(value)` (au lieu d'`emit('update:modelValue')`) ;
|
|
||||||
- **n'affiche aucun message** (`shouldShowMessage = false`) ;
|
|
||||||
- `aria-describedby` = `group.describedBy`.
|
|
||||||
- **Hors groupe** : comportement actuel inchangé.
|
|
||||||
- **Supprimer** le CSS `.radio-item:has(+ .radio-item) .radio-message { display: none }`
|
|
||||||
(remplacé par le vrai groupe).
|
|
||||||
|
|
||||||
## Usage cible (remplace le hack du formulaire client)
|
|
||||||
|
|
||||||
```html
|
|
||||||
<MalioRadioGroup
|
|
||||||
v-model="prestationChoice"
|
|
||||||
:options="prestationOptions"
|
|
||||||
inline
|
|
||||||
error="Sélection requise"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Les cercles s'alignent sur la box du `MalioSelect` voisin et le message sur le message
|
|
||||||
du select, sans réglage manuel.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- `RadioGroup.test.ts` (nouveau) : rendu options + slot, `v-model` (contrôlé/non),
|
|
||||||
message unique, `reserveMessageSpace`, état erreur propagé aux enfants (`aria-invalid`,
|
|
||||||
classe `is-error`), `inline`, `disabled`/`readonly`/`required`, a11y
|
|
||||||
(`role=radiogroup`, `aria-labelledby`, `aria-describedby`).
|
|
||||||
- `RadioButton.test.ts` : ajuster — vérifier le mode groupé (inject) et conserver le
|
|
||||||
mode standalone. Retirer le test du hack CSS s'il existe.
|
|
||||||
|
|
||||||
## Documentation & playground
|
|
||||||
|
|
||||||
- `app/story/radio/RadioGroup.story.vue` (Histoire).
|
|
||||||
- Page playground : variantes du groupe + entrée nav (`playground.nav.ts`).
|
|
||||||
- Revert `client.vue` vers `MalioRadioGroup` (retirer `<style scoped>` et le `<p>`
|
|
||||||
manuel).
|
|
||||||
- Mise à jour manuelle de `COMPONENTS.md` + `CHANGELOG.md`.
|
|
||||||
|
|
||||||
## Hors périmètre (YAGNI)
|
|
||||||
|
|
||||||
- Pas de groupe de checkbox (séparé).
|
|
||||||
- Pas de validation/form-state global.
|
|
||||||
- Pas de layout en grille interne au groupe (inline / empilé suffisent).
|
|
||||||
Reference in New Issue
Block a user