From 621077f5552b6305859e041544af0bfc4562e2ed Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 16:11:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui)=20:=20=C3=A9tat=20readonly=20visuel=20?= =?UTF-8?q?sur=20Select=20et=20SelectCheckbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/malio/select/Select.test.ts | 46 ++++++++++++++++ app/components/malio/select/Select.vue | 53 ++++++++++++------- .../malio/select/SelectCheckbox.test.ts | 46 ++++++++++++++++ .../malio/select/SelectCheckbox.vue | 53 ++++++++++++------- 4 files changed, 162 insertions(+), 36 deletions(-) diff --git a/app/components/malio/select/Select.test.ts b/app/components/malio/select/Select.test.ts index e53bb5e..bf42695 100644 --- a/app/components/malio/select/Select.test.ts +++ b/app/components/malio/select/Select.test.ts @@ -21,6 +21,7 @@ type SelectProps = { textLabel?: string rounded?: string disabled?: boolean + readonly?: boolean required?: boolean } @@ -306,4 +307,49 @@ describe('MalioSelect', () => { expect(buttonClasses).not.toContain('!border-b-0') expect(buttonClasses).toContain('!border-b-transparent') }) + + it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => { + const wrapper = mount(SelectForTest, { + props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]}, + }) + const trigger = wrapper.get('button') + expect(trigger.classes()).toContain('border-black') + expect(trigger.classes()).not.toContain('border-m-muted') + expect(trigger.classes()).not.toContain('grow-height') + expect(trigger.classes()).not.toContain('focus-visible:border-m-primary') + }) + + it('readonly vide : label gris, pas de bleu', () => { + const wrapper = mount(SelectForTest, { + props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]}, + }) + const label = wrapper.get('label') + expect(label.classes()).not.toContain('text-m-primary') + expect(label.classes()).toContain('text-m-muted') + }) + + it('readonly sélectionné : label noir + chevron noir', () => { + const wrapper = mount(SelectForTest, { + props: {label: 'Champ', readonly: true, modelValue: 'a', options: [{label: 'A', value: 'a'}]}, + }) + expect(wrapper.get('label').classes()).toContain('text-black') + expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black') + }) + + it('readonly empêche l’ouverture du dropdown', async () => { + const wrapper = mount(SelectForTest, { + props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]}, + }) + await wrapper.get('button').trigger('click') + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + }) + + it('readonly expose aria-readonly et reste focusable (pas disabled)', () => { + const wrapper = mount(SelectForTest, { + props: {modelValue: null, label: 'Champ', readonly: true, options}, + }) + const trigger = wrapper.get('button') + expect(trigger.attributes('aria-readonly')).toBe('true') + expect(trigger.attributes('disabled')).toBeUndefined() + }) }) diff --git a/app/components/malio/select/Select.vue b/app/components/malio/select/Select.vue index 9276da1..2657787 100644 --- a/app/components/malio/select/Select.vue +++ b/app/components/malio/select/Select.vue @@ -8,8 +8,10 @@ :id="buttonId" ref="buttonRef" type="button" - class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary" + class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none" :class="[ + isReadonly ? '' : 'grow-height', + isReadonly ? '' : 'focus-visible:border-m-primary', hasError ? isOpen ? openDirection === 'down' @@ -22,14 +24,16 @@ ? 'rounded-b-none !border !border-m-success !border-b-transparent' : 'rounded-t-none !border !border-m-success !border-t-transparent' : 'border-m-success' - : isOpen - ? openDirection === 'down' - ? 'rounded-b-none !border !border-m-primary !border-b-transparent' - : 'rounded-t-none !border !border-m-primary !border-t-transparent' - : isOptionSelected - ? 'border-black' - : 'border-m-muted', - disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer', + : isReadonly + ? 'border-black' + : isOpen + ? openDirection === 'down' + ? 'rounded-b-none !border !border-m-primary !border-b-transparent' + : 'rounded-t-none !border !border-m-primary !border-t-transparent' + : isOptionSelected + ? 'border-black' + : 'border-m-muted', + disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer', label ? 'min-h-[40px]' : 'h-[40px] py-0', rounded, textField, @@ -39,6 +43,7 @@ :aria-invalid="hasError" :aria-describedby="describedBy" :aria-required="required || undefined" + :aria-readonly="readonly || undefined" :disabled="disabled" @click="toggle" > @@ -51,11 +56,15 @@ ? 'text-m-danger' : hasSuccess ? 'text-m-success' - : isOpen - ? 'text-m-primary' - : isOptionSelected + : isReadonly + ? isOptionSelected ? 'text-black' - : 'text-m-muted', + : 'text-m-muted' + : isOpen + ? 'text-m-primary' + : isOptionSelected + ? 'text-black' + : 'text-m-muted', textLabel, ]" :style="labelTransformStyle" @@ -83,11 +92,15 @@ ? 'text-m-success' : disabled ? 'text-m-muted' - : isOpen - ? 'text-m-primary' - : isOptionSelected + : isReadonly + ? isOptionSelected ? 'text-black' : 'text-m-muted' + : isOpen + ? 'text-m-primary' + : isOptionSelected + ? 'text-black' + : 'text-m-muted' ]" > @@ -193,6 +206,7 @@ const props = withDefaults(defineProps<{ textLabel?: string rounded?: string disabled?: boolean + readonly?: boolean groupClass?: string noOptionsText?: string required?: boolean @@ -208,6 +222,7 @@ const props = withDefaults(defineProps<{ textLabel: 'text-sm', rounded: 'rounded-md', disabled: false, + readonly: false, groupClass: '', noOptionsText: 'Aucune option disponible', required: false, @@ -238,8 +253,9 @@ const hasSuccess = computed(() => !!props.success && !hasError.value) const isOptionSelected = computed(() => props.options.some(o => o.value === props.modelValue) ) +const isReadonly = computed(() => props.readonly && !props.disabled) const shouldFloatLabel = computed(() => - isOpen.value || isOptionSelected.value + isReadonly.value ? isOptionSelected.value : (isOpen.value || isOptionSelected.value) ) const selectedLabel = computed(() => props.options.find(o => o.value === props.modelValue)?.label ?? '' @@ -267,6 +283,7 @@ function updateOpenDirection() { } function open() { + if (props.disabled || props.readonly) return updateOpenDirection() isOpen.value = true @@ -310,7 +327,7 @@ function close() { } function toggle() { - if (props.disabled) return + if (props.disabled || props.readonly) return if (isOpen.value) { close() return diff --git a/app/components/malio/select/SelectCheckbox.test.ts b/app/components/malio/select/SelectCheckbox.test.ts index 1e1da6a..bc351a3 100644 --- a/app/components/malio/select/SelectCheckbox.test.ts +++ b/app/components/malio/select/SelectCheckbox.test.ts @@ -24,6 +24,7 @@ type SelectCheckboxProps = { displaySelectAll?: boolean selectAllLabel?: string disabled?: boolean + readonly?: boolean groupClass?: string required?: boolean } @@ -281,4 +282,49 @@ describe('MalioSelectCheckbox', () => { expect(buttonClasses).not.toContain('!border-b-0') expect(buttonClasses).toContain('!border-b-transparent') }) + + it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => { + const wrapper = mount(SelectCheckboxForTest, { + props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]}, + }) + const trigger = wrapper.get('button') + expect(trigger.classes()).toContain('border-black') + expect(trigger.classes()).not.toContain('border-m-muted') + expect(trigger.classes()).not.toContain('grow-height') + expect(trigger.classes()).not.toContain('focus-visible:border-m-primary') + }) + + it('readonly vide : label gris, pas de bleu', () => { + const wrapper = mount(SelectCheckboxForTest, { + props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]}, + }) + const label = wrapper.get('label') + expect(label.classes()).not.toContain('text-m-primary') + expect(label.classes()).toContain('text-m-muted') + }) + + it('readonly sélectionné : label noir + chevron noir', () => { + const wrapper = mount(SelectCheckboxForTest, { + props: {label: 'Champ', readonly: true, modelValue: ['a'], options: [{label: 'A', value: 'a'}]}, + }) + expect(wrapper.get('label').classes()).toContain('text-black') + expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black') + }) + + it('readonly empêche l’ouverture du dropdown', async () => { + const wrapper = mount(SelectCheckboxForTest, { + props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]}, + }) + await wrapper.get('button').trigger('click') + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + }) + + it('readonly expose aria-readonly et reste focusable (pas disabled)', () => { + const wrapper = mount(SelectCheckboxForTest, { + props: {label: 'Champ', readonly: true, modelValue: [], options}, + }) + const trigger = wrapper.get('button') + expect(trigger.attributes('aria-readonly')).toBe('true') + expect(trigger.attributes('disabled')).toBeUndefined() + }) }) diff --git a/app/components/malio/select/SelectCheckbox.vue b/app/components/malio/select/SelectCheckbox.vue index 5b23495..4e561f2 100644 --- a/app/components/malio/select/SelectCheckbox.vue +++ b/app/components/malio/select/SelectCheckbox.vue @@ -8,8 +8,10 @@ :id="buttonId" ref="buttonRef" type="button" - class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary" + class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none" :class="[ + isReadonly ? '' : 'grow-height', + isReadonly ? '' : 'focus-visible:border-m-primary', hasError ? isOpen ? openDirection === 'down' @@ -22,14 +24,16 @@ ? 'rounded-b-none !border !border-m-success !border-b-transparent' : 'rounded-t-none !border !border-m-success !border-t-transparent' : 'border-m-success' - : isOpen - ? openDirection === 'down' - ? 'rounded-b-none !border !border-m-primary !border-b-transparent' - : 'rounded-t-none !border !border-m-primary !border-t-transparent' - : isOptionSelected - ? 'border-black' - : 'border-m-muted', - disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer', + : isReadonly + ? 'border-black' + : isOpen + ? openDirection === 'down' + ? 'rounded-b-none !border !border-m-primary !border-b-transparent' + : 'rounded-t-none !border !border-m-primary !border-t-transparent' + : isOptionSelected + ? 'border-black' + : 'border-m-muted', + disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer', label ? 'min-h-[40px]' : 'h-[40px] py-0', rounded, textField, @@ -39,6 +43,7 @@ :aria-invalid="hasError" :aria-describedby="describedBy" :aria-required="required || undefined" + :aria-readonly="readonly || undefined" :disabled="disabled" @click="toggle" > @@ -51,11 +56,15 @@ ? 'text-m-danger' : hasSuccess ? 'text-m-success' - : isOpen - ? 'text-m-primary' - : isOptionSelected + : isReadonly + ? isOptionSelected ? 'text-black' - : 'text-m-muted', + : 'text-m-muted' + : isOpen + ? 'text-m-primary' + : isOptionSelected + ? 'text-black' + : 'text-m-muted', textLabel, ]" :style="labelTransformStyle" @@ -111,11 +120,15 @@ ? 'text-m-success' : disabled ? 'text-m-muted' - : isOpen - ? 'text-m-primary' - : isOptionSelected + : isReadonly + ? isOptionSelected ? 'text-black' : 'text-m-muted' + : isOpen + ? 'text-m-primary' + : isOptionSelected + ? 'text-black' + : 'text-m-muted' ]" > @@ -246,6 +259,7 @@ const props = withDefaults(defineProps<{ displaySelectAll?: boolean selectAllLabel?: string disabled?: boolean + readonly?: boolean groupClass?: string noOptionsText?: string required?: boolean @@ -264,6 +278,7 @@ const props = withDefaults(defineProps<{ displaySelectAll: false, selectAllLabel: 'Tout sélectionner', disabled: false, + readonly: false, groupClass: '', noOptionsText: 'Aucune option disponible', required: false, @@ -291,6 +306,7 @@ const hasSuccess = computed(() => !!props.success && !hasError.value) const isOptionSelected = computed(() => props.modelValue.length > 0 ) +const isReadonly = computed(() => props.readonly && !props.disabled) const selectedOptions = computed(() => normalizedOptions.value.filter(option => props.modelValue.includes(option.value)), ) @@ -298,7 +314,7 @@ const displayTags = computed(() => props.displayTag && selectedOptions.value.length > 0, ) const shouldFloatLabel = computed(() => - isOpen.value || displayTags.value + isReadonly.value ? isOptionSelected.value : (isOpen.value || displayTags.value) ) const selectionSummary = computed(() => `${props.modelValue.length}/${normalizedOptions.value.length}` @@ -330,6 +346,7 @@ function updateOpenDirection() { } function open() { + if (props.disabled || props.readonly) return updateOpenDirection() isOpen.value = true @@ -373,7 +390,7 @@ function close() { } function toggle() { - if (props.disabled) return + if (props.disabled || props.readonly) return if (isOpen.value) { close() return