diff --git a/.playground/pages/composant/site/siteSelector.vue b/.playground/pages/composant/site/siteSelector.vue new file mode 100644 index 0000000..5389c12 --- /dev/null +++ b/.playground/pages/composant/site/siteSelector.vue @@ -0,0 +1,67 @@ + + + + Simple (3 sites) + event change + + Site sélectionné : {{ simpleValue }} + Dernier event change : {{ lastChange }} + + + + Deux sites + + Site sélectionné : {{ twoValue }} + + + + Cinq sites (largeur proportionnelle) + + Site sélectionné : {{ fiveValue }} + + + + Non contrôlé (sans v-model) + + + + + Largeur contrainte + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index efdec6d..9241921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-2] Faire un MCP pour la librairie de composant * [#MUI-15] Création d'un composant drawer * [#MUI-22] Création d'un composant datatable +* [#MUI-27] Création d'un composant sélection de site ### Changed diff --git a/app/components/malio/site/SiteSelector.test.ts b/app/components/malio/site/SiteSelector.test.ts new file mode 100644 index 0000000..4ee9654 --- /dev/null +++ b/app/components/malio/site/SiteSelector.test.ts @@ -0,0 +1,154 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import SiteSelector from './SiteSelector.vue' + +type Site = { + id: string + name: string + color: string +} + +type SiteSelectorProps = { + sites: Site[] + modelValue?: string + id?: string + groupClass?: string + tileClass?: string + labelClass?: string +} + +const SiteSelectorForTest = SiteSelector as DefineComponent + +const sites: Site[] = [ + {id: 'chatellerault', name: 'Châtellerault', color: '#2563eb'}, + {id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a'}, + {id: 'pommevic', name: 'Pommevic', color: '#dc2626'}, +] + +function mountComponent(props: SiteSelectorProps) { + return mount(SiteSelectorForTest, {props}) +} + +describe('MalioSiteSelector', () => { + it('renders one tile per site with the site name', () => { + const wrapper = mountComponent({sites}) + const tiles = wrapper.findAll('[role="radio"]') + expect(tiles).toHaveLength(3) + expect(tiles[0]!.text()).toBe('Châtellerault') + expect(tiles[1]!.text()).toBe('Saint-Jean') + expect(tiles[2]!.text()).toBe('Pommevic') + }) + + it('has role="radiogroup" on the wrapper', () => { + const wrapper = mountComponent({sites}) + expect(wrapper.find('[role="radiogroup"]').exists()).toBe(true) + }) + + it('selects the first site by default in uncontrolled mode', () => { + const wrapper = mountComponent({sites}) + const tiles = wrapper.findAll('[role="radio"]') + expect(tiles[0]!.attributes('aria-checked')).toBe('true') + expect(tiles[1]!.attributes('aria-checked')).toBe('false') + expect(tiles[2]!.attributes('aria-checked')).toBe('false') + }) + + it('paints all tiles with the selected site color', () => { + const wrapper = mountComponent({sites, modelValue: 'saint-jean'}) + const tiles = wrapper.findAll('[role="radio"]') + for (const tile of tiles) { + expect(tile.attributes('style')).toContain('background-color: rgb(22, 163, 74)') + } + }) + + it('applies opacity 1 on the selected tile and 0.4 on the others', () => { + const wrapper = mountComponent({sites, modelValue: 'chatellerault'}) + const tiles = wrapper.findAll('[role="radio"]') + expect(tiles[0]!.attributes('style')).toContain('opacity: 1') + expect(tiles[1]!.attributes('style')).toContain('opacity: 0.4') + expect(tiles[2]!.attributes('style')).toContain('opacity: 0.4') + }) + + it('updates the shared color when the selection changes', async () => { + const wrapper = mountComponent({sites}) + let tiles = wrapper.findAll('[role="radio"]') + expect(tiles[0]!.attributes('style')).toContain('background-color: rgb(37, 99, 235)') + + await tiles[2]!.trigger('click') + tiles = wrapper.findAll('[role="radio"]') + for (const tile of tiles) { + expect(tile.attributes('style')).toContain('background-color: rgb(220, 38, 38)') + } + }) + + it('emits update:modelValue with the clicked site id', async () => { + const wrapper = mountComponent({sites, modelValue: 'chatellerault'}) + const tiles = wrapper.findAll('[role="radio"]') + + await tiles[1]!.trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['saint-jean']) + }) + + it('emits change with the full selected site object', async () => { + const wrapper = mountComponent({sites, modelValue: 'chatellerault'}) + const tiles = wrapper.findAll('[role="radio"]') + + await tiles[2]!.trigger('click') + + expect(wrapper.emitted('change')?.[0]).toEqual([ + {id: 'pommevic', name: 'Pommevic', color: '#dc2626'}, + ]) + }) + + it('respects modelValue in controlled mode', () => { + const wrapper = mountComponent({sites, modelValue: 'pommevic'}) + const tiles = wrapper.findAll('[role="radio"]') + expect(tiles[0]!.attributes('aria-checked')).toBe('false') + expect(tiles[1]!.attributes('aria-checked')).toBe('false') + expect(tiles[2]!.attributes('aria-checked')).toBe('true') + }) + + it('switches selection on click in uncontrolled mode', async () => { + const wrapper = mountComponent({sites}) + const tiles = wrapper.findAll('[role="radio"]') + + await tiles[1]!.trigger('click') + + expect(tiles[0]!.attributes('aria-checked')).toBe('false') + expect(tiles[1]!.attributes('aria-checked')).toBe('true') + expect(tiles[2]!.attributes('aria-checked')).toBe('false') + }) + + it('sets roving tabindex (active = 0, others = -1)', () => { + const wrapper = mountComponent({sites, modelValue: 'saint-jean'}) + const tiles = wrapper.findAll('[role="radio"]') + expect(tiles[0]!.attributes('tabindex')).toBe('-1') + expect(tiles[1]!.attributes('tabindex')).toBe('0') + expect(tiles[2]!.attributes('tabindex')).toBe('-1') + }) + + it('merges groupClass, tileClass and labelClass via twMerge', () => { + const wrapper = mountComponent({ + sites, + groupClass: 'rounded-none bg-black', + tileClass: 'py-10', + labelClass: 'text-xs', + }) + const group = wrapper.find('[role="radiogroup"]') + expect(group.classes()).toContain('rounded-none') + expect(group.classes()).toContain('bg-black') + + const tile = wrapper.find('[role="radio"]') + expect(tile.classes()).toContain('py-10') + expect(tile.classes()).not.toContain('py-4') + + const label = tile.find('span') + expect(label.classes()).toContain('text-xs') + }) + + it('uses a custom id when provided', () => { + const wrapper = mountComponent({sites, id: 'my-selector'}) + expect(wrapper.find('[role="radiogroup"]').attributes('id')).toBe('my-selector') + }) +}) diff --git a/app/components/malio/site/SiteSelector.vue b/app/components/malio/site/SiteSelector.vue new file mode 100644 index 0000000..7b245ca --- /dev/null +++ b/app/components/malio/site/SiteSelector.vue @@ -0,0 +1,104 @@ + + + + {{ site.name }} + + + + + diff --git a/app/story/site/siteSelector.story.vue b/app/story/site/siteSelector.story.vue new file mode 100644 index 0000000..b8a4157 --- /dev/null +++ b/app/story/site/siteSelector.story.vue @@ -0,0 +1,116 @@ + + + + + Trois sites + + Site sélectionné : {{ threeValue }} + + + + Deux sites + + + + + Cinq sites + + + + + Non contrôlé + + + + + + + +# MalioSiteSelector + +Sélecteur horizontal pour choisir **un site** (usine ou lieu) parmi une liste. Les tuiles occupent une largeur proportionnelle du conteneur. La couleur du site sélectionné est appliquée à toutes les tuiles ; la tuile active est opaque (opacité 1), les autres sont atténuées (opacité 0.4). + +--- + +## Props détaillées + +### sites + +- Type : `Array<{ id: string; name: string; color: string }>` +- Requis : oui +- Description : Liste des sites à afficher. `color` est un hex (ex : `'#0055ff'`). La couleur du site actuellement sélectionné est appliquée à toutes les tuiles. + +### modelValue + +- Type : `string` +- Description : `id` du site sélectionné (v-model). Sans `v-model`, le premier site est sélectionné par défaut (mode non contrôlé). + +### id + +- Type : `string` +- Description : Identifiant HTML du conteneur. Auto-généré si absent. + +### groupClass / tileClass / labelClass + +- Type : `string` +- Description : Classes Tailwind additionnelles fusionnées via `twMerge` sur, respectivement, le conteneur ``, chaque tuile et le libellé. + +--- + +## Comportement + +- **Toujours un site sélectionné.** Re-cliquer sur la tuile active ne la désélectionne pas. +- **Couleur partagée.** Le `background-color` de toutes les tuiles suit la couleur du site sélectionné. Changer de site met à jour instantanément la couleur de la bande. +- **Pas de gestion d'overflow** : les tuiles se répartissent proportionnellement sur toute la largeur disponible. + +--- + +## Accessibilité + +- `role="radiogroup"` sur le conteneur. +- `role="radio"` avec `aria-checked` sur chaque tuile. +- Roving `tabindex` : la tuile active est focusable (`tabindex="0"`), les autres sont exclues du tab order (`tabindex="-1"`). +- Activation par Enter/Space via l'élément ``. + +--- + +## Events + +### update:modelValue + +- Émis au clic sur une tuile. +- Retourne l'`id` (`string`) du site sélectionné. + +### change + +- Émis au clic sur une tuile, en complément de `update:modelValue`. +- Retourne l'objet `Site` complet (`{ id, name, color }`) — utile pour déclencher des actions (appel API, filtrage…) sans avoir à relire le tableau `sites` côté consommateur. + + +
Site sélectionné : {{ simpleValue }}
{{ simpleValue }}
Dernier event change : {{ lastChange }}
change
{{ lastChange }}
Site sélectionné : {{ twoValue }}
{{ twoValue }}
Site sélectionné : {{ fiveValue }}
{{ fiveValue }}
Site sélectionné : {{ threeValue }}
{{ threeValue }}