feat(ui) : cohérence du mode disabled sur la famille formulaire

Calqué sur InputText (texte + label grisés, cursor-not-allowed, aucune affordance
interactive). Quand disabled :
- bouton « + » d'ajout masqué (InputPhone, InputEmail)
- œil de révélation masqué (InputPassword)
- chevron masqué (Select, SelectCheckbox, InputAutocomplete)
- croix d'effacement déjà masquée (date, upload, time)
- label en text-m-muted (Select, SelectCheckbox, CalendarField → famille Date, TimePicker, InputNumber aligné)
- tags du SelectCheckbox + valeur du Select grisés ; icône horloge (TimePicker) et icône calendrier (date, même rempli) grisées

Playground : page « Champs disabled » (DIVERS) en miroir de la page readonly,
avec le SelectCheckbox en version tags. Tests par composant ajoutés/adaptés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 14:53:13 +02:00
parent 0558d8c58a
commit ac1d6e7df3
20 changed files with 408 additions and 60 deletions
@@ -0,0 +1,294 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">Champs désactivés (disabled)</h1>
<p class="text-sm text-m-muted">
Tous les champs de formulaire dans leur état <code>disabled</code>, vides puis remplis.
Règles : texte + label grisés, <code>cursor-not-allowed</code>, et <strong>aucune affordance
interactive</strong> (pas de bouton « + », pas de croix « x », pas de chevron, pas d'œil ;
tags et valeurs grisés).
</p>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
<div class="space-y-4">
<MalioInputText
label="Référence (vide)"
:disabled="true"
/>
<MalioInputText
model-value="Commande #A-2048"
label="Référence (rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputEmail (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputEmail
label="Adresse email (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputEmail
model-value="contact@malio.fr"
label="Adresse email (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPhone (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputPhone
label="Téléphone (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
label="Téléphone (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPassword (pas d'œil)</h2>
<div class="space-y-4">
<MalioInputPassword
label="Mot de passe (vide)"
:disabled="true"
/>
<MalioInputPassword
model-value="motdepasse123"
label="Mot de passe (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
<div class="space-y-4">
<MalioInputAmount
label="Montant (vide)"
:disabled="true"
/>
<MalioInputAmount
model-value="1250.00"
label="Montant (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputNumber</h2>
<div class="space-y-4">
<MalioInputNumber
label="Quantité (vide)"
:disabled="true"
/>
<MalioInputNumber
model-value="42"
label="Quantité (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
<div class="space-y-4">
<MalioInputTextArea
label="Description (vide)"
:size="3"
:disabled="true"
/>
<MalioInputTextArea
model-value="Ce texte est désactivé et ne peut pas être modifié."
label="Description (rempli)"
:size="3"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputUpload (pas de croix)</h2>
<div class="space-y-4">
<MalioInputUpload
label="Fichier (vide)"
:disabled="true"
/>
<MalioInputUpload
model-value="document.pdf"
label="Fichier (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete (pas de chevron)</h2>
<div class="space-y-4">
<MalioInputAutocomplete
label="Pays (vide)"
:options="countryOptions"
:disabled="true"
/>
<MalioInputAutocomplete
model-value="de"
label="Pays (rempli)"
:options="countryOptions"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelect (pas de chevron)</h2>
<div class="space-y-4">
<MalioSelect
label="Catégorie (vide)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
<MalioSelect
:model-value="'a'"
label="Catégorie (rempli)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox version tags (grisés)</h2>
<div class="space-y-4">
<MalioSelectCheckbox
label="Catégories (vide)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
<MalioSelectCheckbox
:model-value="['a', 'b', 'c']"
label="Catégories (rempli)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDate (pas de croix)</h2>
<div class="space-y-4">
<MalioDate
label="Date de naissance (vide)"
:disabled="true"
/>
<MalioDate
model-value="2026-06-15"
label="Date de naissance (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
<div class="space-y-4">
<MalioDateTime
label="Date et heure (vide)"
:disabled="true"
/>
<MalioDateTime
model-value="2026-12-25T09:30:00"
label="Date et heure (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
<div class="space-y-4">
<MalioDateRange
label="Période (vide)"
:disabled="true"
/>
<MalioDateRange
:model-value="rangeValue"
label="Période (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
<div class="space-y-4">
<MalioDateWeek
label="Semaine (vide)"
:disabled="true"
/>
<MalioDateWeek
model-value="2026-W52"
label="Semaine (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioTimePicker (pas de croix)</h2>
<div class="space-y-4">
<MalioTimePicker
label="Heure (vide)"
:disabled="true"
/>
<MalioTimePicker
model-value="14:30"
label="Heure (rempli)"
:disabled="true"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const countryOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
]
const categoryOptions: Option[] = [
{label: 'Catégorie A', value: 'a'},
{label: 'Catégorie B', value: 'b'},
{label: 'Catégorie C', value: 'c'},
]
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
</script>
+1
View File
@@ -70,6 +70,7 @@ export const navSections: SidebarSection[] = [
icon: 'mdi:dots-horizontal',
items: [
{label: 'Champs readonly', to: '/composant/divers/readonly'},
{label: 'Champs disabled', to: '/composant/divers/disabled'},
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'},
+1
View File
@@ -58,6 +58,7 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
### Changed
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
* TabList : le nombre d'onglets visibles en mode fenêtré s'**adapte automatiquement à la largeur réelle** (mesure via `ResizeObserver` + ligne de mesure cachée), au lieu d'un `maxVisibleTabs` fixe qui pouvait faire déborder les onglets sur les chevrons. Les chevrons restent fixés aux bords et le nombre affiché est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). `maxVisibleTabs` devient un **plafond optionnel**. Calcul isolé dans une fonction pure testable (`tabFit.ts`, basée sur les largeurs réelles des onglets). Sans layout (SSR), repli sur le plafond / tous les onglets. **Breaking** : la prop `maxWidth` est supprimée (la barre utilise désormais toute la largeur disponible au lieu d'être plafonnée à 1100px).
* TabList : au **survol** d'un onglet inactif, on applique désormais le même style que l'onglet actif — texte `m-primary` plein + barre soulignée `m-primary` (`hover:after:*`) — au lieu du discret `text-m-primary/70`, pour bien marquer la cible.
* Sidebar : états visuels des liens de navigation — **survol** : highlight pleine largeur entièrement porté par le `<li>` (fond `m-primary` à 10 % + texte `m-primary` + semi-bold, `hover:bg-m-primary/10 hover:text-m-primary hover:font-semibold`, espacement `pt-1 pb-1`). La couleur de base (`text-black`) est aussi sur le `<li>` et le `<a>` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du `<a>`, le fond devenait bleu mais le texte restait noir. **Lien actif** : texte `m-primary` + semi-bold, sans fond (`active-class="!text-m-primary font-semibold"` ; `!important` car `active-class` est hors `twMerge`).
+17
View File
@@ -238,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')
@@ -358,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,
),
)
@@ -370,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="[
@@ -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'