feat(ui) : état readonly visuel sur Select et SelectCheckbox

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:11:13 +02:00
parent f5163f10f1
commit 621077f555
4 changed files with 162 additions and 36 deletions
@@ -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 louverture 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()
})
})
+35 -18
View File
@@ -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'
]"
>
<slot name="icon">
@@ -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
@@ -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 louverture 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()
})
})
+35 -18
View File
@@ -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'
]"
>
<slot name="icon">
@@ -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