From 2da30a9138b1bf1b3fdf45bea7fc9866debc6d41 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 16:13:36 +0200 Subject: [PATCH] =?UTF-8?q?docs(radio):=20plan=20d'impl=C3=A9mentation=20M?= =?UTF-8?q?alioRadioGroup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-24-radio-group.md | 1007 +++++++++++++++++ 1 file changed, 1007 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-radio-group.md diff --git a/docs/superpowers/plans/2026-06-24-radio-group.md b/docs/superpowers/plans/2026-06-24-radio-group.md new file mode 100644 index 0000000..1f97303 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-radio-group.md @@ -0,0 +1,1007 @@ +# MalioRadioGroup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Introduce a `MalioRadioGroup` parent component that owns the value, shared `name`, and a single field message (with reserved space, like `MalioSelect`), so a radio group aligns visually with a select; turn `MalioRadioButton` into a group-aware input. + +**Architecture:** Mirror the existing `Accordion`/`AccordionItem` precedent — a `context.ts` injection key, the group `provide`s context, the radio `inject`s it. In a group, the radio inherits `name`/checked/error-styling/disabled and renders no message; standalone radios are unchanged. The group's message markup is copied verbatim from `Select.vue` so the two align automatically. + +**Tech Stack:** Nuxt 4 layer, Vue 3 ` + + +``` + +Note: the old `.radio-item:has(+ .radio-item) .radio-message { display: none }` rule is intentionally removed — the group now owns the single message. + +- [ ] **Step 5: Run RadioButton tests (new + existing regression)** + +Run: `npm run test -- RadioButton` +Expected: PASS — all existing standalone tests plus the new "dans un groupe" suite. +(If a flaky timeout occurs, re-run once.) + +- [ ] **Step 6: Lint** + +Run: `npm run lint -- app/components/malio/radio` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add app/components/malio/radio/context.ts app/components/malio/radio/RadioButton.vue app/components/malio/radio/RadioButton.test.ts +git commit -m "feat(radio): contexte de groupe injectable pour RadioButton" +``` + +--- + +### Task 2: `RadioGroup.vue` + +**Files:** +- Create: `app/components/malio/radio/RadioGroup.vue` +- Test: `app/components/malio/radio/RadioGroup.test.ts` + +**Interfaces:** +- Consumes `radioGroupContextKey`, `RadioValue` from `./context` (Task 1) and `MalioRadioButton` (Task 1). +- Produces `` with props: `modelValue?: RadioValue`, `options?: {label: string; value: RadioValue; disabled?: boolean}[]`, `label?: string`, `name?: string`, `inline?: boolean`, `disabled?/readonly?/required?: boolean`, `hint?/error?/success?: string`, `reserveMessageSpace?: boolean` (default `true`), `groupClass?/inputClass?/labelClass?: string`. Emits `update:modelValue(value: RadioValue)`. Default slot renders extra ``s. + +- [ ] **Step 1: Write the failing test** + +`app/components/malio/radio/RadioGroup.test.ts`: + +```ts +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import RadioGroup from './RadioGroup.vue' +import RadioButton from './RadioButton.vue' + +type Opt = {label: string; value: string; disabled?: boolean} +type RadioGroupProps = { + modelValue?: string | number | boolean | null + options?: Opt[] + label?: string + name?: string + inline?: boolean + disabled?: boolean + readonly?: boolean + required?: boolean + hint?: string + error?: string + success?: string + reserveMessageSpace?: boolean + groupClass?: string + inputClass?: string + labelClass?: string +} + +const RadioGroupForTest = RadioGroup as DefineComponent + +const options: Opt[] = [ + {label: 'Oui', value: 'oui'}, + {label: 'Non', value: 'non'}, +] + +const mountGroup = (props: RadioGroupProps = {}) => + mount(RadioGroupForTest, {props: {options, ...props}}) + +describe('MalioRadioGroup', () => { + it('rend une option par entrée et un seul role=radiogroup', () => { + const wrapper = mountGroup() + expect(wrapper.findAll('input[type="radio"]')).toHaveLength(2) + expect(wrapper.findAll('[role="radiogroup"]')).toHaveLength(1) + }) + + it('partage le même name natif entre les radios', () => { + const wrapper = mountGroup({name: 'prestation'}) + const names = wrapper.findAll('input').map(i => i.attributes('name')) + expect(names).toEqual(['prestation', 'prestation']) + }) + + it('coche selon modelValue', () => { + const wrapper = mountGroup({modelValue: 'non'}) + const inputs = wrapper.findAll('input') + expect((inputs[1].element as HTMLInputElement).checked).toBe(true) + expect((inputs[0].element as HTMLInputElement).checked).toBe(false) + }) + + it('émet update:modelValue au clic sur une option', async () => { + const wrapper = mountGroup() + await wrapper.findAll('input')[1].trigger('change') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['non']) + }) + + it('affiche UN seul message d\'erreur réservant l\'espace', () => { + const wrapper = mountGroup({error: 'Sélection requise'}) + const msgs = wrapper.findAll('[id$="-describedby"]') + expect(msgs).toHaveLength(1) + expect(msgs[0].text()).toBe('Sélection requise') + expect(msgs[0].classes()).toContain('min-h-[1rem]') + expect(msgs[0].classes()).toContain('text-m-danger') + expect(wrapper.find('.radio-message').exists()).toBe(false) + }) + + it('propage l\'erreur aux radios enfants', () => { + const wrapper = mountGroup({error: 'Sélection requise'}) + expect(wrapper.findAll('.radio-control.is-error')).toHaveLength(2) + expect(wrapper.findAll('input').every(i => i.attributes('aria-invalid') === 'true')).toBe(true) + }) + + it('reserveMessageSpace=false sans message : aucune ligne réservée', () => { + const wrapper = mountGroup({reserveMessageSpace: false}) + expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false) + }) + + it('rend la legend et la lie via aria-labelledby', () => { + const wrapper = mountGroup({label: 'Prestation'}) + const legendId = wrapper.find('[id$="-label"]').attributes('id') + expect(wrapper.get('[id$="-label"]').text()).toContain('Prestation') + expect(wrapper.get('[role="radiogroup"]').attributes('aria-labelledby')).toBe(legendId) + }) + + it('inline : la zone radios réserve la hauteur d\'un champ', () => { + const wrapper = mountGroup({inline: true}) + expect(wrapper.get('[role="radiogroup"]').classes()).toContain('min-h-[2.5rem]') + }) + + it('accepte des radios via le slot par défaut', () => { + const wrapper = mount(RadioGroupForTest, { + props: {modelValue: 'b'}, + slots: { + default: () => [ + h(RadioButton, {value: 'a', label: 'A'}), + h(RadioButton, {value: 'b', label: 'B'}), + ], + }, + }) + expect(wrapper.findAll('input')).toHaveLength(2) + expect((wrapper.findAll('input')[1].element as HTMLInputElement).checked).toBe(true) + }) +}) +``` + +Add `import {h} from 'vue'` at the top of the test file (used by the slot test). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm run test -- RadioGroup` +Expected: FAIL — cannot resolve `./RadioGroup.vue`. + +- [ ] **Step 3: Create `RadioGroup.vue`** + +`app/components/malio/radio/RadioGroup.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm run test -- RadioGroup` +Expected: PASS — all `MalioRadioGroup` tests. + +- [ ] **Step 5: Lint** + +Run: `npm run lint -- app/components/malio/radio` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add app/components/malio/radio/RadioGroup.vue app/components/malio/radio/RadioGroup.test.ts +git commit -m "feat(radio): composant MalioRadioGroup (message unique, reserveMessageSpace)" +``` + +--- + +### Task 3: Histoire story + playground page + nav + +**Files:** +- Create: `app/story/radio/RadioGroup.story.vue` +- Create: `.playground/pages/composant/radio/radioGroup.vue` +- Modify: `.playground/playground.nav.ts` (add nav entry under "SÉLECTIONS") + +**Interfaces:** +- Consumes `` (Task 2, auto-imported in story/playground). + +- [ ] **Step 1: Create the Histoire story** + +`app/story/radio/RadioGroup.story.vue`: + +```vue + + + +``` + +- [ ] **Step 2: Create the playground page** + +`.playground/pages/composant/radio/radioGroup.vue`: + +```vue + + + +``` + +- [ ] **Step 3: Add the nav entry** + +In `.playground/playground.nav.ts`, inside the "SÉLECTIONS" group's `items`, add immediately after the `Radio` entry: + +```ts + {label: 'Radio', to: '/composant/radio/radioButton'}, + {label: 'Radio (groupe)', to: '/composant/radio/radioGroup'}, +``` + +- [ ] **Step 4: Lint** + +Run: `npm run lint -- .playground/pages/composant/radio app/story/radio` +Expected: no new errors on the created files (pre-existing warnings elsewhere are fine). + +- [ ] **Step 5: Commit** + +```bash +git add app/story/radio/RadioGroup.story.vue .playground/pages/composant/radio/radioGroup.vue .playground/playground.nav.ts +git commit -m "docs(radio): story + page playground MalioRadioGroup" +``` + +--- + +### Task 4: Use `MalioRadioGroup` in the client form (revert the hack) + +**Files:** +- Modify: `.playground/pages/composant/form/client.vue` + +**Interfaces:** +- Consumes `` (Task 2). + +- [ ] **Step 1: Replace the manual radio block** + +In `.playground/pages/composant/form/client.vue`, replace the whole `
` block (the radios wrapper + manual `

`) with: + +```html + +``` + +- [ ] **Step 2: Remove the now-unused import and style** + +- Delete the line `import MalioRadioButton from "../../../../app/components/malio/radio/RadioButton.vue";` (no longer referenced — `MalioRadioGroup` is auto-imported). +- Delete the entire `` block at the end of the file. +- Keep `prestationChoice` and `prestationOptions` refs as-is. + +- [ ] **Step 3: Lint** + +Run: `npm run lint -- .playground/pages/composant/form/client.vue` +Expected: no new errors from this change. + +- [ ] **Step 4: Visual verification in the browser** + +Start a clean dev server on a dedicated port and confirm alignment (the original bug): + +```bash +PORT=3010 npm run dev +``` + +Navigate to `http://localhost:3010/composant/form/client`. Using Chrome MCP `evaluate_script`, assert that on the "Prestation de triage" row: +- the radio circles' vertical center ≈ the `Fournisseur` select box vertical center (±2px); +- the radio group message top ≈ the `Fournisseur` error message top (±2px). + +Expected: both differences within 2px (was ~10px circle offset / ~6px message offset before). + +> Per project convention, do not open the browser MCP without the user's go-ahead — propose this verification step and wait for approval, or let the user run it. Tests + lint are the primary gate. + +- [ ] **Step 5: Commit** + +```bash +git add .playground/pages/composant/form/client.vue +git commit -m "feat(playground): formulaire client utilise MalioRadioGroup" +``` + +--- + +### Task 5: Documentation (`COMPONENTS.md` + `CHANGELOG.md`) + +**Files:** +- Modify: `COMPONENTS.md` (add a `## MalioRadioGroup` section after the `MalioRadioButton` section, ending line ~500) +- Modify: `CHANGELOG.md` (add an `Added` entry) + +- [ ] **Step 1: Add the `MalioRadioGroup` section to `COMPONENTS.md`** + +Insert immediately after the `MalioRadioButton` section's closing `---` (currently line 500), before `## MalioDate`: + +```markdown +## MalioRadioGroup + +Groupe de boutons radio : possède la valeur, le `name` partagé et **un seul** message (erreur/succès/aide) avec espace réservé comme les autres champs — un groupe en ligne s'aligne donc avec un `MalioSelect` voisin. Les options sont déclarées via `:options` ou via le slot par défaut (``). + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `modelValue` | `string \| number \| boolean \| null` | `undefined` | Valeur sélectionnée (v-model) | +| `options` | `{label, value, disabled?}[]` | `[]` | Options déclaratives | +| `label` | `string` | `''` | Label de groupe (legend, lié par `aria-labelledby`) | +| `name` | `string` | auto | Nom natif partagé des radios | +| `inline` | `boolean` | `false` | Disposition horizontale | +| `disabled` | `boolean` | `false` | Désactive tout le groupe | +| `readonly` | `boolean` | `false` | Lecture seule | +| `required` | `boolean` | `false` | Champ requis (astérisque dans la legend) | +| `hint` / `error` / `success` | `string` | `''` | Message unique du groupe | +| `reserveMessageSpace` | `boolean` | `true` | Réserve la ligne de message (alignement) | +| `groupClass` / `inputClass` / `labelClass` | `string` | `''` | Overrides `twMerge` | + +**Events :** `update:modelValue(value: string | number | boolean | null)` + +**Accessibilité :** conteneur `role="radiogroup"`, `aria-labelledby` (si `label`), `aria-invalid` et `aria-describedby` sur le message unique. Les radios enfants héritent de l'état d'erreur/désactivé du groupe. + +```vue + + + + + + +``` + +--- +``` + +- [ ] **Step 2: Add the `CHANGELOG.md` entry** + +Under `## [0.0.0]` → `### Added`, append a new bullet at the end of the list: + +```markdown +* [#MUI-radio-group] Création d'un composant radio group (message unique, alignement select) +``` + +(If the team uses a real issue id, substitute it for `#MUI-radio-group`.) + +- [ ] **Step 3: Commit** + +```bash +git add COMPONENTS.md CHANGELOG.md +git commit -m "docs(radio): documente MalioRadioGroup (COMPONENTS + CHANGELOG)" +``` + +--- + +### Final verification + +- [ ] **Run the full radio suite + lint** + +Run: `npm run test -- radio` then `npm run lint` +Expected: radio tests green; lint reports no new errors from the created/modified files. + +- [ ] **Confirm the branch is clean except intended files** + +Run: `git status` +Expected: only intended changes committed; `nuxt.config.ts` remains unstaged/untouched.