feat(ui) : état readonly visuel sur pickers date/heure

Bordure noire forcée (même vide), suppression du bleu focus/primary, label
et icône en text-black si rempli sinon text-m-muted, float piloté par isFilled
uniquement en readonly. Bouton clear et astérisque inchangés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:30:45 +02:00
parent f8c0bf13d5
commit 12a165c1c1
4 changed files with 76 additions and 12 deletions
+26
View File
@@ -185,6 +185,32 @@ describe('MalioDate', () => {
await wrapper.get('[data-test="date-input"]').trigger('click') await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
}) })
it('readonly vide : bordure noire sans bleu', () => {
const wrapper = mountDate({readonly: true})
const input = wrapper.get('[data-test="date-input"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('border-m-muted')
expect(input.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label muted sans bleu', () => {
const wrapper = mountDate({readonly: true, label: 'Date'})
const label = wrapper.get('label')
expect(label.classes()).toContain('text-m-muted')
expect(label.classes()).not.toContain('text-m-primary')
})
it('readonly rempli : label et icône en noir, bordure noire', () => {
const wrapper = mountDate({readonly: true, label: 'Date', modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
const label = wrapper.get('label')
const icon = wrapper.get('[data-test="calendar-icon"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('focus:border-m-primary')
expect(label.classes()).toContain('text-black')
expect(icon.classes()).toContain('text-black')
})
}) })
describe('accessibilité', () => { describe('accessibilité', () => {
@@ -158,6 +158,7 @@ const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId
const hasError = computed(() => !!props.error) const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value) const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => props.displayValue.length > 0) const isFilled = computed(() => props.displayValue.length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const showClear = computed(() => const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly, props.clearable && isFilled.value && !props.disabled && !props.readonly,
) )
@@ -195,13 +196,15 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() => const mergedInputClass = computed(() =>
twMerge( twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent', 'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted', isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '', props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value hasError.value
? 'border-m-danger' ? 'border-m-danger'
: hasSuccess.value : hasSuccess.value
? 'border-m-success' ? 'border-m-success'
: 'focus:border-m-primary', : isReadonly.value ? '' : 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '', isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
props.inputClass, props.inputClass,
), ),
@@ -210,14 +213,16 @@ const mergedInputClass = computed(() =>
const mergedLabelClass = computed(() => const mergedLabelClass = computed(() =>
twMerge( twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150', 'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '', (isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: isOpen.value : isReadonly.value
? 'text-m-primary' ? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted text-black', : isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass, props.labelClass,
), ),
) )
@@ -225,6 +230,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => { const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger' if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success' if (hasSuccess.value) return 'text-m-success'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary' if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black' if (isFilled.value) return 'text-black'
return 'text-m-muted' return 'text-m-muted'
@@ -83,4 +83,30 @@ describe('MalioTimePicker', () => {
const wrapper = mountPicker({label: 'Champ'}) const wrapper = mountPicker({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false) expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
}) })
it('readonly vide : bordure noire sans bleu', () => {
const wrapper = mountPicker({readonly: true})
const input = wrapper.get('[data-test="time-field"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('border-m-muted')
expect(input.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label muted sans bleu', () => {
const wrapper = mountPicker({readonly: true, label: 'Heure'})
const label = wrapper.get('label')
expect(label.classes()).toContain('text-m-muted')
expect(label.classes()).not.toContain('text-m-primary')
})
it('readonly rempli : label et icône en noir, bordure noire', () => {
const wrapper = mountPicker({readonly: true, label: 'Heure', modelValue: '14:30'})
const input = wrapper.get('[data-test="time-field"]')
const label = wrapper.get('label')
const icon = wrapper.get('[data-test="clock-icon"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('focus:border-m-primary')
expect(label.classes()).toContain('text-black')
expect(icon.classes()).toContain('text-black')
})
}) })
+12 -6
View File
@@ -153,6 +153,7 @@ const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value) const hasSuccess = computed(() => !!props.success && !hasError.value)
const displayValue = computed(() => currentValue.value ?? '') const displayValue = computed(() => currentValue.value ?? '')
const isFilled = computed(() => displayValue.value.length > 0) const isFilled = computed(() => displayValue.value.length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const wheelsValue = computed(() => currentValue.value || '00:00') const wheelsValue = computed(() => currentValue.value || '00:00')
const showClear = computed(() => const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly, props.clearable && isFilled.value && !props.disabled && !props.readonly,
@@ -192,13 +193,15 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() => const mergedInputClass = computed(() =>
twMerge( twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent', 'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted', isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '', props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '',
hasError.value hasError.value
? 'border-m-danger' ? 'border-m-danger'
: hasSuccess.value : hasSuccess.value
? 'border-m-success' ? 'border-m-success'
: 'focus:border-m-primary', : isReadonly.value ? '' : 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !rounded-b-none !py-[9px]' : '', isOpen.value ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
props.inputClass, props.inputClass,
), ),
@@ -207,14 +210,16 @@ const mergedInputClass = computed(() =>
const mergedLabelClass = computed(() => const mergedLabelClass = computed(() =>
twMerge( twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150', 'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '', (isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: isOpen.value : isReadonly.value
? 'text-m-primary' ? isFilled.value ? 'text-black' : 'text-m-muted'
: 'text-black peer-placeholder-shown:text-m-muted', : isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
props.labelClass, props.labelClass,
), ),
) )
@@ -222,6 +227,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => { const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger' if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success' if (hasSuccess.value) return 'text-m-success'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary' if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black' if (isFilled.value) return 'text-black'
return 'text-m-muted' return 'text-m-muted'