diff --git a/.playground/pages/composant/date/date.vue b/.playground/pages/composant/date/date.vue index 320a5b2..ec73c15 100644 --- a/.playground/pages/composant/date/date.vue +++ b/.playground/pages/composant/date/date.vue @@ -50,6 +50,25 @@ /> + +
+
+

Readonly (readonly vide)

+ +
+ +
+

Readonly (readonly rempli)

+ +
+
@@ -62,6 +81,7 @@ const now = new Date() const todayIso = toIso(now) const maxIso = toIso(new Date(now.getTime() + 30 * 86400000)) +const readonlyFilledDate = ref('2026-06-15') const value = ref(null) const erpValue = ref(null) const bounded = ref(null) diff --git a/.playground/pages/composant/divers/readonly.vue b/.playground/pages/composant/divers/readonly.vue new file mode 100644 index 0000000..368e57a --- /dev/null +++ b/.playground/pages/composant/divers/readonly.vue @@ -0,0 +1,276 @@ + + + diff --git a/.playground/pages/composant/input/inputAmount.vue b/.playground/pages/composant/input/inputAmount.vue index 1006ee6..42190d5 100644 --- a/.playground/pages/composant/input/inputAmount.vue +++ b/.playground/pages/composant/input/inputAmount.vue @@ -36,6 +36,23 @@ /> +
+

Readonly (readonly vide)

+ +
+ +
+

Readonly (readonly rempli)

+ +
+

Erreur et succès

@@ -57,4 +74,7 @@ diff --git a/.playground/pages/composant/input/inputAutocomplete.vue b/.playground/pages/composant/input/inputAutocomplete.vue index 7597851..dfac49b 100644 --- a/.playground/pages/composant/input/inputAutocomplete.vue +++ b/.playground/pages/composant/input/inputAutocomplete.vue @@ -82,6 +82,25 @@ />
+
+

Readonly (readonly vide)

+ +
+ +
+

Readonly (readonly rempli)

+ +
+

Avec hint

('de') const simpleValue = ref(null) const leftIconValue = ref(null) const createValue = ref(null) diff --git a/.playground/pages/composant/input/inputEmail.vue b/.playground/pages/composant/input/inputEmail.vue index 38c6791..8583754 100644 --- a/.playground/pages/composant/input/inputEmail.vue +++ b/.playground/pages/composant/input/inputEmail.vue @@ -48,6 +48,23 @@ />
+
+

Readonly (readonly vide)

+ +
+ +
+

Readonly (readonly rempli)

+ +
+

Avec hint

+ +
+

Email obligatoire

+ +
+ +
+

Email normalisé (minuscules)

+ +
diff --git a/.playground/pages/composant/select/selectCheckbox.vue b/.playground/pages/composant/select/selectCheckbox.vue index 4764d96..f9e1a77 100644 --- a/.playground/pages/composant/select/selectCheckbox.vue +++ b/.playground/pages/composant/select/selectCheckbox.vue @@ -123,6 +123,28 @@ /> +
+

Lecture seule (vide)

+ +
+ +
+

Lecture seule (rempli)

+ +
+

Liste longue

+ @@ -190,4 +213,6 @@ const selectAllValue = ref>([]) const selectAllCustomValue = ref>([]) const longListValue = ref>([]) const bottomValue = ref>([]) +const readonlyEmptyValue = ref>([]) +const readonlyFilledValue = ref>(['fr']) diff --git a/.playground/pages/composant/tab/tabList.vue b/.playground/pages/composant/tab/tabList.vue index f48d61f..ae19ca7 100644 --- a/.playground/pages/composant/tab/tabList.vue +++ b/.playground/pages/composant/tab/tabList.vue @@ -36,6 +36,36 @@ + +
+

Beaucoup d'onglets (fenêtré)

+

+ 7 onglets avec :max-visible-tabs="5" — flèches gauche/droite pour faire défiler + (1 par 1). L'onglet actif reste sélectionné même hors fenêtre. +

+ + + + + + + + + +
+ +
+

Peu d'onglets avec maxVisibleTabs

+

+ 3 onglets avec :max-visible-tabs="5" — le fenêtrage ne s'active pas + (onglets ≤ max), donc pas de flèches, affichage normal centré. +

+ + + + + +
@@ -60,7 +90,25 @@ const tabsTwo = [ { key: 'details', label: 'Détails', icon: 'mdi:cog-outline' }, ] +const manyTabs = [ + { key: 'infos', label: 'Informations', icon: 'mdi:information-outline' }, + { key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' }, + { key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' }, + { key: 'compta', label: 'Comptabilité', icon: 'mdi:web' }, + { key: 'documents', label: 'Documents', icon: 'mdi:file-document-outline' }, + { key: 'historique', label: 'Historique', icon: 'mdi:history' }, + { key: 'parametres', label: 'Paramètres', icon: 'mdi:cog-outline' }, +] + const simpleValue = ref('qualimat') const noIconValue = ref('tab1') const twoTabValue = ref('general') +const manyValue = ref('infos') + +const fewTabs = [ + { key: 'general', label: 'Général', icon: 'mdi:information-outline' }, + { key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' }, + { key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' }, +] +const fewValue = ref('general') diff --git a/.playground/pages/composant/time/timePicker.vue b/.playground/pages/composant/time/timePicker.vue index 127342c..e2ab011 100644 --- a/.playground/pages/composant/time/timePicker.vue +++ b/.playground/pages/composant/time/timePicker.vue @@ -30,12 +30,23 @@

Non effaçable

+ +
+

Readonly (readonly vide)

+ +
+ +
+

Readonly (readonly rempli)

+ +
diff --git a/app/components/malio/tab/TabList.test.ts b/app/components/malio/tab/TabList.test.ts index 48874ac..a81026f 100644 --- a/app/components/malio/tab/TabList.test.ts +++ b/app/components/malio/tab/TabList.test.ts @@ -15,6 +15,8 @@ type TabListProps = { tabs: Tab[] modelValue?: string id?: string + maxVisibleTabs?: number + maxWidth?: number } const TabListForTest = TabList as DefineComponent @@ -185,3 +187,154 @@ describe('MalioTabList', () => { expect(buttons[1].attributes('aria-selected')).toBe('false') }) }) + +describe('MalioTabList — fenêtrage maxVisibleTabs', () => { + const sevenTabs: Tab[] = [ + {key: 't1', label: 'Tab 1'}, + {key: 't2', label: 'Tab 2'}, + {key: 't3', label: 'Tab 3'}, + {key: 't4', label: 'Tab 4'}, + {key: 't5', label: 'Tab 5'}, + {key: 't6', label: 'Tab 6'}, + {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"]') + expect(buttons).toHaveLength(5) + expect(buttons[0].text()).toContain('Tab 1') + expect(buttons[4].text()).toContain('Tab 5') + + const prev = wrapper.find('[data-test="tab-prev"]') + const next = wrapper.find('[data-test="tab-next"]') + expect(prev.exists()).toBe(true) + expect(next.exists()).toBe(true) + expect(prev.attributes('disabled')).toBeDefined() + expect(next.attributes('disabled')).toBeUndefined() + }) + + it('shifts the window by 1 on next click', async () => { + const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) + + await wrapper.find('[data-test="tab-next"]').trigger('click') + + const labels = wrapper.findAll('[role="tab"]').map(b => b.text()) + expect(labels.some(l => l.includes('Tab 1'))).toBe(false) + expect(labels.some(l => l.includes('Tab 6'))).toBe(true) + expect(labels).toHaveLength(5) + + expect(wrapper.find('[data-test="tab-prev"]').attributes('disabled')).toBeUndefined() + }) + + it('disables next at the end and shows the last window', async () => { + const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) + + // 7 - 5 = 2 clicks to reach the end + await wrapper.find('[data-test="tab-next"]').trigger('click') + await wrapper.find('[data-test="tab-next"]').trigger('click') + + const next = wrapper.find('[data-test="tab-next"]') + expect(next.attributes('disabled')).toBeDefined() + + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons).toHaveLength(5) + // last window starts at tabs[length-5] = tabs[2] = Tab 3 + expect(buttons[0].text()).toContain('Tab 3') + expect(buttons[4].text()).toContain('Tab 7') + }) + + it('clicking next past the end does not overshoot', async () => { + const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) + const next = wrapper.find('[data-test="tab-next"]') + + await next.trigger('click') + await next.trigger('click') + await next.trigger('click') // guarded, no-op + await next.trigger('click') // guarded, no-op + + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons).toHaveLength(5) + expect(buttons[0].text()).toContain('Tab 3') + expect(buttons[4].text()).toContain('Tab 7') + }) + + it('renders no arrows and all tabs when maxVisibleTabs is undefined', () => { + const wrapper = mountComponent({tabs: sevenTabs}) + expect(wrapper.findAll('[role="tab"]')).toHaveLength(7) + expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false) + expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false) + }) + + it('renders no arrows when maxVisibleTabs >= tabs.length', () => { + const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 7}) + expect(wrapper.findAll('[role="tab"]')).toHaveLength(7) + expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false) + expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false) + }) + + it('selecting a visible tab activates it without moving the window', async () => { + const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) + const buttons = wrapper.findAll('[role="tab"]') + + await buttons[2].trigger('click') + + const after = wrapper.findAll('[role="tab"]') + expect(after[2].attributes('aria-selected')).toBe('true') + // window unchanged + expect(after[0].text()).toContain('Tab 1') + expect(after).toHaveLength(5) + }) + + it('keeps the active panel rendered even when its tab is outside the window', async () => { + const wrapper = mountComponent( + {tabs: sevenTabs, maxVisibleTabs: 5, modelValue: 't1'}, + {t1: '

Panel 1

'}, + ) + + await wrapper.find('[data-test="tab-next"]').trigger('click') + await wrapper.find('[data-test="tab-next"]').trigger('click') + + // Tab 1 is no longer in the window + const labels = wrapper.findAll('[role="tab"]').map(b => b.text()) + expect(labels.some(l => l.includes('Tab 1'))).toBe(false) + + // but its panel is still rendered and visible + const panels = wrapper.findAll('[role="tabpanel"]') + expect(panels).toHaveLength(7) + expect(wrapper.text()).toContain('Panel 1') + }) + + it('keeps exactly one rendered tab with tabindex=0 when the active tab scrolls out of the window', async () => { + const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) + + // active tab is the first one (t1) by default; scroll it out of the window + await wrapper.find('[data-test="tab-next"]').trigger('click') + await wrapper.find('[data-test="tab-next"]').trigger('click') + + // t1 is no longer rendered + const labels = wrapper.findAll('[role="tab"]').map(b => b.text()) + expect(labels.some(l => l.includes('Tab 1'))).toBe(false) + + const focusable = wrapper.findAll('[role="tab"]').filter(b => b.attributes('tabindex') === '0') + expect(focusable).toHaveLength(1) + // falls back to the first visible tab (Tab 3) + expect(focusable[0].text()).toContain('Tab 3') + }) + + it('arrows expose aria-labels', () => { + const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) + expect(wrapper.find('[data-test="tab-prev"]').attributes('aria-label')).toBe('Onglets précédents') + expect(wrapper.find('[data-test="tab-next"]').attributes('aria-label')).toBe('Onglets suivants') + }) +}) diff --git a/app/components/malio/tab/TabList.vue b/app/components/malio/tab/TabList.vue index efa4f3d..fe0ab04 100644 --- a/app/components/malio/tab/TabList.vue +++ b/app/components/malio/tab/TabList.vue @@ -1,11 +1,81 @@ +``` + +- [ ] **Step 4 : Lancer le test, vérifier le succès** + +Run: `npm run test -- app/components/malio/shared/RequiredMark.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5 : Commit** + +```bash +git add app/components/malio/shared/RequiredMark.vue app/components/malio/shared/RequiredMark.test.ts +git commit -m "feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)" +``` + +--- + +## Task 2 : Prop `required` + a11y + astérisque sur les 4 composants sans la prop + +Composants : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`. Chacun reçoit la prop `required`, le câblage a11y adapté, l'import + le rendu de l'astérisque, et un test. + +**Files:** +- Modify: `app/components/malio/select/Select.vue`, `app/components/malio/select/SelectCheckbox.vue`, `app/components/malio/input/InputUpload.vue`, `app/components/malio/input/InputRichText.vue` +- Test: `app/components/malio/select/Select.test.ts`, `app/components/malio/select/SelectCheckbox.test.ts`, `app/components/malio/input/InputUpload.test.ts`, `app/components/malio/input/InputRichText.test.ts` + +- [ ] **Step 1 : Écrire les tests qui échouent (un par composant)** + +Patron d'assertion (à adapter au helper de chaque fichier) : + +```ts +it('affiche l’astérisque quand required est vrai', () => { + const wrapper = /* monter avec { label: 'Champ', required: true, ...props requises } */ + expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true) +}) + +it('n’affiche pas l’astérisque par défaut', () => { + const wrapper = /* monter avec { label: 'Champ', ...props requises } */ + expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false) +}) +``` + +Montage par fichier : + +| Fichier test | Montage | +|---|---| +| `select/Select.test.ts` | inline : `mount(SelectForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` (et sans `required` pour le 2ᵉ test) | +| `select/SelectCheckbox.test.ts` | inline : `mount(SelectCheckboxForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` | +| `input/InputUpload.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` | +| `input/InputRichText.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` | + +> Note : pour `Select`/`SelectCheckbox`, reprendre la forme exacte des `options` et les `global.stubs` déjà utilisés dans les autres `it()` du fichier (copier un montage voisin). + +- [ ] **Step 2 : Lancer les tests, vérifier l'échec** + +Run: `npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts` +Expected: FAIL sur les nouveaux tests « affiche l’astérisque » (la prop/le rendu n'existent pas encore). + +- [ ] **Step 3 : Ajouter la prop `required` (type + défaut) dans les 4 composants** + +Dans chaque `defineProps<{…}>()`, ajouter la ligne : + +```ts + required?: boolean +``` + +Dans chaque `withDefaults(…, { … })`, ajouter : + +```ts + required: false, +``` + +- [ ] **Step 4 : Câbler l'accessibilité (un élément interactif par composant)** + +`Select.vue` — sur le `