diff --git a/.playground/pages/composant/form/client.vue b/.playground/pages/composant/form/client.vue index e2887a1..3650407 100644 --- a/.playground/pages/composant/form/client.vue +++ b/.playground/pages/composant/form/client.vue @@ -10,7 +10,7 @@ />
Valeur sélectionnée : {{ simpleValue ?? 'null' }}
@@ -20,6 +21,7 @@
icon-name="mdi:magnify"
icon-position="left"
:options="staticOptions"
+ local-filter
/>
@@ -121,7 +120,7 @@ const mergedLabelClass = computed(() => const mergedMessageClass = computed(() => twMerge( - 'text-xs', + 'text-xs min-h-[1rem]', hasError.value ? 'text-m-danger' : hasSuccess.value diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue index 15e9148..b89d616 100644 --- a/app/components/malio/date/internal/CalendarField.vue +++ b/app/components/malio/date/internal/CalendarField.vue @@ -85,11 +85,10 @@
{{ error || success || hint }} diff --git a/app/components/malio/input/Input.test.ts b/app/components/malio/input/Input.test.ts index 167bb35..85db24c 100644 --- a/app/components/malio/input/Input.test.ts +++ b/app/components/malio/input/Input.test.ts @@ -126,6 +126,13 @@ describe('MalioInputText', () => { expect(wrapper.get('input').classes()).toContain('text-black/60') }) + it('shows muted label color when disabled (matches border color)', () => { + const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'}) + + expect(wrapper.get('label').classes()).toContain('text-m-muted') + expect(wrapper.get('label').classes()).not.toContain('text-black/60') + }) + it('emits update:modelValue on input change', async () => { const wrapper = mountInput({modelValue: ''}) @@ -253,6 +260,15 @@ describe('MalioInputText', () => { expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test') }) + it('reserves space for the message even when no hint/error/success is set', () => { + const wrapper = mountInput({}) + + const p = wrapper.find('p') + expect(p.exists()).toBe(true) + expect(p.text()).toBe('') + expect(p.classes()).toContain('min-h-[1rem]') + }) + it('does not render label when label prop is missing', () => { const wrapper = mountInput({labelClass: 'text-red-500'}) diff --git a/app/components/malio/input/InputAmount.vue b/app/components/malio/input/InputAmount.vue index 210cea9..3226bdc 100644 --- a/app/components/malio/input/InputAmount.vue +++ b/app/components/malio/input/InputAmount.vue @@ -44,7 +44,6 @@
{{ hint || error || success }} @@ -109,7 +108,7 @@ const props = withDefaults( hint: '', error: '', success: '', - iconSize: 24, + iconSize: 20, iconColor: 'text-m-muted', }, ) @@ -153,12 +152,13 @@ const mergedLabelClass = computed(() => 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', labelPositionClass.value, shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', - disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '', hasError.value ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' - : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', + : disabled.value + ? 'text-m-muted' + : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', props.labelClass, ), ) diff --git a/app/components/malio/input/InputAutocomplete.test.ts b/app/components/malio/input/InputAutocomplete.test.ts index c7a56bc..64810f8 100644 --- a/app/components/malio/input/InputAutocomplete.test.ts +++ b/app/components/malio/input/InputAutocomplete.test.ts @@ -28,6 +28,7 @@ type InputAutocompleteProps = { debounce?: number minSearchLength?: number allowCreate?: boolean + localFilter?: boolean iconName?: string iconPosition?: 'left' | 'right' iconSize?: string | number @@ -427,4 +428,82 @@ describe('MalioInputAutocomplete', () => { expect(wrapper.get('input').element.value).toBe('Custom') }) + + it('does not filter options when localFilter is false (default)', async () => { + const wrapper = mountComponent({options}) + + await wrapper.get('input').trigger('focus') + await wrapper.get('input').setValue('fr') + + expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3) + }) + + it('filters options client-side when localFilter is true', async () => { + const wrapper = mountComponent({options, localFilter: true}) + + await wrapper.get('input').trigger('focus') + await wrapper.get('input').setValue('fr') + + const items = wrapper.findAll('[data-test="option"]') + expect(items).toHaveLength(1) + expect(items[0].text()).toBe('France') + }) + + it('localFilter is case-insensitive and matches substrings', async () => { + const wrapper = mountComponent({options, localFilter: true}) + + await wrapper.get('input').trigger('focus') + await wrapper.get('input').setValue('GIQ') + + const items = wrapper.findAll('[data-test="option"]') + expect(items).toHaveLength(1) + expect(items[0].text()).toBe('Belgique') + }) + + it('localFilter shows all options when input is empty', async () => { + const wrapper = mountComponent({options, localFilter: true}) + + await wrapper.get('input').trigger('focus') + + expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3) + }) + + it('localFilter shows the no-results state when nothing matches', async () => { + const wrapper = mountComponent({options, localFilter: true}) + + await wrapper.get('input').trigger('focus') + await wrapper.get('input').setValue('zzzzz') + + expect(wrapper.findAll('[data-test="option"]')).toHaveLength(0) + expect(wrapper.find('[data-test="no-results-text"]').exists()).toBe(true) + }) + + it('keeps the floating label at the same position whether focused or not (no jump)', async () => { + const wrapper = mountComponent({options, label: 'Pays', modelValue: 'fr'}) + + // when a value is selected and the field is not focused, the label is already floated + const labelClasses = wrapper.get('label').classes() + expect(labelClasses).toContain('-translate-y-[1.25rem]') + // and there is no extra peer-focus translate that would make it jump on click + expect(labelClasses).not.toContain('peer-focus:-translate-y-[1.55rem]') + }) + + it('does not shift inner text horizontally on focus (no focus:pl change)', () => { + const wrapper = mountComponent({options}) + + const inputClasses = wrapper.get('input').classes() + expect(inputClasses).not.toContain('focus:pl-[11px]') + }) + + it('keeps the bottom border allocation when open (transparent, not zero)', async () => { + const wrapper = mountComponent({options}) + + await wrapper.get('input').trigger('focus') + + const inputClasses = wrapper.get('input').classes() + // border-b-0 would shrink the bottom border to 0px and grow content area by 1px; + // border-b-transparent keeps the 1px allocation but hides the line + expect(inputClasses).not.toContain('!border-b-0') + expect(inputClasses).toContain('!border-b-transparent') + }) }) diff --git a/app/components/malio/input/InputAutocomplete.vue b/app/components/malio/input/InputAutocomplete.vue index 5c57090..00321f5 100644 --- a/app/components/malio/input/InputAutocomplete.vue +++ b/app/components/malio/input/InputAutocomplete.vue @@ -107,7 +107,7 @@ {{ minSearchText }}
{{ hint || error || success }} @@ -180,6 +179,7 @@ const props = withDefaults( debounce?: number minSearchLength?: number allowCreate?: boolean + localFilter?: boolean iconName?: string iconPosition?: 'left' | 'right' iconSize?: string | number @@ -207,6 +207,7 @@ const props = withDefaults( debounce: 300, minSearchLength: 0, allowCreate: false, + localFilter: false, iconName: '', iconPosition: 'left', iconSize: 24, @@ -253,9 +254,18 @@ const showMinSearch = computed(() => props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength, ) +const filteredOptions = computed(() => { + if (!props.localFilter) return props.options + const query = inputValue.value.trim().toLowerCase() + if (query === '') return props.options + return props.options.filter(opt => + opt.label.toLowerCase().includes(query), + ) +}) + const optionId = (index: number) => `${inputId.value}-option-${index}` const activeOptionId = computed(() => - activeIndex.value >= 0 && props.options[activeIndex.value] + activeIndex.value >= 0 && filteredOptions.value[activeIndex.value] ? optionId(activeIndex.value) : undefined, ) @@ -294,11 +304,6 @@ const iconInputPaddingClass = computed(() => { return parts.join(' ') }) -const focusPaddingClass = computed(() => { - if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11' - return 'focus:pl-[11px]' -}) - const labelPositionClass = computed(() => props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3', ) @@ -315,10 +320,9 @@ const mergedInputClass = computed(() => : hasSuccess.value ? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success' : 'focus:border-m-primary', - isOpen.value ? '!rounded-b-none !border-b-0' : '', + isOpen.value ? '!rounded-b-none !border-b-transparent' : '', props.inputClass, iconInputPaddingClass.value, - focusPaddingClass.value, ), ) @@ -326,13 +330,14 @@ const mergedLabelClass = computed(() => twMerge( 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', labelPositionClass.value, - shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', - props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '', + shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '', hasError.value ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' - : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', + : props.disabled + ? 'text-m-muted' + : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', props.labelClass, ), ) @@ -432,8 +437,8 @@ const onKeydown = (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault() - if (activeIndex.value >= 0 && props.options[activeIndex.value]) { - onSelect(props.options[activeIndex.value]) + if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) { + onSelect(filteredOptions.value[activeIndex.value]) return } if (props.allowCreate && inputValue.value !== '') { @@ -450,7 +455,7 @@ const onKeydown = (event: KeyboardEvent) => { if (!isOpen.value) { isOpen.value = true } - activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1) + activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1) return } @@ -481,12 +486,7 @@ onBeforeUnmount(() => { } .grow-height { - transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; -} - -.grow-height:focus { - padding-top: 0.625rem; - padding-bottom: 0.625rem; + transition: border-color 160ms ease, box-shadow 160ms ease; } @media (prefers-reduced-motion: reduce) { diff --git a/app/components/malio/input/InputEmail.vue b/app/components/malio/input/InputEmail.vue index c0498dd..e728e1e 100644 --- a/app/components/malio/input/InputEmail.vue +++ b/app/components/malio/input/InputEmail.vue @@ -42,7 +42,6 @@
{{ hint || error || success }} @@ -147,12 +146,13 @@ const mergedLabelClass = computed(() => 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', labelPositionClass.value, shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', - disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '', hasError.value ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' - : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', + : disabled.value + ? 'text-m-muted' + : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', props.labelClass, ), ) diff --git a/app/components/malio/input/InputNumber.vue b/app/components/malio/input/InputNumber.vue index d39c2b4..78ed4d2 100644 --- a/app/components/malio/input/InputNumber.vue +++ b/app/components/malio/input/InputNumber.vue @@ -51,7 +51,6 @@
{{ hint || error || success }} diff --git a/app/components/malio/input/InputPassword.vue b/app/components/malio/input/InputPassword.vue index c28d73e..4bd805d 100644 --- a/app/components/malio/input/InputPassword.vue +++ b/app/components/malio/input/InputPassword.vue @@ -47,7 +47,6 @@
{{ hint || error || success }} @@ -155,12 +154,13 @@ const mergedLabelClass = computed(() => 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'left-3', shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', - disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '', hasError.value ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' - : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', + : disabled.value + ? 'text-m-muted' + : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', props.labelClass, ), ) diff --git a/app/components/malio/input/InputPhone.test.ts b/app/components/malio/input/InputPhone.test.ts index 95e334c..3bce6d5 100644 --- a/app/components/malio/input/InputPhone.test.ts +++ b/app/components/malio/input/InputPhone.test.ts @@ -298,6 +298,41 @@ describe('MalioInputPhone', () => { expect(wrapper.get('input').classes()).toContain('!pr-10') }) + it('shows default add button color when empty and unfocused', () => { + const wrapper = mountComponent({addable: true}) + + expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-muted') + expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('text-m-primary') + }) + + it('shows primary add button color on focus', async () => { + const wrapper = mountComponent({addable: true}) + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-primary') + }) + + it('shows black add button color when filled and unfocused', () => { + const wrapper = mountComponent({addable: true, modelValue: '+33612345678'}) + + expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-black') + }) + + it('error overrides focus color on add button', async () => { + const wrapper = mountComponent({addable: true, error: 'Numéro invalide'}) + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-danger') + }) + + it('success applies to add button', () => { + const wrapper = mountComponent({addable: true, success: 'Numéro valide'}) + + expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-success') + }) + it('applies mask via maska directive', async () => { const wrapper = mountComponent({mask: '+## # ## ## ## ##'}) diff --git a/app/components/malio/input/InputPhone.vue b/app/components/malio/input/InputPhone.vue index 9d3b9ee..33a61cc 100644 --- a/app/components/malio/input/InputPhone.vue +++ b/app/components/malio/input/InputPhone.vue @@ -60,7 +60,6 @@
{{ hint || error || success }} @@ -175,19 +174,21 @@ const mergedLabelClass = computed(() => 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', labelPositionClass.value, shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', - disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '', hasError.value ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' - : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', + : disabled.value + ? 'text-m-muted' + : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', props.labelClass, ), ) const mergedAddButtonClass = computed(() => twMerge( - 'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70', + 'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70', + iconStateClass.value, (props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '', ), ) diff --git a/app/components/malio/input/InputRichText.vue b/app/components/malio/input/InputRichText.vue index e07ae1b..a2e9ddc 100644 --- a/app/components/malio/input/InputRichText.vue +++ b/app/components/malio/input/InputRichText.vue @@ -184,7 +184,6 @@
{{ error || success || hint }} @@ -279,10 +278,11 @@ const mergedLabelClass = computed(() => ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' - : isFocused.value - ? 'text-m-primary' - : 'text-m-text', - props.disabled ? 'text-black/60' : '', + : props.disabled + ? 'text-m-muted' + : isFocused.value + ? 'text-m-primary' + : 'text-m-text', props.labelClass, ), ) diff --git a/app/components/malio/input/InputText.vue b/app/components/malio/input/InputText.vue index 80a9ccf..c574f29 100644 --- a/app/components/malio/input/InputText.vue +++ b/app/components/malio/input/InputText.vue @@ -44,7 +44,6 @@
{{ hint || error || success }}
@@ -158,12 +157,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
- disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
- : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
+ : disabled.value
+ ? 'text-m-muted'
+ : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
diff --git a/app/components/malio/input/InputTextArea.test.ts b/app/components/malio/input/InputTextArea.test.ts
index e5819fd..89913fc 100644
--- a/app/components/malio/input/InputTextArea.test.ts
+++ b/app/components/malio/input/InputTextArea.test.ts
@@ -149,4 +149,38 @@ describe('MalioInputTextArea', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
})
+
+ it('renders as a single root element (works as a single grid item)', () => {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const wrapper = mount(InputTextAreaForTest, {
+ attachTo: host,
+ })
+
+ // host > div[data-v-app] > component roots
+ const app = host.firstElementChild as HTMLElement
+ expect(app.children.length).toBe(1)
+
+ wrapper.unmount()
+ host.remove()
+ })
+
+ it('applies primary scrollbar class on focus', async () => {
+ const wrapper = mount(InputTextAreaForTest)
+
+ expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
+
+ await wrapper.get('textarea').trigger('focus')
+
+ expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
+ })
+
+ it('removes primary scrollbar class on blur', async () => {
+ const wrapper = mount(InputTextAreaForTest)
+
+ await wrapper.get('textarea').trigger('focus')
+ await wrapper.get('textarea').trigger('blur')
+
+ expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
+ })
})
diff --git a/app/components/malio/input/InputTextArea.vue b/app/components/malio/input/InputTextArea.vue
index e236ca9..691a892 100644
--- a/app/components/malio/input/InputTextArea.vue
+++ b/app/components/malio/input/InputTextArea.vue
@@ -1,79 +1,82 @@
+ {{ error || success || hint }}
+
{{ hint || error || success }}
@@ -144,12 +143,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
- disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
- : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
+ : disabled.value
+ ? 'text-m-muted'
+ : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
diff --git a/app/components/malio/select/Select.test.ts b/app/components/malio/select/Select.test.ts
index dae6334..0e4cbce 100644
--- a/app/components/malio/select/Select.test.ts
+++ b/app/components/malio/select/Select.test.ts
@@ -207,4 +207,70 @@ describe('MalioSelect', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
})
+
+ it('shows muted chevron color when empty and closed', () => {
+ const wrapper = mount(SelectForTest, {
+ props: {modelValue: null, options},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
+ })
+
+ it('shows primary chevron color when open', async () => {
+ const wrapper = mount(SelectForTest, {
+ props: {modelValue: null, options},
+ })
+
+ await wrapper.get('button').trigger('click')
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
+ })
+
+ it('shows black chevron color when an option is selected and closed', () => {
+ const wrapper = mount(SelectForTest, {
+ props: {modelValue: 'fr', options},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
+ })
+
+ it('shows muted chevron color when disabled', () => {
+ const wrapper = mount(SelectForTest, {
+ props: {modelValue: 'fr', options, disabled: true},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
+ })
+
+ it('shows danger chevron color on error even when open', async () => {
+ const wrapper = mount(SelectForTest, {
+ props: {modelValue: null, options, error: 'Selection error'},
+ })
+
+ await wrapper.get('button').trigger('click')
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
+ })
+
+ it('shows success chevron color on success', () => {
+ const wrapper = mount(SelectForTest, {
+ props: {modelValue: null, options, success: 'OK'},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
+ })
+
+ it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
+ const wrapper = mount(SelectForTest, {
+ props: {modelValue: null, options},
+ })
+
+ await wrapper.get('button').trigger('click')
+
+ const buttonClasses = wrapper.get('button').classes()
+ // !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
+ // !border-b-transparent keeps the 1px allocation but hides the line
+ expect(buttonClasses).not.toContain('!border-b-0')
+ expect(buttonClasses).toContain('!border-b-transparent')
+ })
})
diff --git a/app/components/malio/select/Select.vue b/app/components/malio/select/Select.vue
index c7127f1..2d4540c 100644
--- a/app/components/malio/select/Select.vue
+++ b/app/components/malio/select/Select.vue
@@ -13,19 +13,19 @@
hasError
? isOpen
? openDirection === 'down'
- ? 'rounded-b-none !border !border-m-danger !border-b-0'
- : 'rounded-t-none !border !border-m-danger !border-t-0'
+ ? 'rounded-b-none !border !border-m-danger !border-b-transparent'
+ : 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger'
: hasSuccess
? isOpen
? openDirection === 'down'
- ? 'rounded-b-none !border !border-m-success !border-b-0'
- : 'rounded-t-none !border !border-m-success !border-t-0'
+ ? '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-0'
- : 'rounded-t-none !border !border-m-primary !border-t-0'
+ ? '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',
@@ -73,13 +73,20 @@
{{ error || success || hint }}
@@ -330,12 +336,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
}
.grow-height {
- transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
-}
-
-.grow-height:focus {
- padding-top: 0.625rem;
- padding-bottom: 0.625rem;
+ transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
diff --git a/app/components/malio/select/SelectCheckbox.test.ts b/app/components/malio/select/SelectCheckbox.test.ts
index 8956d89..db9c5aa 100644
--- a/app/components/malio/select/SelectCheckbox.test.ts
+++ b/app/components/malio/select/SelectCheckbox.test.ts
@@ -182,4 +182,70 @@ describe('MalioSelectCheckbox', () => {
const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('mt-4')
})
+
+ it('shows muted chevron color when nothing is selected and closed', () => {
+ const wrapper = mount(SelectCheckboxForTest, {
+ props: {modelValue: [], options},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
+ })
+
+ it('shows primary chevron color when open', async () => {
+ const wrapper = mount(SelectCheckboxForTest, {
+ props: {modelValue: [], options},
+ })
+
+ await wrapper.get('button').trigger('click')
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
+ })
+
+ it('shows black chevron color when options are selected and closed', () => {
+ const wrapper = mount(SelectCheckboxForTest, {
+ props: {modelValue: ['fr'], options},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
+ })
+
+ it('shows muted chevron color when disabled', () => {
+ const wrapper = mount(SelectCheckboxForTest, {
+ props: {modelValue: ['fr'], options, disabled: true},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
+ })
+
+ it('shows danger chevron color on error even when open', async () => {
+ const wrapper = mount(SelectCheckboxForTest, {
+ props: {modelValue: [], options, error: 'Selection error'},
+ })
+
+ await wrapper.get('button').trigger('click')
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
+ })
+
+ it('shows success chevron color on success', () => {
+ const wrapper = mount(SelectCheckboxForTest, {
+ props: {modelValue: [], options, success: 'OK'},
+ })
+
+ expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
+ })
+
+ it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
+ const wrapper = mount(SelectCheckboxForTest, {
+ props: {modelValue: [], options},
+ })
+
+ await wrapper.get('button').trigger('click')
+
+ const buttonClasses = wrapper.get('button').classes()
+ // !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
+ // !border-b-transparent keeps the 1px allocation but hides the line
+ expect(buttonClasses).not.toContain('!border-b-0')
+ expect(buttonClasses).toContain('!border-b-transparent')
+ })
})
diff --git a/app/components/malio/select/SelectCheckbox.vue b/app/components/malio/select/SelectCheckbox.vue
index 16bd449..673f12b 100644
--- a/app/components/malio/select/SelectCheckbox.vue
+++ b/app/components/malio/select/SelectCheckbox.vue
@@ -13,19 +13,19 @@
hasError
? isOpen
? openDirection === 'down'
- ? 'rounded-b-none !border !border-m-danger !border-b-0'
- : 'rounded-t-none !border !border-m-danger !border-t-0'
+ ? 'rounded-b-none !border !border-m-danger !border-b-transparent'
+ : 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger'
: hasSuccess
? isOpen
? openDirection === 'down'
- ? 'rounded-b-none !border !border-m-success !border-b-0'
- : 'rounded-t-none !border !border-m-success !border-t-0'
+ ? '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-0'
- : 'rounded-t-none !border !border-m-primary !border-t-0'
+ ? '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',
@@ -101,13 +101,20 @@
{{ error || success || hint }}
@@ -409,12 +415,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
}
.grow-height {
- transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
-}
-
-.grow-height:focus {
- padding-top: 0.625rem;
- padding-bottom: 0.625rem;
+ transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
diff --git a/app/components/malio/time/Time.vue b/app/components/malio/time/Time.vue
index 27eabe7..a3801c7 100644
--- a/app/components/malio/time/Time.vue
+++ b/app/components/malio/time/Time.vue
@@ -58,7 +58,6 @@
{{ error || success || hint }}
diff --git a/app/components/malio/time/TimePicker.vue b/app/components/malio/time/TimePicker.vue
index a292b77..9a5d656 100644
--- a/app/components/malio/time/TimePicker.vue
+++ b/app/components/malio/time/TimePicker.vue
@@ -78,11 +78,10 @@
{{ error || success || hint }}