Files
malio-layer-ui/docs/superpowers/plans/2026-06-03-readonly-field-state.md
T
tristan c934019260 docs : plan état readonly cohérent
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:03:26 +02:00

13 KiB

État visuel readonly cohérent — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax.

Goal: Donner aux champs readonly un état visuel distinct et cohérent : bordure noire même vide, aucun grossissement/bleu au focus, label gris→noir selon rempli, icône gris→noir selon rempli.

Architecture: Pas de composant partagé (les styles sont dupliqués par composant, on suit ce pattern). Dans chaque composant on rend conditionnelles 4 zones de classes selon readonly. Le champ reste focusable (sélection/copie du texte) mais sans visuel de focus.

Tech Stack: Vue 3 <script setup>, Tailwind m-*, twMerge, Vitest + @vue/test-utils.

Branche: feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co (on continue dessus).


La recette commune (appliquée quand readonly === true)

Priorité inchangée : error puis success puis disabled passent TOUJOURS avant readonly. La recette readonly ne s'applique que dans la branche « état normal ».

  1. Bordure : forcer border-black (même vide). Ne PAS inclure border-m-muted ni focus:border-m-primary quand readonly.
  2. Grow + bleu : ne PAS inclure la classe grow-height (donc pas de grossissement au focus) ni les classes focus:* (border, padding focus:pl-*/focus:!pl-*). Pour InputTextArea (pas de grow-height) : retirer focus:border-m-primary et le surlignage de focus textarea-scrollbar-primary.
  3. Label : utiliser isFilled ? 'text-black' : 'text-m-muted' ; ne PAS inclure peer-focus:text-m-primary ni les combos peer-placeholder-shown/peer-[&:not(:placeholder-shown):not(:focus)]. De plus, en readonly, shouldFloatLabel (ou équivalent qui pilote le float) doit ignorer isFocused → float basé sur isFilled seul (un champ readonly vide garde son label gris au repos).
  4. Icône : isFilled ? 'text-black' : 'text-m-muted' ; sauter la branche isFocused → text-m-primary. (error/success/disabled toujours prioritaires.)
  5. Interaction : readonly bloque l'ouverture (Upload : openFilePicker no-op ; pickers : déjà bloqué). Le champ reste sélectionnable (ne pas retirer la focusabilité).

Implémentation conseillée : un petit computed isReadonly = computed(() => props.readonly && !props.disabled) (disabled prime), puis dans chaque twMerge(...) remplacer les fragments concernés par des expressions ternaires sur props.readonly. Garder le code lisible et homogène avec l'existant du fichier.

Patron de test (adapter le sélecteur input/textarea et le helper de montage du fichier)

it('readonly : bordure noire même vide, pas de grow/bleu', () => {
  const wrapper = mountX({label: 'Champ', readonly: true}) // pas de modelValue → vide
  const field = wrapper.get('input') // ou 'textarea'
  expect(field.classes()).toContain('border-black')
  expect(field.classes()).not.toContain('border-m-muted')
  expect(field.classes()).not.toContain('grow-height')          // sauf InputTextArea (pas de grow-height) : asserter l'absence de 'focus:border-m-primary'
  expect(field.classes()).not.toContain('focus:border-m-primary')
})

it('readonly : label gris si vide, pas de bleu', () => {
  const wrapper = mountX({label: 'Champ', readonly: true})
  const label = wrapper.get('label')
  expect(label.classes()).not.toContain('peer-focus:text-m-primary')
  expect(label.classes()).toContain('text-m-muted')
})

it('readonly rempli : label noir + icône noire', () => {
  const wrapper = mountX({label: 'Champ', readonly: true, modelValue: '...valeur remplie...'})
  expect(wrapper.get('label').classes()).toContain('text-black')
  // si le composant a une icône d'état :
  expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})

Pour les composants à icône d'état, ajouter aussi : readonly + vide → icône text-m-muted. Adapter modelValue au type (montant, date ISO, etc.). Pour les pickers, la « valeur remplie » se passe via la prop d'affichage habituelle (voir tests voisins).


Task 1 : InputUpload (ajout de la prop readonly)

InputUpload n'a PAS de prop readonly aujourd'hui (son <input type="text"> est :readonly="true" en dur pour empêcher la saisie). On AJOUTE une vraie prop readonly.

Files: Modify app/components/malio/input/InputUpload.vue ; Test app/components/malio/input/InputUpload.test.ts

  • Step 1 — tests d'abord : ajouter le patron de test ci-dessus. Champ = wrapper.get('input[type="text"]'). Icône = [data-test="icon"] (le nuage). « rempli » = modelValue: 'fichier.pdf'.
  • Step 2 — run, FAIL : npm run test -- app/components/malio/input/InputUpload.test.ts
  • Step 3 — ajouter la prop : readonly?: boolean dans defineProps + readonly: false dans withDefaults.
  • Step 4 — appliquer la recette dans mergedInputClass, mergedLabelClass, iconStateClass et shouldFloatLabel (float = isFilled quand readonly). Forcer cursor-default (au lieu de cursor-pointer) quand readonly.
  • Step 5 — bloquer l'ouverture : dans openFilePicker, if (props.disabled || props.readonly) return.
  • Step 6 — run, PASS : même commande. (Suite flaky connue : relancer le fichier si timeout non lié ; --no-verify si un timeout flaky bloque un commit déjà vérifié.)
  • Step 7 — commit
git add app/components/malio/input/InputUpload.vue app/components/malio/input/InputUpload.test.ts
git commit -m "feat(ui) : état readonly visuel sur InputUpload (+ prop readonly)"

(corps + ligne vide + Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>)


Task 2 : Inputs floating-label standard (lot de 6)

InputText, InputEmail, InputAmount, InputAutocomplete, InputPassword, InputTextArea ont déjà une prop readonly. Appliquer la recette à chacun.

Files: Modify les 6 .vue (app/components/malio/input/InputText.vue, InputEmail.vue, InputAmount.vue, InputAutocomplete.vue, InputPassword.vue, InputTextArea.vue) ; Test les 6 *.test.ts correspondants (Input.test.ts pour InputText, puis InputEmail/InputAmount/InputAutocomplete/InputPassword/InputTextArea.test.ts).

Spécificités par fichier :

  • InputText / InputEmail / InputAmount : structure identique (mergedInputClass avec grow-height + focus:border-m-primary + focus:pl-[11px] ; mergedLabelClass avec peer-focus:text-m-primary ; iconStateClass avec branche isFocused). Appliquer la recette 1-4.

  • InputAutocomplete : idem ; il a deux usages de iconStateClass (icône gauche + chevron) — appliquer la recette à iconStateClass. isFilled y inclut hasSelection.

  • InputPassword : recette 1-4. L'icône est le toggle œil (cliquable) : garder le @click de bascule ; seule la couleur suit la recette (pas de bleu). NE PAS rendre l'œil non-cliquable en readonly.

  • InputTextArea : classes inline dans le template (pas de grow-height). Recette : isFilled ? border-black : border-m-mutedreadonly ? border-black : (isFilled ? border-black : border-m-muted) ; retirer focus:border-m-primary et le isFocused ? 'textarea-scrollbar-primary' quand readonly ; label idem. Pas d'icône (recette 4 N/A).

  • Step 1 — tests d'abord : ajouter le patron à chacun des 6 fichiers test (champ = input, sauf TextArea = textarea ; pour TextArea ne pas asserter grow-height). Pour les composants à icône, asserter aussi l'icône ([data-test="icon"]). Adapter modelValue rempli au type (Amount : un montant valide).

  • Step 2 — run, FAIL : npm run test -- app/components/malio/input/Input.test.ts app/components/malio/input/InputEmail.test.ts app/components/malio/input/InputAmount.test.ts app/components/malio/input/InputAutocomplete.test.ts app/components/malio/input/InputPassword.test.ts app/components/malio/input/InputTextArea.test.ts

  • Step 3 — appliquer la recette aux 6 .vue.

  • Step 4 — run, PASS : même commande (relancer un fichier si flaky).

  • Step 5 — commit

git add app/components/malio/input/InputText.vue app/components/malio/input/InputEmail.vue app/components/malio/input/InputAmount.vue app/components/malio/input/InputAutocomplete.vue app/components/malio/input/InputPassword.vue app/components/malio/input/InputTextArea.vue app/components/malio/input/Input.test.ts app/components/malio/input/InputEmail.test.ts app/components/malio/input/InputAmount.test.ts app/components/malio/input/InputAutocomplete.test.ts app/components/malio/input/InputPassword.test.ts app/components/malio/input/InputTextArea.test.ts
git commit -m "feat(ui) : état readonly visuel sur les inputs floating-label"

Task 3 : InputPhone (découpler readonly de disabled)

Aujourd'hui InputPhone traite disabled || readonly ensemble (bouton « add » + opacity-40, look désactivé). On découple : readonly applique la recette readonly (bordure noire, pas de look disabled), tout en restant non-éditable. L'action « add » reste bloquée en readonly mais le champ ne doit plus avoir l'apparence désactivée.

Files: Modify app/components/malio/input/InputPhone.vue ; Test app/components/malio/input/InputPhone.test.ts

  • Step 1 — tests d'abord : patron readonly (champ = input, icône = [data-test="icon"]). Ajouter aussi une assertion que le champ readonly n'a PAS opacity-40 (plus de look disabled). modelValue rempli = un numéro.
  • Step 2 — run, FAIL : npm run test -- app/components/malio/input/InputPhone.test.ts
  • Step 3 — appliquer la recette à mergedInputClass/mergedLabelClass/iconStateClass (recette 1-4). Pour le bouton « add » (mergedAddButtonClass) : garder l'action bloquée en readonly (onAdd retourne déjà), mais retirer l'apparence opacity-40 cursor-not-allowed spécifique au readonly — la garder uniquement pour disabled. (En readonly, le bouton add suit la couleur d'icône readonly.)
  • Step 4 — run, PASS (relancer si flaky).
  • Step 5 — commit
git add app/components/malio/input/InputPhone.vue app/components/malio/input/InputPhone.test.ts
git commit -m "feat(ui) : InputPhone readonly suit les règles readonly (plus de look disabled)"

Task 4 : Pickers CalendarField (date family) + TimePicker

CalendarField (rend Date/DateTime/DateRange/DateWeek) et TimePicker ont déjà une prop readonly qui bloque l'ouverture du popover. Appliquer la recette visuelle. Leur input interne est déjà readonly natif ; le float du label suit isFilled || isOpen — en readonly, isOpen reste faux (ouverture bloquée), donc float = isFilled. Forcer bordure noire, label gris→noir, icône gris→noir sans branche focus/open.

Files: Modify app/components/malio/date/internal/CalendarField.vue, app/components/malio/time/TimePicker.vue ; Test app/components/malio/date/Date.test.ts (couvre CalendarField) et app/components/malio/time/TimePicker.test.ts

  • Step 1 — tests d'abord : patron readonly. Pour Date.test.ts, monter mountDate({label, readonly: true}) et une variante remplie (passer une valeur de date ISO comme les tests voisins). Champ = l'input du composant (voir sélecteur utilisé par les tests voisins). Pour TimePicker.test.ts, utiliser le helper du fichier.
  • Step 2 — run, FAIL : npm run test -- app/components/malio/date/Date.test.ts app/components/malio/time/TimePicker.test.ts
  • Step 3 — appliquer la recette à CalendarField.vue et TimePicker.vue (mergedInputClass/mergedLabelClass/iconStateClass ; float = isFilled en readonly). Vérifier que la croix « clear » reste masquée en readonly (déjà le cas — ne pas régresser).
  • Step 4 — run, PASS (relancer si flaky).
  • Step 5 — commit
git add app/components/malio/date/internal/CalendarField.vue app/components/malio/time/TimePicker.vue app/components/malio/date/Date.test.ts app/components/malio/time/TimePicker.test.ts
git commit -m "feat(ui) : état readonly visuel sur pickers date/heure"

Task 5 : Playground + vérification finale

Files: Modify les pages playground concernées sous .playground/pages/composant/...

  • Step 1 — exemples readonly : ajouter sur chaque page concernée (inputText, inputEmail, inputAmount, inputAutocomplete, inputPassword, inputTextArea, inputPhone, inputUpload [manquant], date, timePicker) un exemple readonly : une instance vide (:readonly="true") ET une instance remplie readonly, pour visualiser bordure noire vide + label/icône noir rempli. Suivre le pattern de chaque page ; si une page rend l'ajout coûteux, le signaler et passer (mais inputUpload est demandé explicitement, le faire).
  • Step 2 — lint : npm run lint → 0 erreur (baseline 24 warnings préexistants).
  • Step 3 — suite complète : npm run test → tout vert (relancer un fichier en cas de timeout flaky).
  • Step 4 — commit
git add .playground
git commit -m "docs(playground) : exemples readonly"

Récapitulatif commits attendus

  1. feat(ui) : état readonly visuel sur InputUpload (+ prop readonly)
  2. feat(ui) : état readonly visuel sur les inputs floating-label
  3. feat(ui) : InputPhone readonly suit les règles readonly (plus de look disabled)
  4. feat(ui) : état readonly visuel sur pickers date/heure
  5. docs(playground) : exemples readonly

Note convention : le hook commit-msg malio impose un espace avant :.