feat(frontend): add FormSelect primitive component
This commit is contained in:
parent
875f685caf
commit
740546ee5a
|
|
@ -0,0 +1,175 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import FormSelect from './FormSelect.vue'
|
||||
|
||||
describe('FormSelect', () => {
|
||||
it('renders the Bulma select wrapper and a native select', () => {
|
||||
const wrapper = mount(FormSelect)
|
||||
expect(wrapper.find('div.select').exists()).toBe(true)
|
||||
expect(wrapper.find('div.select > select').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders options from the default slot', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
slots: {
|
||||
default: '<option value="a">A</option><option value="b">B</option>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.findAll('option').length).toBe(2)
|
||||
})
|
||||
|
||||
it('supports v-model with string values', async () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
modelValue: 'a',
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
slots: {
|
||||
default: '<option value="a">A</option><option value="b">B</option>',
|
||||
},
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
expect((select.element as HTMLSelectElement).value).toBe('a')
|
||||
|
||||
await select.setValue('b')
|
||||
expect(wrapper.props('modelValue')).toBe('b')
|
||||
})
|
||||
|
||||
it('preserves numeric type in v-model when modelValue is a number', async () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
modelValue: 1,
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
slots: {
|
||||
default: '<option value="1">One</option><option value="2">Two</option>',
|
||||
},
|
||||
})
|
||||
await wrapper.find('select').setValue('2')
|
||||
expect(wrapper.props('modelValue')).toBe(2)
|
||||
})
|
||||
|
||||
it('coerces to number when the .number modifier is set even if modelValue starts null', async () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
modelValue: null,
|
||||
modelModifiers: {number: true},
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
slots: {
|
||||
default: '<option value="1">One</option><option value="2">Two</option>',
|
||||
},
|
||||
})
|
||||
await wrapper.find('select').setValue('2')
|
||||
expect(wrapper.props('modelValue')).toBe(2)
|
||||
expect(typeof wrapper.props('modelValue')).toBe('number')
|
||||
})
|
||||
|
||||
it('applies is-loading on the wrapper when loading', () => {
|
||||
const wrapper = mount(FormSelect, {props: {loading: true}})
|
||||
expect(wrapper.find('div.select').classes()).toContain('is-loading')
|
||||
})
|
||||
|
||||
it('applies disabled to the native select', () => {
|
||||
const wrapper = mount(FormSelect, {props: {disabled: true}})
|
||||
expect(wrapper.find('select').attributes('disabled')).toBe('')
|
||||
})
|
||||
|
||||
it('uses an explicit id prop when given, otherwise generates one', () => {
|
||||
const withProp = mount(FormSelect, {props: {id: 'explicit'}})
|
||||
expect(withProp.find('select').attributes('id')).toBe('explicit')
|
||||
|
||||
const standalone = mount(FormSelect)
|
||||
expect(standalone.find('select').attributes('id')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders error message when error prop is set', () => {
|
||||
const wrapper = mount(FormSelect, {props: {error: 'Pick one'}})
|
||||
expect(wrapper.find('p.help.is-danger').text()).toBe('Pick one')
|
||||
})
|
||||
|
||||
it('does not render error message when error is null or empty', () => {
|
||||
const nullErr = mount(FormSelect, {props: {error: null}})
|
||||
expect(nullErr.find('p.help.is-danger').exists()).toBe(false)
|
||||
|
||||
const emptyErr = mount(FormSelect, {props: {error: ''}})
|
||||
expect(emptyErr.find('p.help.is-danger').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders options from the options prop with object entries', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
options: [
|
||||
{value: 'a', label: 'Alpha'},
|
||||
{value: 'b', label: 'Bravo'},
|
||||
],
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(2)
|
||||
expect(options[0].attributes('value')).toBe('a')
|
||||
expect(options[0].text()).toBe('Alpha')
|
||||
expect(options[1].attributes('value')).toBe('b')
|
||||
expect(options[1].text()).toBe('Bravo')
|
||||
})
|
||||
|
||||
it('coerces primitive options into value/label pairs', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {options: ['one', 'two']},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(2)
|
||||
expect(options[0].attributes('value')).toBe('one')
|
||||
expect(options[0].text()).toBe('one')
|
||||
})
|
||||
|
||||
it('marks an option as disabled when disabled: true is given', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
options: [
|
||||
{value: 'a', label: 'Alpha'},
|
||||
{value: 'b', label: 'Bravo', disabled: true},
|
||||
],
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options[0].attributes('disabled')).toBeUndefined()
|
||||
expect(options[1].attributes('disabled')).toBe('')
|
||||
})
|
||||
|
||||
it('falls back to the default slot when options prop is not given', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
slots: {
|
||||
default: '<option value="x">From slot</option>',
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].text()).toBe('From slot')
|
||||
})
|
||||
|
||||
it('does not bind value when modelValue is undefined', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
slots: {
|
||||
default: '<option value="">--</option><option value="a">A</option><option value="b">B</option>',
|
||||
},
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
// Without an explicit value binding, the native select defaults to the
|
||||
// first option. If the component forced :value="undefined" that default
|
||||
// would be broken.
|
||||
expect((select.element as HTMLSelectElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('ignores the slot when options prop is given', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {options: [{value: 'a', label: 'From prop'}]},
|
||||
slots: {
|
||||
default: '<option value="x">From slot</option>',
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].text()).toBe('From prop')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, useId} from 'vue'
|
||||
|
||||
export type SelectOption =
|
||||
| string
|
||||
| number
|
||||
| {value: string | number, label: string, disabled?: boolean}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | null
|
||||
modelModifiers?: {number?: boolean}
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
options?: SelectOption[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelModifiers: () => ({}),
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
defineOptions({inheritAttrs: false})
|
||||
|
||||
const fallbackId = useId()
|
||||
const selectId = computed(() => props.id ?? fallbackId)
|
||||
|
||||
const wrapperClasses = computed(() => [
|
||||
'select',
|
||||
{'is-loading': props.loading},
|
||||
])
|
||||
|
||||
const selectBindings = computed(() => {
|
||||
const bindings: Record<string, unknown> = {}
|
||||
if (props.modelValue !== undefined) {
|
||||
bindings.value = props.modelValue
|
||||
}
|
||||
return bindings
|
||||
})
|
||||
|
||||
const normalizedOptions = computed(() => {
|
||||
if (!props.options) {
|
||||
return null
|
||||
}
|
||||
return props.options.map(opt => {
|
||||
if (typeof opt === 'object' && opt !== null) {
|
||||
return opt
|
||||
}
|
||||
return {value: opt, label: String(opt)}
|
||||
})
|
||||
})
|
||||
|
||||
function handleChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
const shouldCoerceNumber = props.modelModifiers.number || typeof props.modelValue === 'number'
|
||||
if (shouldCoerceNumber) {
|
||||
emit('update:modelValue', value === '' ? '' : Number(value))
|
||||
} else {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="wrapperClasses">
|
||||
<select
|
||||
:id="selectId"
|
||||
v-bind="{ ...$attrs, ...selectBindings }"
|
||||
:disabled="disabled || undefined"
|
||||
@change="handleChange"
|
||||
>
|
||||
<template v-if="normalizedOptions">
|
||||
<option
|
||||
v-for="opt in normalizedOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:disabled="opt.disabled || undefined"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</template>
|
||||
<slot v-else />
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select select {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue