Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc03559dcf | |||
| 6b1e11bd6f | |||
| 4f5eaaacb9 | |||
| 2d8639a913 | |||
| 3e09f4278e | |||
| 4e2303c471 | |||
| 6081f0c90c | |||
| 120020b210 | |||
| 61cb90a9c6 | |||
| 167cc43870 | |||
| 03fe458248 | |||
| df289aa829 | |||
| 05949b727e |
@@ -84,6 +84,24 @@
|
||||
:success="dynamicSuccess"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Email obligatoire</h2>
|
||||
<MalioInputEmail
|
||||
v-model="requiredEmail"
|
||||
label="Email obligatoire"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Email normalisé (minuscules)</h2>
|
||||
<MalioInputEmail
|
||||
v-model="lowercaseEmail"
|
||||
label="Email normalisé (minuscules)"
|
||||
:lowercase="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -92,6 +110,8 @@ import { computed, ref } from 'vue'
|
||||
|
||||
const emailValue = ref('')
|
||||
const dynamicEmail = ref('')
|
||||
const requiredEmail = ref('')
|
||||
const lowercaseEmail = ref('')
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
|
||||
|
||||
@@ -108,6 +108,14 @@
|
||||
icon-size="20"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Champ obligatoire</h2>
|
||||
<MalioInputText
|
||||
label="Champ obligatoire"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
|
||||
<MalioInputText
|
||||
|
||||
@@ -82,6 +82,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sélection obligatoire</h2>
|
||||
<MalioSelect
|
||||
v-model="requiredValue"
|
||||
:options="options"
|
||||
label="Sélection obligatoire"
|
||||
:required="true"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
|
||||
<MalioSelect
|
||||
@@ -151,6 +162,7 @@ const longOptions = [
|
||||
{label: 'Republique tcheque', value: 'cz'},
|
||||
]
|
||||
|
||||
const requiredValue = ref<string | number | null>(null)
|
||||
const basicValue = ref<string | number | null>(null)
|
||||
const labelValue = ref<string | number | null>(null)
|
||||
const selectedValue = ref<string | number | null>('fr')
|
||||
|
||||
@@ -39,6 +39,8 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
|
||||
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
|
||||
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
|
||||
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
|
||||
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||
|
||||
### Changed
|
||||
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
||||
|
||||
+25
-9
@@ -2,6 +2,8 @@
|
||||
|
||||
Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-model` pour le binding bidirectionnel sur les composants de formulaire.
|
||||
|
||||
> **Champ obligatoire :** sur les composants de formulaire, la prop `required` ajoute un astérisque rouge dans le label. C'est un repère visuel ; la sémantique « obligatoire » est portée par l'attribut natif `required` ou `aria-required`.
|
||||
|
||||
---
|
||||
|
||||
## MalioInputText
|
||||
@@ -15,7 +17,7 @@ Champ texte avec label, icône optionnelle et support de masque de saisie.
|
||||
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
@@ -53,6 +55,7 @@ Champ mot de passe avec toggle visibilité.
|
||||
| `displayIcon` | `boolean` | `true` | Afficher l'icône toggle |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
@@ -79,7 +82,8 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
|
||||
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
@@ -91,6 +95,8 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
|
||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||
|
||||
> **Sanitisation à la saisie :** tous les espaces sont supprimés automatiquement au fil de la frappe (sans masque). Avec `lowercase=true`, la valeur est également convertie en minuscules à la frappe. La validation du format (ex. présence d'un `@`) reste à la charge du parent via la prop `error` ou la couche de validation.
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
|
||||
```vue
|
||||
@@ -115,7 +121,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
|
||||
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
@@ -169,7 +175,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
||||
| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
@@ -231,6 +237,7 @@ Champ montant avec icône devise (euro par défaut).
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `iconName` | `string` | `'mdi:currency-eur'` | Icône devise |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
@@ -253,6 +260,7 @@ Champ numérique avec boutons +/-.
|
||||
| `min` | `number \| string` | — | Valeur minimum |
|
||||
| `max` | `number \| string` | — | Valeur maximum |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
@@ -276,6 +284,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
||||
| `maxLength` | `number` | `800` | Longueur max |
|
||||
| `showCounter` | `boolean` | `false` | Afficher le compteur |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
|
||||
|
||||
@@ -304,6 +313,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
||||
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
|
||||
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule (toolbar visible mais désactivée) |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
@@ -334,6 +344,7 @@ Champ d'upload de fichier.
|
||||
| `accept` | `string` | `''` | Types de fichiers acceptés |
|
||||
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
|
||||
@@ -358,6 +369,7 @@ Liste déroulante.
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
|
||||
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
||||
@@ -389,6 +401,7 @@ Liste déroulante multi-sélection avec checkboxes.
|
||||
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
|
||||
|
||||
**Events :** `update:modelValue(value: (string | number)[])`
|
||||
@@ -410,6 +423,7 @@ Case à cocher.
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
|
||||
**Events :** `update:modelValue(value: boolean)`
|
||||
@@ -433,6 +447,7 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
||||
| `name` | `string` | `''` | Nom du groupe radio |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
|
||||
**Events :** `update:modelValue(value: string | number | boolean | null)`
|
||||
|
||||
@@ -456,7 +471,7 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
@@ -490,7 +505,7 @@ La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
@@ -523,7 +538,7 @@ La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
@@ -553,6 +568,7 @@ Sélecteur d'heure.
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
@@ -575,7 +591,7 @@ Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes in
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
|
||||
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
@@ -608,7 +624,7 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
|
||||
@@ -161,4 +161,14 @@ describe('MalioCheckbox', () => {
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountCheckbox({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountCheckbox({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -40,6 +40,16 @@ describe('MalioDate', () => {
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountDate({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountDate({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays the formatted value in the field', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
@@ -100,6 +100,7 @@
|
||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../../shared/RequiredMark.vue'
|
||||
import CalendarHeader from './CalendarHeader.vue'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||
|
||||
@@ -53,6 +53,16 @@ describe('MalioInputText', () => {
|
||||
expect(wrapper.get('label').text()).toBe('labelTest')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInput({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInput({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the name attribute', () => {
|
||||
const wrapper = mountInput({name: 'nameTest'})
|
||||
|
||||
|
||||
@@ -174,4 +174,14 @@ describe('MalioInputAmount', () => {
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -63,6 +63,7 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ describe('MalioInputAutocomplete', () => {
|
||||
expect(wrapper.get('label').text()).toBe('Pays')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders with type combobox role', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -151,6 +151,7 @@
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type InputEmailProps = {
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
lowercase?: boolean
|
||||
}
|
||||
|
||||
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
||||
@@ -52,6 +53,16 @@ describe('MalioInputEmail', () => {
|
||||
expect(wrapper.get('label').text()).toBe('Adresse email')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has type email', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -225,4 +236,42 @@ describe('MalioInputEmail', () => {
|
||||
|
||||
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
|
||||
})
|
||||
|
||||
it('supprime tous les espaces saisis', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
|
||||
expect(wrapper.get('input').element.value).toBe('ab@c.com')
|
||||
})
|
||||
|
||||
it('conserve la casse par défaut', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.get('input').setValue('User@Example.COM')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
|
||||
})
|
||||
|
||||
it('met en minuscules quand lowercase est vrai', async () => {
|
||||
const wrapper = mountComponent({lowercase: true})
|
||||
await wrapper.get('input').setValue('User@Example.COM')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['user@example.com'])
|
||||
})
|
||||
|
||||
it('émet la valeur sanitisée en mode contrôlé', async () => {
|
||||
const wrapper = mountComponent({modelValue: ''})
|
||||
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['ab@c.com'])
|
||||
})
|
||||
|
||||
it('resynchronise le DOM en mode contrôlé même quand la valeur sanitisée égale déjà modelValue', async () => {
|
||||
// L'utilisateur ajoute un espace en fin alors que la valeur nettoyée vaut déjà modelValue.
|
||||
// Le parent ne « changera » pas modelValue → Vue ne re-patche pas le DOM ; l'écriture
|
||||
// manuelle target.value = sanitized est donc indispensable pour retirer l'espace affiché.
|
||||
const wrapper = mountComponent({modelValue: 'ab@c.com'})
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('ab@c.com ')
|
||||
expect(input.element.value).toBe('ab@c.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -62,6 +62,7 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||
|
||||
@@ -85,6 +86,7 @@ const props = withDefaults(
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
lowercase?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -105,6 +107,7 @@ const props = withDefaults(
|
||||
success: '',
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
lowercase: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -169,12 +172,37 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const sanitizeEmail = (v: string) => {
|
||||
let out = v.replace(/\s+/g, '')
|
||||
if (props.lowercase) out = out.toLowerCase()
|
||||
return out
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!isControlled.value) {
|
||||
localValue.value = target.value
|
||||
const raw = target.value
|
||||
const sanitized = sanitizeEmail(raw)
|
||||
|
||||
if (sanitized !== raw) {
|
||||
// `<input type="email">` ne supporte pas l'API de sélection :
|
||||
// selectionStart vaut null et setSelectionRange lève en navigateur.
|
||||
// (En jsdom selectionStart peut renvoyer un nombre, d'où le code gardé ci-dessous.)
|
||||
const caret = target.selectionStart
|
||||
target.value = sanitized
|
||||
if (caret !== null) {
|
||||
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
|
||||
try {
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
} catch {
|
||||
/* type d'input sans support de sélection — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
emit('update:modelValue', target.value)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = sanitized
|
||||
}
|
||||
emit('update:modelValue', sanitized)
|
||||
}
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import InputNumber from './InputNumber.vue'
|
||||
type InputNumberProps = {
|
||||
modelValue?: string | null
|
||||
label?: string
|
||||
required?: boolean
|
||||
readonly?: boolean
|
||||
min?: number | string
|
||||
max?: number | string
|
||||
@@ -162,4 +163,14 @@ describe('MalioInputNumber', () => {
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
|
||||
expect(input.element.value).toBe('5')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@@ -70,6 +70,7 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -51,6 +51,16 @@ describe('MalioInputPassword', () => {
|
||||
expect(wrapper.get('label').text()).toBe('Mot de passe')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has type password by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -67,6 +67,7 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -56,6 +56,16 @@ describe('MalioInputPhone', () => {
|
||||
expect(wrapper.get('label').text()).toBe('Téléphone')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has type tel', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -82,6 +82,7 @@ import {vMaska} from 'maska/vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type InputRichTextProps = {
|
||||
groupClass?: string
|
||||
labelClass?: string
|
||||
editorClass?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
|
||||
@@ -155,6 +156,18 @@ describe('MalioInputRichText', () => {
|
||||
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
|
||||
})
|
||||
|
||||
it('expose aria-required quand required est vrai', async () => {
|
||||
const wrapper = await mountComponent({required: true})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'expose pas aria-required par défaut', async () => {
|
||||
const wrapper = await mountComponent()
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders initial markdown content visually', async () => {
|
||||
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
|
||||
|
||||
@@ -162,4 +175,16 @@ describe('MalioInputRichText', () => {
|
||||
expect(html).toContain('Mon titre')
|
||||
expect(html).toContain('Un paragraphe.')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', async () => {
|
||||
const wrapper = await mountComponent({label: 'Champ', required: true})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', async () => {
|
||||
const wrapper = await mountComponent({label: 'Champ'})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:for="editorId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<!-- Mode lecture seule (rendu uniquement) -->
|
||||
@@ -22,6 +22,7 @@
|
||||
v-else
|
||||
:id="editorId"
|
||||
:class="mergedEditorWrapperClass"
|
||||
:aria-required="required || undefined"
|
||||
@click="focusEditor"
|
||||
>
|
||||
<div
|
||||
@@ -210,6 +211,7 @@ import Color from '@tiptap/extension-color'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
|
||||
|
||||
@@ -232,6 +234,7 @@ const props = withDefaults(
|
||||
groupClass?: string
|
||||
labelClass?: string
|
||||
editorClass?: string
|
||||
required?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -249,6 +252,7 @@ const props = withDefaults(
|
||||
groupClass: '',
|
||||
labelClass: '',
|
||||
editorClass: '',
|
||||
required: false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -66,6 +66,7 @@ import {vMaska} from 'maska/vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -183,4 +183,14 @@ describe('MalioInputTextArea', () => {
|
||||
|
||||
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', required: true}})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
textLabel,
|
||||
]"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
<span
|
||||
v-if="showCounterComputed"
|
||||
@@ -83,6 +83,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ type InputUploadProps = {
|
||||
success?: string
|
||||
displayIcon?: boolean
|
||||
accept?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
|
||||
@@ -167,6 +168,11 @@ describe('MalioInputUpload', () => {
|
||||
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false')
|
||||
})
|
||||
|
||||
it('expose aria-required sur le champ visible quand required est vrai', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||
expect(wrapper.get('input[type="text"]').attributes('aria-required')).toBe('true')
|
||||
})
|
||||
|
||||
it('passes accept attribute to file input', () => {
|
||||
const wrapper = mountComponent({accept: '.pdf,.doc'})
|
||||
|
||||
@@ -186,4 +192,16 @@ describe('MalioInputUpload', () => {
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
:accept="accept"
|
||||
class="hidden"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
@change="onFileChange"
|
||||
>
|
||||
|
||||
@@ -19,6 +20,7 @@
|
||||
:value="currentDisplayValue"
|
||||
:readonly="true"
|
||||
:aria-invalid="!!error"
|
||||
:aria-required="required || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
@@ -33,7 +35,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -70,6 +72,7 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||
|
||||
@@ -87,6 +90,7 @@ const props = withDefaults(
|
||||
success?: string
|
||||
displayIcon?: boolean
|
||||
accept?: string
|
||||
required?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -101,6 +105,7 @@ const props = withDefaults(
|
||||
success: '',
|
||||
displayIcon: true,
|
||||
accept: '',
|
||||
required: false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -173,6 +173,16 @@ describe('MalioRadioButton', () => {
|
||||
expect(wrapper.get('input').classes()).toContain('checked:border-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountRadioButton({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountRadioButton({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type SelectProps = {
|
||||
textLabel?: string
|
||||
rounded?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const SelectForTest = Select as DefineComponent<SelectProps>
|
||||
@@ -260,6 +261,38 @@ describe('MalioSelect', () => {
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ', required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ'},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('expose aria-required quand required est vrai', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options, required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'expose pas aria-required par défaut', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
:aria-controls="listboxId"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -59,7 +60,7 @@
|
||||
]"
|
||||
:style="labelTransformStyle"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<span
|
||||
@@ -171,6 +172,7 @@
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||
|
||||
@@ -193,6 +195,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
groupClass?: string
|
||||
noOptionsText?: string
|
||||
required?: boolean
|
||||
}>(), {
|
||||
options: () => [],
|
||||
emptyOptionLabel: '',
|
||||
@@ -207,6 +210,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled: false,
|
||||
groupClass: '',
|
||||
noOptionsText: 'Aucune option disponible',
|
||||
required: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -25,6 +25,7 @@ type SelectCheckboxProps = {
|
||||
selectAllLabel?: string
|
||||
disabled?: boolean
|
||||
groupClass?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
|
||||
@@ -235,6 +236,38 @@ describe('MalioSelectCheckbox', () => {
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], label: 'Champ', required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], label: 'Champ'},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('expose aria-required quand required est vrai', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options, required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'expose pas aria-required par défaut', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
:aria-controls="listboxId"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -59,7 +60,7 @@
|
||||
]"
|
||||
:style="labelTransformStyle"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
@@ -221,6 +222,7 @@ import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import Checkbox from '../checkbox/Checkbox.vue'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||
|
||||
@@ -246,6 +248,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
groupClass?: string
|
||||
noOptionsText?: string
|
||||
required?: boolean
|
||||
}>(), {
|
||||
options: () => [],
|
||||
emptyOptionLabel: '',
|
||||
@@ -263,6 +266,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled: false,
|
||||
groupClass: '',
|
||||
noOptionsText: 'Aucune option disponible',
|
||||
required: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import RequiredMark from './RequiredMark.vue'
|
||||
|
||||
describe('MalioRequiredMark', () => {
|
||||
it('rend un astérisque', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.text()).toBe('*')
|
||||
})
|
||||
|
||||
it('est masqué pour les technologies d\'assistance', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('utilise le token de couleur danger', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-m-danger')
|
||||
})
|
||||
|
||||
it('rend l\'astérisque à 16px', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-[16px]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<span
|
||||
data-test="required-mark"
|
||||
aria-hidden="true"
|
||||
class="ml-0.5 select-none text-[16px] leading-none text-m-danger"
|
||||
>*</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
|
||||
</script>
|
||||
@@ -76,4 +76,14 @@ describe('MalioTime', () => {
|
||||
expect(inputs[0].classes()).toContain('border-m-primary')
|
||||
expect(inputs[1].classes()).not.toContain('border-m-primary')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountTime({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountTime({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:for="hoursInputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -76,6 +76,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioTime', inheritAttrs: false})
|
||||
|
||||
|
||||
@@ -73,4 +73,14 @@ describe('MalioTimePicker', () => {
|
||||
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('Heure requise')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountPicker({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountPicker({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
@@ -93,6 +93,7 @@
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import TimeWheels from './internal/TimeWheels.vue'
|
||||
|
||||
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
# État « obligatoire » cohérent + normalisation email — 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:** Exposer une prop `required` cohérente avec astérisque rouge dans le label sur toute la famille formulaire, et ajouter une sanitisation à la saisie (suppression des espaces + option `lowercase`) à `MalioInputEmail`.
|
||||
|
||||
**Architecture :** Un composant présentational partagé `MalioRequiredMark` (astérisque `aria-hidden`, token `text-m-danger`) est importé explicitement et rendu dans le `<label>` de chaque composant quand `required` est vrai. Les 4 composants sans la prop la reçoivent (+ câblage `aria-required` là où il n'y a pas de `required` natif). `MalioInputEmail.onInput` sanitise la valeur avant émission.
|
||||
|
||||
**Tech Stack :** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Tailwind (palette `m-*`), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
|
||||
|
||||
**Spec :** `docs/superpowers/specs/2026-06-03-required-asterisk-email-sanitization-design.md`
|
||||
|
||||
**Conventions de test (rappel) :** chaque fichier `*.test.ts` définit son propre helper de montage (nom variable : `mountInput`, `mountDate`, `mountCheckbox`, `mountTime`, `mountComponent`…) ou monte en inline. Le tableau de chaque tâche indique le helper exact à réutiliser.
|
||||
|
||||
**⚠️ Suite flaky :** des timeouts intermittents existent sur diverses suites. Si un test échoue par timeout sans rapport avec le changement, relancer le fichier ciblé ; ne pas conclure à un échec sans relance. Le hook pre-commit lance les tests — si un timeout flaky bloque un commit déjà vérifié manuellement, utiliser `git commit --no-verify`.
|
||||
|
||||
**Branche :** `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (rester dessus, ne pas créer de branche).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Composant partagé `MalioRequiredMark`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/malio/shared/RequiredMark.vue`
|
||||
- Test: `app/components/malio/shared/RequiredMark.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
Create `app/components/malio/shared/RequiredMark.test.ts` :
|
||||
|
||||
```ts
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import RequiredMark from './RequiredMark.vue'
|
||||
|
||||
describe('MalioRequiredMark', () => {
|
||||
it('rend un astérisque', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.text()).toBe('*')
|
||||
})
|
||||
|
||||
it('est masqué pour les technologies d’assistance', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('utilise le token de couleur danger', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-m-danger')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
|
||||
|
||||
Run: `npm run test -- app/components/malio/shared/RequiredMark.test.ts`
|
||||
Expected: FAIL — `Failed to resolve import './RequiredMark.vue'` (le composant n'existe pas encore).
|
||||
|
||||
- [ ] **Step 3 : Créer le composant**
|
||||
|
||||
Create `app/components/malio/shared/RequiredMark.vue` :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<span
|
||||
data-test="required-mark"
|
||||
aria-hidden="true"
|
||||
class="ml-0.5 select-none text-m-danger"
|
||||
>*</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test, vérifier le succès**
|
||||
|
||||
Run: `npm run test -- app/components/malio/shared/RequiredMark.test.ts`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/shared/RequiredMark.vue app/components/malio/shared/RequiredMark.test.ts
|
||||
git commit -m "feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Prop `required` + a11y + astérisque sur les 4 composants sans la prop
|
||||
|
||||
Composants : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`. Chacun reçoit la prop `required`, le câblage a11y adapté, l'import + le rendu de l'astérisque, et un test.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/select/Select.vue`, `app/components/malio/select/SelectCheckbox.vue`, `app/components/malio/input/InputUpload.vue`, `app/components/malio/input/InputRichText.vue`
|
||||
- Test: `app/components/malio/select/Select.test.ts`, `app/components/malio/select/SelectCheckbox.test.ts`, `app/components/malio/input/InputUpload.test.ts`, `app/components/malio/input/InputRichText.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent (un par composant)**
|
||||
|
||||
Patron d'assertion (à adapter au helper de chaque fichier) :
|
||||
|
||||
```ts
|
||||
it('affiche l’astérisque quand required est vrai', () => {
|
||||
const wrapper = /* monter avec { label: 'Champ', required: true, ...props requises } */
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n’affiche pas l’astérisque par défaut', () => {
|
||||
const wrapper = /* monter avec { label: 'Champ', ...props requises } */
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
```
|
||||
|
||||
Montage par fichier :
|
||||
|
||||
| Fichier test | Montage |
|
||||
|---|---|
|
||||
| `select/Select.test.ts` | inline : `mount(SelectForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` (et sans `required` pour le 2ᵉ test) |
|
||||
| `select/SelectCheckbox.test.ts` | inline : `mount(SelectCheckboxForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` |
|
||||
| `input/InputUpload.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` |
|
||||
| `input/InputRichText.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` |
|
||||
|
||||
> Note : pour `Select`/`SelectCheckbox`, reprendre la forme exacte des `options` et les `global.stubs` déjà utilisés dans les autres `it()` du fichier (copier un montage voisin).
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts`
|
||||
Expected: FAIL sur les nouveaux tests « affiche l’astérisque » (la prop/le rendu n'existent pas encore).
|
||||
|
||||
- [ ] **Step 3 : Ajouter la prop `required` (type + défaut) dans les 4 composants**
|
||||
|
||||
Dans chaque `defineProps<{…}>()`, ajouter la ligne :
|
||||
|
||||
```ts
|
||||
required?: boolean
|
||||
```
|
||||
|
||||
Dans chaque `withDefaults(…, { … })`, ajouter :
|
||||
|
||||
```ts
|
||||
required: false,
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Câbler l'accessibilité (un élément interactif par composant)**
|
||||
|
||||
`Select.vue` — sur le `<button>` déclencheur (là où sont déjà `:aria-expanded`, `:aria-controls`), ajouter :
|
||||
|
||||
```vue
|
||||
:aria-required="required || undefined"
|
||||
```
|
||||
|
||||
`SelectCheckbox.vue` — idem, sur son `<button>` déclencheur :
|
||||
|
||||
```vue
|
||||
:aria-required="required || undefined"
|
||||
```
|
||||
|
||||
`InputUpload.vue` — sur l'`<input type="file">`, ajouter l'attribut natif :
|
||||
|
||||
```vue
|
||||
:required="required"
|
||||
```
|
||||
|
||||
`InputRichText.vue` — sur le wrapper éditeur identifié par `:id="editorId"` (le conteneur de `<EditorContent>` en mode éditable), ajouter :
|
||||
|
||||
```vue
|
||||
:aria-required="required || undefined"
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Importer et rendre l'astérisque dans les 4 composants**
|
||||
|
||||
Dans le `<script setup>` de chacun, ajouter l'import (chemin relatif depuis `family/Component.vue`) :
|
||||
|
||||
```ts
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
```
|
||||
|
||||
Dans le `<template>`, remplacer le rendu du libellé `{{ label }}` (celui à l'intérieur du `<label>` du champ — **pas** un `{{ opt.label }}`) par :
|
||||
|
||||
```vue
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
```
|
||||
|
||||
> Respecter l'indentation existante de chaque fichier. Pour `Select`/`SelectCheckbox`, viser le `{{ label }}` du `<label>` flottant, pas le `{{ opt.label }}` des options.
|
||||
|
||||
- [ ] **Step 6 : Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts`
|
||||
Expected: PASS (anciens + nouveaux tests). En cas de timeout flaky non lié, relancer le fichier concerné.
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/select/Select.vue app/components/malio/select/SelectCheckbox.vue app/components/malio/input/InputUpload.vue app/components/malio/input/InputRichText.vue app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts
|
||||
git commit -m "feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Astérisque sur les composants ayant déjà `required`
|
||||
|
||||
Ces composants ont déjà la prop `required` (câblée nativement). On ajoute uniquement l'import + le rendu de l'astérisque + un test.
|
||||
|
||||
**Files (16 composants → 13 via CalendarField mutualisé) :**
|
||||
|
||||
| Composant `.vue` | Import à ajouter | Fichier test | Helper de montage |
|
||||
|---|---|---|---|
|
||||
| `input/InputText.vue` | `'../shared/RequiredMark.vue'` | `input/Input.test.ts` | `mountInput({label:'Champ', required:true})` |
|
||||
| `input/InputEmail.vue` | `'../shared/RequiredMark.vue'` | `input/InputEmail.test.ts` | `mountComponent({label:'Champ', required:true})` |
|
||||
| `input/InputPhone.vue` | `'../shared/RequiredMark.vue'` | `input/InputPhone.test.ts` | `mountComponent({label:'Champ', required:true})` |
|
||||
| `input/InputPassword.vue` | `'../shared/RequiredMark.vue'` | `input/InputPassword.test.ts` | `mountComponent({label:'Champ', required:true})` |
|
||||
| `input/InputTextArea.vue` | `'../shared/RequiredMark.vue'` | `input/InputTextArea.test.ts` | helper du fichier (`mount<…>` ; copier un montage voisin) |
|
||||
| `input/InputAmount.vue` | `'../shared/RequiredMark.vue'` | `input/InputAmount.test.ts` | helper du fichier |
|
||||
| `input/InputNumber.vue` | `'../shared/RequiredMark.vue'` | `input/InputNumber.test.ts` | helper du fichier |
|
||||
| `input/InputAutocomplete.vue` | `'../shared/RequiredMark.vue'` | `input/InputAutocomplete.test.ts` | `mountComponent({label:'Champ', required:true, …props requises})` |
|
||||
| `checkbox/Checkbox.vue` | `'../shared/RequiredMark.vue'` | `checkbox/Checkbox.test.ts` | `mountCheckbox({label:'Champ', required:true})` |
|
||||
| `radio/RadioButton.vue` | `'../shared/RequiredMark.vue'` | `radio/RadioButton.test.ts` | helper du fichier |
|
||||
| `time/Time.vue` | `'../shared/RequiredMark.vue'` | `time/Time.test.ts` | `mountTime({label:'Champ', required:true})` |
|
||||
| `time/TimePicker.vue` | `'../shared/RequiredMark.vue'` | `time/TimePicker.test.ts` | helper du fichier |
|
||||
| `date/internal/CalendarField.vue` | `'../../shared/RequiredMark.vue'` | `date/Date.test.ts` | `mountDate({label:'Champ', required:true})` |
|
||||
|
||||
> `CalendarField` rend le label de tout le date family (`Date`, `DateTime`, `DateRange`, `DateWeek`). Une seule modif + un seul test (via `Date.test.ts`) couvrent les quatre.
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent (un couple par fichier test du tableau)**
|
||||
|
||||
Pour chaque fichier test listé, ajouter :
|
||||
|
||||
```ts
|
||||
it('affiche l’astérisque quand required est vrai', () => {
|
||||
const wrapper = /* helper du tableau, avec required: true */
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n’affiche pas l’astérisque par défaut', () => {
|
||||
const wrapper = /* helper du tableau, sans required */
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts`
|
||||
Expected: FAIL sur les nouveaux tests « affiche l’astérisque ».
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'import + le rendu de l'astérisque dans les 13 `.vue`**
|
||||
|
||||
Dans chaque `<script setup>`, ajouter l'import indiqué dans la colonne « Import à ajouter ».
|
||||
|
||||
Dans chaque `<template>`, transformer le libellé du champ :
|
||||
|
||||
```vue
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
```
|
||||
|
||||
(Le `{{ label }}` est à l'intérieur du `<label v-if="label">` du champ. Respecter l'indentation propre à chaque fichier.)
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts`
|
||||
Expected: PASS. (Vérifier notamment que `input/InputEmail.test.ts` « renders the label text » → `'Adresse email'` passe toujours : pas de `required` dans ce test, donc pas d'astérisque.) Relancer en cas de timeout flaky.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/internal/CalendarField.vue app/components/malio/date/Date.test.ts
|
||||
git commit -m "feat(ui): astérisque required dans le label de la famille formulaire"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Sanitisation de `MalioInputEmail`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputEmail.vue`
|
||||
- Test: `app/components/malio/input/InputEmail.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||
|
||||
Ajouter à `input/InputEmail.test.ts` :
|
||||
|
||||
```ts
|
||||
it('supprime tous les espaces saisis', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
|
||||
expect(wrapper.get('input').element.value).toBe('ab@c.com')
|
||||
})
|
||||
|
||||
it('conserve la casse par défaut', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.get('input').setValue('User@Example.COM')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
|
||||
})
|
||||
|
||||
it('met en minuscules quand lowercase est vrai', async () => {
|
||||
const wrapper = mountComponent({lowercase: true})
|
||||
await wrapper.get('input').setValue('User@Example.COM')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['user@example.com'])
|
||||
})
|
||||
```
|
||||
|
||||
> Ajouter `lowercase?: boolean` au type `InputEmailProps` en tête du fichier de test (sinon TS refuse la prop dans le 3ᵉ test).
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `npm run test -- app/components/malio/input/InputEmail.test.ts`
|
||||
Expected: FAIL — les espaces ne sont pas supprimés / `lowercase` inconnu.
|
||||
|
||||
- [ ] **Step 3 : Ajouter la prop `lowercase`**
|
||||
|
||||
Dans `defineProps<{…}>()` de `InputEmail.vue`, ajouter :
|
||||
|
||||
```ts
|
||||
lowercase?: boolean
|
||||
```
|
||||
|
||||
Dans `withDefaults(…, { … })`, ajouter :
|
||||
|
||||
```ts
|
||||
lowercase: false,
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter la fonction de sanitisation et réécrire `onInput`**
|
||||
|
||||
Ajouter la fonction pure (au-dessus de `onInput`) :
|
||||
|
||||
```ts
|
||||
const sanitizeEmail = (v: string) => {
|
||||
let out = v.replace(/\s+/g, '')
|
||||
if (props.lowercase) out = out.toLowerCase()
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Remplacer le `onInput` existant par :
|
||||
|
||||
```ts
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const raw = target.value
|
||||
const sanitized = sanitizeEmail(raw)
|
||||
|
||||
if (sanitized !== raw) {
|
||||
// `<input type="email">` ne supporte pas l'API de sélection :
|
||||
// selectionStart vaut null, setSelectionRange lève. On garde defensivement.
|
||||
const caret = target.selectionStart
|
||||
target.value = sanitized
|
||||
if (caret !== null) {
|
||||
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
|
||||
try {
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
} catch {
|
||||
/* type d'input sans support de sélection — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = sanitized
|
||||
}
|
||||
emit('update:modelValue', sanitized)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `npm run test -- app/components/malio/input/InputEmail.test.ts`
|
||||
Expected: PASS (anciens tests inclus, dont « emits update:modelValue on input change » avec `'new@example.com'` qui n'a pas d'espace → inchangé).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputEmail.vue app/components/malio/input/InputEmail.test.ts
|
||||
git commit -m "feat(inputs): sanitisation email (suppression des espaces + option lowercase)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Documentation (`COMPONENTS.md` + `CHANGELOG.md`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`, `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : `COMPONENTS.md` — lignes `required` manquantes**
|
||||
|
||||
Pour les sections `MalioSelect`, `MalioSelectCheckbox`, `MalioInputUpload`, `MalioInputRichText`, ajouter dans le tableau des props la ligne (au même format que les autres composants) :
|
||||
|
||||
```
|
||||
| `required` | `boolean` | `false` | Champ requis (affiche un astérisque rouge dans le label) |
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : `COMPONENTS.md` — note astérisque + prop `lowercase`**
|
||||
|
||||
- Dans l'introduction de la famille formulaire (ou la section des props communes), ajouter une phrase : « Lorsque `required` est vrai, un astérisque rouge est ajouté dans le label (visuel ; la sémantique est portée par l'attribut `required`/`aria-required`). »
|
||||
- Dans la section `MalioInputEmail`, ajouter la ligne de prop :
|
||||
|
||||
```
|
||||
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
|
||||
```
|
||||
|
||||
et préciser que les espaces sont supprimés automatiquement à la saisie (pas de masque ; la validation de format reste à la couche `error`).
|
||||
|
||||
- [ ] **Step 3 : `CHANGELOG.md` — entrées**
|
||||
|
||||
Sous le `### Added` de la version en cours (format `* [#…] …`), ajouter :
|
||||
|
||||
```
|
||||
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire
|
||||
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Exemples playground + vérification finale
|
||||
|
||||
**Files:**
|
||||
- Modify: page(s) playground des composants concernés (selon `.playground/` ; cf. mémoire « Architecture playground »)
|
||||
|
||||
- [ ] **Step 1 : Ajouter des exemples légers**
|
||||
|
||||
Sur la page playground d'un composant représentatif (ex. `InputText`/`Select`), ajouter une instance `:required="true"`. Sur la page `InputEmail`, ajouter une instance `:lowercase="true"`. Si le coût d'intégration dépasse quelques minutes (routage/nav à câbler), le **noter** et passer — c'est hors scope strict du ticket.
|
||||
|
||||
- [ ] **Step 2 : Lint**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 0 erreur. Corriger le cas échéant.
|
||||
|
||||
- [ ] **Step 3 : Suite de tests complète des fichiers touchés**
|
||||
|
||||
Run: `npm run test -- app/components/malio/shared app/components/malio/input app/components/malio/select app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date`
|
||||
Expected: PASS. En cas de timeout flaky, relancer le(s) fichier(s) concerné(s) individuellement.
|
||||
|
||||
- [ ] **Step 4 : Commit (si exemples playground ajoutés)**
|
||||
|
||||
```bash
|
||||
git add .playground
|
||||
git commit -m "docs(playground): exemples required + email lowercase"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif des commits attendus
|
||||
|
||||
1. `feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)`
|
||||
2. `feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText`
|
||||
3. `feat(ui): astérisque required dans le label de la famille formulaire`
|
||||
4. `feat(inputs): sanitisation email (suppression des espaces + option lowercase)`
|
||||
5. `docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)`
|
||||
6. `docs(playground): exemples required + email lowercase` (optionnel)
|
||||
@@ -0,0 +1,168 @@
|
||||
# Design — État « obligatoire » cohérent + normalisation email
|
||||
|
||||
- **Date** : 2026-06-03
|
||||
- **Ticket Malio UI** : MUI-41 (branche `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co`)
|
||||
- **Ticket Starseed lié** : ERP-101 (MAJ Malio UI + branchement `required` + stratégie de validation), découvert pendant ERP-63 (écran « Ajouter un client »)
|
||||
|
||||
## Contexte & problème
|
||||
|
||||
Pendant ERP-63, deux manques ont bloqué la mise en place de champs obligatoires :
|
||||
|
||||
1. Certains composants de formulaire n'exposent pas de prop `required` (`MalioSelect`, `MalioSelectCheckbox`), et **aucun composant n'affiche d'indicateur visuel** de champ obligatoire. Résultat : le bouton « Valider » se bloque sans feedback à l'utilisateur — anti-pattern UX.
|
||||
2. Tentation erronée de « masquer » l'email à la maska. Un email n'a **pas** de structure fixe : pas de masque. Le bon comportement est une **sanitisation** légère à la saisie + validation déléguée à la couche `error`.
|
||||
|
||||
État réel constaté (inventaire) : la **majorité** des composants ont déjà la prop `required` (câblée sur l'attribut HTML natif uniquement, sans astérisque). Seuls **5** ne l'ont pas : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`, `SiteSelector`. Aucun composant n'affiche d'astérisque. Il n'existe pas de composant de label partagé : chaque composant rend `{{ label }}` dans son propre `<label>` au style spécifique (floating labels).
|
||||
|
||||
## Objectifs
|
||||
|
||||
- Prop `required: boolean` cohérente sur **toute la famille formulaire**.
|
||||
- Quand `required` est vrai → **astérisque rouge dans le label**.
|
||||
- `MalioInputEmail` : sanitisation à la saisie (suppression de tous les espaces, option `lowercase`), **sans** masque ni validation de format.
|
||||
- Mettre à jour `COMPONENTS.md` et `CHANGELOG.md`.
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Validation de format email (reste à la charge de la couche validation via la prop `error`, alimentée serveur ou check client).
|
||||
- Toute logique de masque sur l'email.
|
||||
- Refonte des suites de tests existantes.
|
||||
|
||||
## Décisions de cadrage (validées avec l'utilisateur)
|
||||
|
||||
| Décision | Choix retenu |
|
||||
|---|---|
|
||||
| Périmètre `required` + astérisque | **Toute la famille formulaire**, y compris `InputUpload`, `InputRichText`, `SiteSelector` |
|
||||
| Prop `lowercase` (email) | **Opt-in, défaut `false`** |
|
||||
| Espaces email | **Supprimer tous les espaces** (début, milieu, fin) ; préservation du curseur *best-effort* (voir caveat ci-dessous) |
|
||||
| Accessibilité astérisque | `aria-hidden="true"` — la sémantique est portée par l'attribut HTML natif `required` |
|
||||
|
||||
## Section 1 — Indicateur « obligatoire »
|
||||
|
||||
### Composant partagé `MalioRequiredMark`
|
||||
|
||||
Nouveau composant `app/components/malio/shared/RequiredMark.vue` (auto-importé `<MalioRequiredMark>`). Source unique de vérité pour couleur/espacement.
|
||||
|
||||
Rendu :
|
||||
|
||||
```vue
|
||||
<span aria-hidden="true" class="ml-0.5 select-none text-m-danger">*</span>
|
||||
```
|
||||
|
||||
- `aria-hidden="true"` : évite la double annonce, la sémantique est déjà sur l'attribut natif `required`.
|
||||
- Couleur via token existant `text-m-danger` (`--m-danger`, rouge `#F2696B`).
|
||||
- `defineOptions({ name: 'MalioRequiredMark', inheritAttrs: false })`.
|
||||
|
||||
### Intégration
|
||||
|
||||
Dans chaque composant de la famille, remplacer `{{ label }}` par :
|
||||
|
||||
```vue
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
```
|
||||
|
||||
L'astérisque vit **à l'intérieur du `<label>`** → il flotte avec le floating-label et reste dans la pastille blanche.
|
||||
|
||||
### Props à ajouter
|
||||
|
||||
`required?: boolean` (défaut `false`) sur les **4** composants qui ne l'ont pas et qui possèdent un label de champ : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`.
|
||||
|
||||
### Câblage accessibilité (a11y)
|
||||
|
||||
L'astérisque est `aria-hidden` : la sémantique « obligatoire » doit donc être portée par le DOM.
|
||||
|
||||
- **Élément natif `required` déjà câblé** (asterisque suffit) : `InputText`, `InputEmail`, `InputPhone`, `InputPassword`, `InputTextArea`, `InputAmount`, `InputNumber`, `InputAutocomplete`, `Checkbox`, `RadioButton`, `Time`, `TimePicker`, et `CalendarField` (date family).
|
||||
- **Pas de `required` natif** → ajouter `:aria-required="required || undefined"` sur l'élément interactif :
|
||||
- `Select` / `SelectCheckbox` : le `<button>` déclencheur (combobox).
|
||||
- `InputRichText` : le wrapper éditeur (`#editorId`, contenteditable via TipTap).
|
||||
- `InputUpload` : possède un `<input type="file">` natif → on câble `:required="required"` dessus (natif).
|
||||
|
||||
### Composants concernés par le rendu de l'astérisque
|
||||
|
||||
`InputText`, `InputEmail`, `InputPhone`, `InputPassword`, `InputTextArea`, `InputAmount`, `InputNumber`, `InputAutocomplete`, `InputUpload`, `InputRichText`, `Select`, `SelectCheckbox`, `Checkbox`, `RadioButton`, `Time`, `TimePicker`, et `CalendarField` (rendu mutualisé pour `Date`, `DateTime`, `DateRange`, `DateWeek`).
|
||||
|
||||
### Exclusion : `SiteSelector`
|
||||
|
||||
`MalioSiteSelector` est un **radiogroup de tuiles** (segmented control) : il n'a **pas de label de champ** (son `labelClass` style le nom de chaque tuile). Y placer un astérisque n'a pas de sens. Il est **exclu** du périmètre `required`/astérisque. À rouvrir si un besoin de « groupe obligatoire » émerge (ce serait alors un libellé de groupe distinct, hors de ce ticket).
|
||||
|
||||
### Alternative écartée
|
||||
|
||||
Inliner un `<span>` dans chaque composant : duplication, couleur/espacement à changer à ~20 endroits. Le composant partagé est préféré.
|
||||
|
||||
## Section 2 — Sanitisation `MalioInputEmail`
|
||||
|
||||
### Nouvelle prop
|
||||
|
||||
`lowercase?: boolean` (défaut `false`).
|
||||
|
||||
### Fonction de sanitisation (pure, testable)
|
||||
|
||||
```ts
|
||||
const sanitizeEmail = (v: string) => {
|
||||
let out = v.replace(/\s+/g, '') // supprime TOUT espace
|
||||
if (props.lowercase) out = out.toLowerCase()
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
### `onInput` réécrit
|
||||
|
||||
```ts
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const raw = target.value
|
||||
const sanitized = sanitizeEmail(raw)
|
||||
|
||||
if (sanitized !== raw) {
|
||||
// `<input type="email">` ne supporte PAS l'API de sélection :
|
||||
// selectionStart vaut null, setSelectionRange lève une exception.
|
||||
// On garde donc la repositionnement défensif (no-op sur type=email).
|
||||
const caret = target.selectionStart
|
||||
target.value = sanitized
|
||||
if (caret !== null) {
|
||||
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
|
||||
try {
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
} catch {
|
||||
/* type d'input sans support de sélection — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isControlled.value) localValue.value = sanitized
|
||||
emit('update:modelValue', sanitized)
|
||||
}
|
||||
```
|
||||
|
||||
Points clés :
|
||||
|
||||
- **Resynchro DOM** : `target.value = sanitized` même en mode contrôlé, pour que l'affichage colle toujours à la valeur émise.
|
||||
- **Caveat curseur** : la spec HTML interdit l'API de sélection sur `type="email"` (`selectionStart` = `null`, `setSelectionRange` lève). La repositionnement est donc **best-effort** et inactif sur l'email : sur le cas rare d'une suppression d'espace en milieu de chaîne, le curseur peut aller en fin. Les cas courants (espace en fin, collage) gardent naturellement le curseur en fin. Le code est gardé (`caret !== null` + `try/catch`) pour ne jamais lever.
|
||||
- **Collage** couvert (paste déclenche `input`).
|
||||
- **Inchangé** : `type="email"`, `inputmode="email"`, icône, et **aucune validation de format**.
|
||||
|
||||
## Section 3 — Tests, docs & livraison
|
||||
|
||||
### Tests (colocalisés `*.test.ts`)
|
||||
|
||||
- `RequiredMark.test.ts` — rend `*`, `aria-hidden="true"`, classe `text-m-danger`.
|
||||
- 1 test ciblé par composant équipé : `required: true` → astérisque présent dans le label ; défaut → absent. S'appuie sur le helper `mountComponent` existant de chaque fichier.
|
||||
- `InputEmail.test.ts` — espaces (début/milieu/fin) supprimés ; `lowercase=false` préserve la casse ; `lowercase=true` minuscule ; valeur émise sanitisée ; valeur DOM resynchronisée. Le curseur n'est pas testé (peu fiable en jsdom) → on teste la valeur.
|
||||
|
||||
⚠️ Suite de tests **flaky** connue (timeouts intermittents). Lancer les tests des fichiers touchés ; en cas de timeout non lié aux changements, relancer / documenter plutôt que conclure à un échec.
|
||||
|
||||
### Documentation (manuelle, requise par convention)
|
||||
|
||||
- `COMPONENTS.md` : ajouter la ligne `required` aux 5 composants manquants ; ajouter `lowercase` à `MalioInputEmail` ; mentionner en intro famille formulaire que `required` affiche un astérisque rouge.
|
||||
- `CHANGELOG.md` : entrée(s) `MUI-41` sous `### Added`, format existant (`* [#MUI-41] ...`).
|
||||
|
||||
### Playground / Histoire
|
||||
|
||||
Ajouter un exemple `required` + un exemple email `lowercase` sur les pages playground concernées si coût faible ; sinon signaler (hors scope strict).
|
||||
|
||||
### Découpage de livraison (1 PR, commits Conventional)
|
||||
|
||||
1. `feat(ui): MalioRequiredMark + prop required sur Select/SelectCheckbox/Upload/RichText/SiteSelector`
|
||||
2. `feat(ui): astérisque required dans le label de la famille formulaire`
|
||||
3. `feat(inputs): sanitisation email (suppression espaces + option lowercase)`
|
||||
4. `docs: COMPONENTS.md + CHANGELOG`
|
||||
|
||||
Branche : `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (inchangée).
|
||||
Reference in New Issue
Block a user