fix(date) : borne la saisie clavier pour empêcher les dates absurdes (99/99/9999) (#79)

## Problème

Sur la famille Date editable, le masque maska n'imposait que la *forme* (`##/##/####`). Une valeur structurellement absurde comme `99/99/9999` était donc **saisissable**, puis rejetée *a posteriori* par la validation. Le métier veut que ce soit **impossible à taper**.

## Solution (masque borné + validation en filet)

- `composables/maskTemplate.ts` — `buildBoundedMask(template)` : borne le **premier chiffre de chaque champ** (jour `0-3`, mois `0-1`, heure `0-2`, minute `0-5`). Distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit, pour ne pas brider la saisie des minutes du DateTime.
- `internal/CalendarField.vue` — branche le builder dans `maskaOptions` (remplace le `replace(/[A-Za-z]/g, '#')`).

Les impossibilités plus fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la **validation** (`invalidMessage` + `update:valid=false`).

## Tests

- `maskTemplate.test.ts` (5) — bornes par champ, structure du masque, non-confusion mois/minutes.
- `Date.test.ts` — test `invalidMessage` adapté (`32/13/2026`, typable→invalide) + garde de non-régression : `99/99/9999` ne s'inscrit jamais et n'émet aucune date.
- Suite complète : **1004/1004 verte** (DateTime 36 incluse → saisie d'heure intacte).

Doc : `COMPONENTS.md` (MalioDate) + `CHANGELOG.md` (Fixed) à jour.
Reviewed-on: #79
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #79.
This commit is contained in:
2026-06-19 13:04:11 +00:00
committed by Autin
parent 06c739cdc7
commit be3d88ed45
31 changed files with 939 additions and 125 deletions
+66 -13
View File
@@ -73,6 +73,18 @@ describe('MalioDate', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on calendar icon click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on calendar icon click in editable mode', async () => {
const wrapper = mountDate({editable: true})
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on the current month when there is no value', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
@@ -226,6 +238,23 @@ describe('MalioDate', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('disabled : label grisé', () => {
const wrapper = mountDate({disabled: true, label: 'Date'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('disabled : pas de croix d\'effacement même avec une valeur', () => {
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
})
it('disabled + rempli : icône calendrier grisée (pas noire)', () => {
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
const icon = wrapper.get('[data-test="calendar-icon"]')
expect(icon.classes()).toContain('text-m-muted')
expect(icon.classes()).not.toContain('text-black')
})
it('does not open when readonly', async () => {
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
await wrapper.get('[data-test="date-input"]').trigger('click')
@@ -308,7 +337,7 @@ describe('MalioDate', () => {
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-19'})
@@ -338,10 +367,10 @@ describe('MalioDate', () => {
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
expect((input.element as HTMLInputElement).value).toBe('31/02/2026')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide')
})
@@ -366,7 +395,7 @@ describe('MalioDate', () => {
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await input.trigger('focus')
@@ -391,10 +420,34 @@ describe('MalioDate', () => {
it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999')
// 31/02/2026 : champs valides (jour ≤ 31, mois ≤ 12) mais le 31 février n'existe pas.
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect')
})
it('empêche la frappe d\'une date absurde (99/99/9999 borné par le masque)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999')
await input.trigger('blur')
// Le bornage refuse « 9 » dès le 1er chiffre du jour/mois : la saisie absurde
// ne s'inscrit jamais et aucune date réelle n'est émise.
expect((input.element as HTMLInputElement).value).not.toContain('99')
const emitted = wrapper.emitted('update:modelValue') ?? []
expect(emitted.every(([value]) => value === null)).toBe(true)
})
it('empêche un jour > 31 ou un mois > 12 (exemple métier 33/19, 2e chiffre borné)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
// 33 (jour) : le 2e « 3 » est refusé → seul « 3 » subsiste.
await input.setValue('33')
expect((input.element as HTMLInputElement).value).toBe('3')
// 19 en mois : après un jour valide, le 2e chiffre du mois (« 9 ») est refusé.
await input.setValue('15/19')
expect((input.element as HTMLInputElement).value).not.toContain('19')
})
})
describe('gabarit de saisie (editable)', () => {
@@ -438,9 +491,9 @@ describe('MalioDate', () => {
it('vide le champ au clic sur la croix même après une saisie invalide (modelValue déjà null)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
expect((input.element as HTMLInputElement).value).toBe('31/02/2026')
await wrapper.get('[data-test="clear"]').trigger('click')
expect((input.element as HTMLInputElement).value).toBe('')
})
@@ -468,7 +521,7 @@ describe('MalioDate', () => {
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
@@ -507,7 +560,7 @@ describe('MalioDate', () => {
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
await wrapper.setProps({modelValue: '2026-05-19'})
@@ -519,9 +572,9 @@ describe('MalioDate', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026'])
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
@@ -560,9 +613,9 @@ describe('MalioDate', () => {
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026'])
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
+24 -10
View File
@@ -145,14 +145,27 @@ describe('MalioDateTime', () => {
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('32/13/2026 14:30')
expect((input.element as HTMLInputElement).value).toBe('31/02/2026 14:30')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide')
})
it('empêche la frappe d\'un datetime absurde (99/99/9999 99:99 borné par le masque)', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999 99:99')
await input.trigger('blur')
// Le masque borne le 1er chiffre de chaque champ (jour 0-3, mois 0-1,
// heure 0-2, minute 0-5) : « 9 » est rejeté partout, rien ne s'inscrit
// et aucun datetime réel n'est émis.
expect((input.element as HTMLInputElement).value).not.toContain('99')
const emitted = wrapper.emitted('update:modelValue') ?? []
expect(emitted.every(([value]) => value === null)).toBe(true)
})
it('passe en erreur si le datetime saisi est hors min/max', async () => {
const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
const input = wrapper.get('[data-test="date-input"]')
@@ -184,7 +197,8 @@ describe('MalioDateTime', () => {
it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999 10:00')
// 31/02 : champs valides mais date inexistante (le masque la laisse passer, la validation la rejette).
await input.setValue('31/02/2026 10:00')
await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect')
})
@@ -192,7 +206,7 @@ describe('MalioDateTime', () => {
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
@@ -246,7 +260,7 @@ describe('MalioDateTime', () => {
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
@@ -276,7 +290,7 @@ describe('MalioDateTime', () => {
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
@@ -288,9 +302,9 @@ describe('MalioDateTime', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30'])
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
@@ -329,9 +343,9 @@ describe('MalioDateTime', () => {
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30'])
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
@@ -0,0 +1,44 @@
import {describe, it, expect} from 'vitest'
import {buildBoundedMask} from './maskTemplate'
describe('buildBoundedMask', () => {
it('dérive le masque structurel du gabarit (séparateurs conservés)', () => {
expect(buildBoundedMask('JJ/MM/AAAA').mask).toBe('##/##/####')
expect(buildBoundedMask('JJ/MM/AAAA HH:MM').mask).toBe('##/##/#### ##:##')
})
})
describe('preProcess — bornage de la saisie (1er ET 2e chiffre)', () => {
const pre = (template: string, value: string) => buildBoundedMask(template).preProcess!(value)
it('jour : refuse > 31 et 00, accepte 01-31', () => {
expect(pre('JJ/MM/AAAA', '32')).toBe('3') // 32 impossible → 2e chiffre refusé
expect(pre('JJ/MM/AAAA', '33')).toBe('3') // exemple métier : 33 refusé
expect(pre('JJ/MM/AAAA', '31')).toBe('31')
expect(pre('JJ/MM/AAAA', '00')).toBe('0') // 00 impossible
expect(pre('JJ/MM/AAAA', '09')).toBe('09')
})
it('mois : refuse > 12 et 00 (après un jour valide) — cas 33/19', () => {
expect(pre('JJ/MM/AAAA', '0119')).toBe('011') // 19 (mois) refusé
expect(pre('JJ/MM/AAAA', '0113')).toBe('011')
expect(pre('JJ/MM/AAAA', '0112')).toBe('0112')
expect(pre('JJ/MM/AAAA', '0100')).toBe('010')
})
it('laisse lannée libre', () => {
expect(pre('JJ/MM/AAAA', '01012026')).toBe('01012026')
})
it('heure 00-23 et minute 00-59 (datetime), sans confondre minute et mois', () => {
const t = 'JJ/MM/AAAA HH:MM'
expect(pre(t, '010120262300')).toBe('010120262300') // 23:00 ok
expect(pre(t, '010120262460')).toBe('010120262') // heure 24 refusée
expect(pre(t, '010120261259')).toBe('010120261259') // minute 59 ok (≠ mois)
expect(pre(t, '010120261260')).toBe('0101202612') // minute 60 refusée
})
it('stoppe à la première saisie invalide (99/99/9999 → rien)', () => {
expect(pre('JJ/MM/AAAA', '99/99/9999')).toBe('')
})
})
@@ -0,0 +1,91 @@
import type {MaskInputOptions} from 'maska'
// Un champ numérique du gabarit : sa longueur et la plage de valeurs autorisée.
interface Field {
length: number
min: number
max: number
}
// Découpe un gabarit (ex. `JJ/MM/AAAA HH:MM`) en champs numériques bornés.
// Le `M` désigne le mois avant les heures, et les minutes après — d'où le suivi
// de `seenHour`, pour ne pas borner les minutes comme un mois (0-59 vs 1-12).
function parseFields(template: string): Field[] {
const fields: Field[] = []
let seenHour = false
let i = 0
while (i < template.length) {
const ch = template[i]!
if (!/[A-Za-z]/.test(ch)) {
i++ // séparateur (/, espace, :)
continue
}
let j = i
while (j < template.length && template[j] === ch) j++
const length = j - i
const letter = ch.toUpperCase()
if (letter === 'H') seenHour = true
if (letter === 'J') fields.push({length, min: 1, max: 31})
else if (letter === 'M') fields.push(seenHour ? {length, min: 0, max: 59} : {length, min: 1, max: 12})
else if (letter === 'H') fields.push({length, min: 0, max: 23})
else fields.push({length, min: 0, max: 10 ** length - 1}) // année (ou autre) : libre
i = j
}
return fields
}
// Un chiffre est accepté tant qu'il existe encore une complétion valide du champ :
// on borne la valeur partielle [min possible (padding 0), max possible (padding 9)]
// et on vérifie qu'elle croise la plage autorisée [field.min, field.max].
function canComplete(partial: string, field: Field): boolean {
const low = Number(partial.padEnd(field.length, '0'))
const high = Number(partial.padEnd(field.length, '9'))
return high >= field.min && low <= field.max
}
// Ne conserve que les chiffres qui gardent chaque champ complétable, et s'arrête
// au premier chiffre invalide (rien de ce qui suit n'est réinterprété). maska
// réinsère ensuite les séparateurs via le masque structurel.
function clampDigits(rawDigits: string, fields: Field[]): string {
let result = ''
let di = 0
for (const field of fields) {
let fieldDigits = ''
while (fieldDigits.length < field.length) {
if (di >= rawDigits.length) return result + fieldDigits // plus de saisie
const candidate = fieldDigits + rawDigits[di]
if (!canComplete(candidate, field)) return result + fieldDigits // 1er chiffre invalide → stop
fieldDigits = candidate
di++
}
result += fieldDigits
}
return result
}
/**
* Construit les options maska d'un champ date/heure à partir d'un gabarit
* d'affichage (ex. `JJ/MM/AAAA`, `JJ/MM/AAAA HH:MM`).
*
* - `mask` : masque structurel (chiffres + séparateurs), pour le formatage/eager.
* - `preProcess` : borne la saisie AVANT masquage, sur le 1er **et** le 2e chiffre
* de chaque champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59), si bien
* qu'une valeur impossible (99/99/9999, 33, 19 en mois…) ne peut pas être tapée.
* Les impossibilités calendaires fines (31/02, 29/02 non bissextile) et les
* bornes `min`/`max` restent du ressort de la validation, en filet.
*/
export function buildBoundedMask(template: string): Pick<MaskInputOptions, 'mask' | 'preProcess'> {
const mask = template.replace(/[A-Za-z]/g, '#')
const fields = parseFields(template)
return {
mask,
preProcess: (value: string) => clampDigits(value.replace(/\D/g, ''), fields),
}
}
@@ -70,7 +70,8 @@
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
:class="[iconStateClass, (disabled || readonly) ? 'cursor-not-allowed' : 'cursor-pointer']"
@click="onFieldClick"
/>
</div>
@@ -128,6 +129,7 @@ import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
import {buildBoundedMask} from '../composables/maskTemplate'
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
@@ -190,12 +192,15 @@ const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const draft = ref(props.displayValue)
// Le masque maska est dérivé du gabarit (lettres → slot `#`, séparateurs conservés).
// eager : pose les séparateurs (/, espace, :) dès qu'un groupe est complet.
const maskaOptions = computed<MaskInputOptions>(() => ({
mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined,
eager: props.editable,
}))
// Le masque maska est dérivé du gabarit : masque structurel pour le formatage,
// + preProcess qui borne la saisie (1er ET 2e chiffre : jour 1-31, mois 1-12,
// heure 0-23, minute 0-59) afin qu'une valeur impossible (99/99/9999, 33, mois 19…)
// ne puisse pas être tapée. eager : pose les séparateurs dès qu'un groupe est complet.
const maskaOptions = computed<MaskInputOptions>(() => {
if (!props.editable) return {eager: false}
const {mask, preProcess} = buildBoundedMask(props.placeholderTemplate)
return {mask, preProcess, eager: true}
})
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
@@ -353,11 +358,13 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
: props.disabled
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
@@ -365,6 +372,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
@@ -375,6 +375,12 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
})
it('hides the chevron when disabled', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('sets readonly attribute', () => {
const wrapper = mountComponent({readonly: true})
@@ -64,7 +64,7 @@
class="animate-spin text-m-primary"
/>
<IconifyIcon
v-else
v-else-if="!disabled"
icon="mdi:chevron-down"
:width="20"
:height="20"
+2 -10
View File
@@ -339,12 +339,10 @@ describe('MalioInputEmail', () => {
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit add when disabled', async () => {
it('hides the add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('does not emit add when readonly', async () => {
@@ -355,12 +353,6 @@ describe('MalioInputEmail', () => {
expect(wrapper.emitted('add')).toBeUndefined()
})
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
+1 -2
View File
@@ -41,9 +41,8 @@
/>
<button
v-if="addable"
v-if="addable && !disabled"
type="button"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
+1 -1
View File
@@ -205,7 +205,7 @@ const mergedLabelClass = computed(() =>
'cursor-pointer text-black mr-4 text-[18px]',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
props.disabled ? 'cursor-not-allowed text-black/60' : '',
props.disabled ? 'cursor-not-allowed text-m-muted' : '',
props.labelClass,
),
)
@@ -91,6 +91,12 @@ describe('MalioInputPassword', () => {
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('hides the eye icon when disabled', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('renders icon by default', () => {
const wrapper = mountComponent()
+1 -1
View File
@@ -33,7 +33,7 @@
</label>
<IconifyIcon
v-if="displayIcon"
v-if="displayIcon && !disabled"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24"
:height="24"
+2 -10
View File
@@ -253,12 +253,10 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit add when disabled', async () => {
it('hides the add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('does not emit add when readonly', async () => {
@@ -269,12 +267,6 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toBeUndefined()
})
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
+1 -2
View File
@@ -42,9 +42,8 @@
/>
<button
v-if="addable"
v-if="addable && !disabled"
type="button"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
+11 -2
View File
@@ -237,12 +237,21 @@ describe('MalioSelect', () => {
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
it('hides the chevron when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the selected value when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', label: 'Pays', options, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('button span.block').classes()).toContain('text-black/60')
})
it('shows danger chevron color on error even when open', async () => {
+12 -9
View File
@@ -65,15 +65,17 @@
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted',
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
@@ -85,13 +87,14 @@
class="block truncate"
:class="[
textValue,
isOptionSelected ? 'text-black' : 'select-none text-transparent'
isOptionSelected ? (disabled ? 'text-black/60' : 'text-black') : 'select-none text-transparent'
]"
>
{{ selectedLabel || '\u00A0' }}
</span>
<span
v-if="!disabled"
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
@@ -227,12 +227,23 @@ describe('MalioSelectCheckbox', () => {
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
it('hides the chevron when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the tags when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], label: 'Pays', options, displayTag: true, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
const tag = wrapper.findAll('span.inline-flex')[0]
expect(tag.classes()).toContain('text-black/60')
expect(tag.classes()).not.toContain('text-black')
})
it('shows danger chevron color on error even when open', async () => {
+14 -10
View File
@@ -65,15 +65,17 @@
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted',
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
@@ -89,7 +91,8 @@
<span
v-for="option in selectedOptions"
:key="String(option.value)"
class="inline-flex max-w-full items-center rounded-md border border-black px-2 text-sm leading-none text-black"
class="inline-flex max-w-full items-center rounded-md border px-2 text-sm leading-none"
:class="disabled ? 'border-black/40 text-black/60' : 'border-black text-black'"
>
<span class="truncate pb-[2px]">{{ option.label }}</span>
</span>
@@ -113,13 +116,14 @@
:class="[
textValue,
label ? 'pl-24' : '',
isOptionSelected ? 'text-black' : 'text-m-muted'
disabled ? 'text-black/60' : isOptionSelected ? 'text-black' : 'text-m-muted'
]"
>
{{ selectionSummary }}
</span>
<span
v-if="!disabled"
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
+24 -1
View File
@@ -62,7 +62,7 @@ describe('MalioSidebar', () => {
it('renders expanded by default', () => {
const wrapper = mountComponent({sections})
const aside = wrapper.find('aside')
expect(aside.classes()).toContain('w-[280px]')
expect(aside.classes()).toContain('w-[232px]')
})
it('renders section labels with icons when expanded', () => {
@@ -89,6 +89,29 @@ describe('MalioSidebar', () => {
expect(links[2].attributes('href')).toBe('/fournisseurs')
})
it('hover : fond + couleur + semi-bold tous portés par le <li> (texte non figé sur le <a>)', () => {
const wrapper = mountComponent({sections})
const li = wrapper.find('li')
expect(li.classes()).toContain('hover:bg-m-primary/10')
expect(li.classes()).toContain('hover:text-m-primary')
expect(li.classes()).toContain('hover:font-semibold')
expect(li.classes()).toContain('text-black')
expect(li.classes()).toContain('pt-1')
expect(li.classes()).toContain('pb-1')
// Le <a> ne fige PAS sa couleur (sinon le texte resterait noir sur les bandes
// pt-1/pb-1 hors du <a> alors que le fond du <li> est bleu).
expect(wrapper.find('a').classes()).not.toContain('text-black')
expect(wrapper.find('a').classes()).not.toContain('hover:text-m-primary')
})
it('actif : texte primary + semi-bold, sans fond, via active-class', () => {
const wrapper = mountComponent({sections})
const activeClass = wrapper.find('a').attributes('active-class') ?? ''
expect(activeClass).toContain('text-m-primary')
expect(activeClass).toContain('font-semibold')
expect(activeClass).not.toContain('bg-')
})
it('renders section icons via IconifyIcon', () => {
const wrapper = mountComponent({sections})
const icons = wrapper.findAllComponents(IconifyIcon)
+4 -3
View File
@@ -28,7 +28,7 @@
<div
v-if="section.label"
:class="[
'flex items-center gap-2 pt-2 pb-3',
'flex items-center gap-2 pt-2 pb-2',
collapsed ? 'justify-center pt-[40px]' : '',
]"
>
@@ -49,12 +49,13 @@
<li
v-for="item in section.items"
:key="item.to"
:class="collapsed ? '' : 'pb-2 last:pb-1'"
:class="collapsed ? '' : 'text-black hover:bg-m-primary/10 hover:font-semibold hover:text-m-primary pt-1 pb-1'"
>
<NuxtLink
:to="item.to"
active-class="!text-m-primary font-semibold"
:class="twMerge(
'block truncate rounded-md text-[15px] text-m-text text-black transition-colors hover:bg-m-surface leading-[150%]',
'block truncate text-[15px] leading-[150%]',
collapsed ? 'px-3 text-center' : 'pl-[32px]',
)"
>
+10 -12
View File
@@ -16,7 +16,6 @@ type TabListProps = {
modelValue?: string
id?: string
maxVisibleTabs?: number
maxWidth?: number
}
const TabListForTest = TabList as DefineComponent<TabListProps>
@@ -157,7 +156,16 @@ describe('MalioTabList', () => {
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
expect(buttons[1].classes()).toContain('cursor-not-allowed')
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70')
expect(buttons[1].classes()).not.toContain('hover:text-m-primary')
})
it('hover sur un onglet inactif applique le même style que l\'actif (texte plein + barre)', () => {
const wrapper = mountComponent({tabs})
const inactive = wrapper.findAll('[role="tab"]')[1]
expect(inactive.attributes('aria-selected')).toBe('false')
expect(inactive.classes()).toContain('hover:text-m-primary')
expect(inactive.classes()).toContain('hover:after:bg-m-primary')
expect(inactive.classes()).toContain('hover:after:h-[3px]')
})
it('does not emit update:modelValue when clicking a disabled tab', async () => {
@@ -199,16 +207,6 @@ describe('MalioTabList — fenêtrage maxVisibleTabs', () => {
{key: 't7', label: 'Tab 7'},
]
it('applies the default maxWidth (1100px) on the tabs container when windowed', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1100px')
})
it('applies a custom maxWidth on the tabs container', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5, maxWidth: 1200})
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1200px')
})
it('renders only maxVisibleTabs buttons and disables prev at start', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
const buttons = wrapper.findAll('[role="tab"]')
+84 -14
View File
@@ -1,5 +1,30 @@
<template>
<div v-bind="$attrs">
<div
ref="rootRef"
v-bind="$attrs"
>
<!-- Ligne de mesure cachée : largeur réelle de chaque onglet (mêmes classes de
layout, placeholder à la place de l'icône pour ne pas fausser les tests),
afin de calculer combien d'onglets tiennent. Invisible et hors flux. -->
<div
ref="measureRef"
aria-hidden="true"
class="pointer-events-none invisible absolute left-0 top-0 flex"
>
<span
v-for="tab in tabs"
:key="tab.key"
class="flex items-center gap-[18px] text-[24px] font-[600]"
>
<span
v-if="tab.icon"
class="inline-block shrink-0"
:style="{ width: `${tab.iconSize ?? 24}px`, height: `${tab.iconSize ?? 24}px` }"
/>
{{ tab.label }}
</span>
</div>
<div v-if="isWindowed" class="flex items-center justify-center gap-[36px] border-b border-m-primary">
<button
type="button"
@@ -20,7 +45,6 @@
<div
role="tablist"
class="flex flex-1 justify-center gap-[60px]"
:style="{ maxWidth: `${maxWidth}px` }"
>
<button
v-for="tab in visibleTabs"
@@ -39,7 +63,7 @@
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
: tab.disabled
? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
: 'cursor-pointer text-m-primary/50 hover:text-m-primary hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
]"
@click="selectTab(tab.key)"
>
@@ -91,7 +115,7 @@
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
: tab.disabled
? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
: 'cursor-pointer text-m-primary/50 hover:text-m-primary hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
]"
@click="selectTab(tab.key)"
>
@@ -119,8 +143,9 @@
</template>
<script setup lang="ts">
import {computed, ref, useId, watch} from 'vue'
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {computeVisibleCount} from './tabFit'
defineOptions({name: 'MalioTabList', inheritAttrs: false})
@@ -137,12 +162,10 @@ const props = withDefaults(defineProps<{
modelValue?: string
id?: string
maxVisibleTabs?: number
maxWidth?: number
}>(), {
modelValue: undefined,
id: '',
maxVisibleTabs: undefined,
maxWidth: 1100,
})
const emit = defineEmits<{
@@ -159,22 +182,69 @@ const activeTab = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isWindowed = computed(() =>
props.maxVisibleTabs != null && props.tabs.length > props.maxVisibleTabs,
)
const TAB_GAP = 60
const CHEVRON_RESERVE = 110
const maxStartIndex = computed(() =>
isWindowed.value ? Math.max(0, props.tabs.length - props.maxVisibleTabs!) : 0,
)
const rootRef = ref<HTMLElement | null>(null)
const measureRef = ref<HTMLElement | null>(null)
const containerWidth = ref(0)
const tabWidths = ref<number[]>([])
// Nombre d'onglets affichés, calculé pour qu'ils tiennent dans la largeur réelle
// (cf. tabFit.ts) la structure « flèches fixes » est conservée, et comme le
// nombre est choisi pour tenir, pas de débordement sur les flèches ni de rognage.
// `maxVisibleTabs` reste un plafond optionnel. Sans mesure (SSR/jsdom), repli sur
// ce plafond / tous les onglets.
const visibleCount = computed(() => computeVisibleCount({
count: props.tabs.length,
containerWidth: containerWidth.value,
tabWidths: tabWidths.value,
gap: TAB_GAP,
chevronReserve: CHEVRON_RESERVE,
maxVisibleTabs: props.maxVisibleTabs,
}))
const isWindowed = computed(() => props.tabs.length > visibleCount.value)
const maxStartIndex = computed(() => Math.max(0, props.tabs.length - visibleCount.value))
const startIndex = ref(0)
const visibleTabs = computed(() =>
isWindowed.value
? props.tabs.slice(startIndex.value, startIndex.value + props.maxVisibleTabs!)
? props.tabs.slice(startIndex.value, startIndex.value + visibleCount.value)
: props.tabs,
)
function measureTabWidths() {
const el = measureRef.value
if (!el) return
tabWidths.value = Array.from(el.children).map(c => (c as HTMLElement).offsetWidth)
}
function measureContainer() {
if (rootRef.value) containerWidth.value = rootRef.value.clientWidth
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
measureTabWidths()
measureContainer()
if (typeof ResizeObserver !== 'undefined' && rootRef.value) {
resizeObserver = new ResizeObserver(() => measureContainer())
resizeObserver.observe(rootRef.value)
}
})
onBeforeUnmount(() => resizeObserver?.disconnect())
// Re-mesure quand la liste change (labels/icônes largeurs différentes).
watch(() => props.tabs, () => nextTick(() => {
measureTabWidths()
measureContainer()
}), {deep: true})
const focusedKey = computed(() => {
if (!isWindowed.value) return activeTab.value
const inView = visibleTabs.value.some(t => t.key === activeTab.value)
+43
View File
@@ -0,0 +1,43 @@
import {describe, it, expect} from 'vitest'
import {computeVisibleCount} from './tabFit'
const base = {gap: 60, chevronReserve: 110}
const widths = (n: number, w = 180) => Array.from({length: n}, () => w)
describe('computeVisibleCount', () => {
it('sans layout : respecte maxVisibleTabs', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: [], maxVisibleTabs: 3})).toBe(3)
})
it('sans layout ni maxVisibleTabs : tous les onglets', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: []})).toBe(5)
})
it('tout tient : retourne le total (pas de chevrons)', () => {
// 4×180 + 3×60 = 900 <= 1000
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: widths(4)})).toBe(4)
})
it('trop large : additionne les vraies largeurs (pas la pire) — pas d\'effondrement à 1', () => {
// total 7×180+6×60=1620 > 1400 ; avail=1400-110=1290 ; 180,420,660,900,1140,(1380>1290) → 5
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7)})).toBe(5)
})
it('largeur étroite : montre ce qui tient (≥ 2 ici, pas 1)', () => {
// avail=570-110=460 ; 180,(420),(660>460) → 2
expect(computeVisibleCount({...base, count: 7, containerWidth: 570, tabWidths: widths(7)})).toBe(2)
})
it('maxVisibleTabs plafonne le résultat', () => {
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7), maxVisibleTabs: 3})).toBe(3)
})
it('au moins 1 onglet si rien ne tient', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 150, tabWidths: widths(5, 300)})).toBe(1)
})
it('gère des largeurs hétérogènes', () => {
// total 1080 > 1000 → fenêtré ; avail=1000-110=890 ; 300,560,820,(1080>890) → 3
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: [300, 200, 200, 200]})).toBe(3)
})
})
+49
View File
@@ -0,0 +1,49 @@
// Calcule combien d'onglets afficher pour qu'ils tiennent dans la largeur dispo,
// en gardant la structure « flèches fixes aux bords » : le nombre est choisi pour
// que les onglets visibles tiennent → pas de débordement sur les flèches, pas de
// rognage, barre d'onglet actif intacte.
//
// On additionne les VRAIES largeurs d'onglets (pas la pire), donc le résultat
// n'est pas sur-conservateur (évite de tomber à 1 onglet inutilement).
//
// Fonction pure → testable sans DOM. Sans layout (SSR / jsdom : largeurs à 0),
// on retombe sur le plafond `maxVisibleTabs` (ou tous les onglets).
export interface TabFitInput {
count: number // nombre total d'onglets
containerWidth: number // largeur dispo mesurée (0 si inconnue)
tabWidths: number[] // largeur mesurée de chaque onglet (vide si inconnu)
gap: number // espace entre onglets (px)
chevronReserve: number // place des chevrons + marges quand fenêtré (px)
maxVisibleTabs?: number // plafond optionnel imposé par le consommateur
}
export function computeVisibleCount(input: TabFitInput): number {
const {count, containerWidth, tabWidths, gap, chevronReserve, maxVisibleTabs} = input
// Pas d'info de layout : on respecte le plafond explicite, sinon tout afficher.
if (containerWidth <= 0 || tabWidths.length === 0) {
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, count) : count
}
const fullAvail = containerWidth
const total = tabWidths.reduce((s, w) => s + w, 0) + gap * Math.max(0, count - 1)
let fit: number
if (total <= fullAvail) {
fit = count // tout tient, pas de chevrons
}
else {
const avail = fullAvail - chevronReserve
let used = 0
let n = 0
for (const w of tabWidths) {
const add = w + (n > 0 ? gap : 0)
if (used + add > avail) break
used += add
n++
}
fit = Math.max(1, n)
}
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, fit) : fit
}
@@ -53,6 +53,14 @@ describe('MalioTimePicker', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('disabled + rempli : label et icône horloge grisés', () => {
const wrapper = mountPicker({disabled: true, label: 'Heure', modelValue: '14:30'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
const icon = wrapper.get('[data-test="clock-icon"]')
expect(icon.classes()).toContain('text-m-muted')
expect(icon.classes()).not.toContain('text-black')
})
it('n\'ouvre pas le popover si readonly', async () => {
const wrapper = mountPicker({readonly: true})
await wrapper.get('[data-test="time-field"]').trigger('click')
+8 -5
View File
@@ -219,11 +219,13 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
: props.disabled
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
props.labelClass,
),
)
@@ -231,6 +233,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'