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

162 lines
13 KiB
Markdown

# É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)
```ts
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**
```bash
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-muted``readonly ? 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**
```bash
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**
```bash
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**
```bash
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**
```bash
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 `:`.