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:
@@ -8,8 +8,10 @@
|
||||
:id="buttonId"
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary"
|
||||
class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none"
|
||||
:class="[
|
||||
isReadonly ? '' : 'grow-height',
|
||||
isReadonly ? '' : 'focus-visible:border-m-primary',
|
||||
hasError
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
@@ -22,14 +24,16 @@
|
||||
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||
: 'border-m-success'
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
||||
: isReadonly
|
||||
? 'border-black'
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
|
||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||
rounded,
|
||||
textField,
|
||||
@@ -38,6 +42,8 @@
|
||||
:aria-controls="listboxId"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -50,16 +56,20 @@
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
: isReadonly
|
||||
? isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
: 'text-m-muted'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
textLabel,
|
||||
]"
|
||||
:style="labelTransformStyle"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<span
|
||||
@@ -82,11 +92,15 @@
|
||||
? 'text-m-success'
|
||||
: disabled
|
||||
? 'text-m-muted'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
: isReadonly
|
||||
? isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted'
|
||||
]"
|
||||
>
|
||||
<slot name="icon">
|
||||
@@ -152,6 +166,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${buttonId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -159,7 +174,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs min-h-[1rem]',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
@@ -171,6 +187,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})
|
||||
|
||||
@@ -191,8 +208,11 @@ const props = withDefaults(defineProps<{
|
||||
textLabel?: string
|
||||
rounded?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
groupClass?: string
|
||||
noOptionsText?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(), {
|
||||
options: () => [],
|
||||
emptyOptionLabel: '',
|
||||
@@ -205,8 +225,11 @@ const props = withDefaults(defineProps<{
|
||||
textLabel: 'text-sm',
|
||||
rounded: 'rounded-md',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
groupClass: '',
|
||||
noOptionsText: 'Aucune option disponible',
|
||||
required: false,
|
||||
reserveMessageSpace: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -234,8 +257,9 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isOptionSelected = computed(() =>
|
||||
props.options.some(o => o.value === props.modelValue)
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isOpen.value || isOptionSelected.value
|
||||
isReadonly.value ? isOptionSelected.value : (isOpen.value || isOptionSelected.value)
|
||||
)
|
||||
const selectedLabel = computed(() =>
|
||||
props.options.find(o => o.value === props.modelValue)?.label ?? ''
|
||||
@@ -263,6 +287,7 @@ function updateOpenDirection() {
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (props.disabled || props.readonly) return
|
||||
updateOpenDirection()
|
||||
isOpen.value = true
|
||||
|
||||
@@ -306,7 +331,7 @@ function close() {
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) return
|
||||
if (props.disabled || props.readonly) return
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user