[#MUI-27] Création d'un composant sélection de site (#29)
Composant MalioSiteSelector : bande horizontale pour choisir un site (usine ou lieu) parmi une liste. Tuiles flex proportionnelles, couleur du site sélectionné partagée par toutes les tuiles (opacité 1 / 0.4). Expose update:modelValue (id) + change (objet site complet) pour faciliter les appels API côté consommateur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #29 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #29.
This commit is contained in:
154
app/components/malio/site/SiteSelector.test.ts
Normal file
154
app/components/malio/site/SiteSelector.test.ts
Normal file
@@ -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<SiteSelectorProps>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
104
app/components/malio/site/SiteSelector.vue
Normal file
104
app/components/malio/site/SiteSelector.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
:id="componentId"
|
||||
role="radiogroup"
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<button
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="activeId === site.id"
|
||||
:tabindex="activeId === site.id ? 0 : -1"
|
||||
:style="{
|
||||
backgroundColor: activeColor,
|
||||
opacity: activeId === site.id ? 1 : 0.4,
|
||||
}"
|
||||
:class="mergedTileClass"
|
||||
@click="select(site.id)"
|
||||
>
|
||||
<span :class="mergedLabelClass">{{ site.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
defineOptions({name: 'MalioSiteSelector', inheritAttrs: false})
|
||||
|
||||
type Site = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sites: Site[]
|
||||
modelValue?: string
|
||||
id?: string
|
||||
groupClass?: string
|
||||
tileClass?: string
|
||||
labelClass?: string
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
id: '',
|
||||
groupClass: '',
|
||||
tileClass: '',
|
||||
labelClass: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', site: Site): void
|
||||
}>()
|
||||
|
||||
const generatedId = useId()
|
||||
const componentId = computed(() => props.id || `malio-site-selector-${generatedId}`)
|
||||
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const localValue = ref(props.sites.length > 0 ? props.sites[0]!.id : '')
|
||||
|
||||
const activeId = computed(() =>
|
||||
isControlled.value ? props.modelValue! : localValue.value,
|
||||
)
|
||||
|
||||
const activeColor = computed(() =>
|
||||
props.sites.find((s) => s.id === activeId.value)?.color ?? '',
|
||||
)
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'flex w-full',
|
||||
props.groupClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedTileClass = computed(() =>
|
||||
twMerge(
|
||||
'flex-1 cursor-pointer px-6 py-4 text-center transition-opacity focus:outline-none',
|
||||
props.tileClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'text-white font-bold uppercase tracking-wide',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
function select(id: string) {
|
||||
const site = props.sites.find((s) => s.id === id)
|
||||
if (!site) return
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = id
|
||||
}
|
||||
emit('update:modelValue', id)
|
||||
emit('change', site)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user