feat(ui) : required cohérent + astérisque label + sanitisation email (MUI-41) (#60)

## Résumé (MUI-41)

Harmonise l'état « obligatoire » des composants de formulaire et normalise le champ email.

### `required` + astérisque
- Nouveau composant partagé `MalioRequiredMark` : astérisque rouge (`text-m-danger`, **16px**), `aria-hidden`.
- Prop `required` désormais cohérente sur toute la famille formulaire ; quand vraie, l'astérisque s'affiche **dans le label**.
- Prop ajoutée à `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText` (les autres l'avaient déjà).
- Accessibilité : `required` natif là où l'élément le supporte, sinon `aria-required` (Select/SelectCheckbox sur le `<button>`, RichText sur le wrapper éditeur, Upload sur le champ visible).
- `MalioSiteSelector` **exclu** volontairement (segmented control, pas de label de champ).

### Sanitisation email (`MalioInputEmail`)
- Suppression de **tous les espaces** à la saisie (pas de masque).
- Nouvelle prop opt-in `lowercase` (défaut `false`) : normalise en minuscules à la frappe (cohérent RG-1.21 Starseed).
- Garde défensive curseur : l'API de sélection est interdite sur `type="email"` → repositionnement best-effort sans jamais lever.
- La validation de format reste à la couche `error`.

### Docs & playground
- `COMPONENTS.md` (doc `required` cohérente + note famille + `lowercase`) et `CHANGELOG.md` mis à jour.
- Exemples playground `required` et email `lowercase` ajoutés.

## Test plan
- [x] Suite complète : 42 fichiers / 771 tests verts
- [x] Lint : 0 erreur
- [x] Tests `aria-required` sur Select/SelectCheckbox/RichText
- [ ] Vérif visuelle playground : astérisque 16px dans le label, email qui retire les espaces / minuscule

Spec & plan : `docs/superpowers/specs/` et `docs/superpowers/plans/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #60
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #60.
This commit is contained in:
2026-06-04 06:42:19 +00:00
committed by Autin
parent aedfaa865d
commit 887ebdebd7
58 changed files with 3192 additions and 167 deletions
+30 -11
View File
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -44,7 +44,7 @@
<button
v-if="addable"
type="button"
:disabled="disabled || readonly"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@@ -60,6 +60,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -67,7 +68,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -82,6 +84,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})
@@ -109,6 +112,7 @@ const props = withDefaults(
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -133,6 +137,7 @@ const props = withDefaults(
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter un numéro',
reserveMessageSpace: true,
},
)
@@ -144,10 +149,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -156,31 +166,39 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -189,7 +207,7 @@ const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
@@ -249,6 +267,7 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor