refactor(frontend): support two-col layout on FormField
This commit is contained in:
parent
59e7c9bce3
commit
a6811a922a
|
|
@ -14,7 +14,7 @@ describe('FormField', () => {
|
|||
const wrapper = mount(FormField, {
|
||||
props: {
|
||||
modelValue: 'initial',
|
||||
'onUpdate:modelValue': (val: string) => wrapper.setProps({modelValue: val}),
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
|
|
@ -199,4 +199,63 @@ describe('FormField', () => {
|
|||
await input.setValue('test value')
|
||||
expect(wrapper.vm.value).toBe('test value')
|
||||
})
|
||||
|
||||
it('renders two-col layout with wrapping label', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col'},
|
||||
slots: {
|
||||
default: '<input class="input" />',
|
||||
},
|
||||
})
|
||||
const label = wrapper.find('label.two-col')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.find('span').text()).toBe('Name')
|
||||
expect(label.find('input.input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('two-col layout exposes id via slot scope', () => {
|
||||
const wrapper = mount({
|
||||
components: {FormField},
|
||||
template: `
|
||||
<FormField label="X" layout="two-col" id="custom-id" v-slot="{id}">
|
||||
<input :id="id" />
|
||||
</FormField>
|
||||
`,
|
||||
})
|
||||
expect(wrapper.find('input').attributes('id')).toBe('custom-id')
|
||||
})
|
||||
|
||||
it('two-col layout omits the for attribute so implicit nesting labels any slotted control', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col'},
|
||||
slots: {
|
||||
default: '<input id="some-generated-id" />',
|
||||
},
|
||||
})
|
||||
const label = wrapper.find('label.two-col')
|
||||
// for would point to a different id than the slotted control generates,
|
||||
// so omit it entirely and rely on the label wrapping the control.
|
||||
expect(label.attributes('for')).toBeUndefined()
|
||||
expect(label.find('input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the error message in two-col layout', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col', error: 'Required'},
|
||||
})
|
||||
const help = wrapper.find('p.help.is-danger')
|
||||
expect(help.exists()).toBe(true)
|
||||
expect(help.text()).toBe('Required')
|
||||
})
|
||||
|
||||
it('renders the addon slot in two-col layout', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col'},
|
||||
slots: {
|
||||
addon: '<button>Copy</button>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.field.has-addons').exists()).toBe(true)
|
||||
expect(wrapper.find('button').text()).toBe('Copy')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,16 +8,18 @@ interface Props {
|
|||
id?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
layout?: 'stacked' | 'two-col'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'stacked',
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
// Preserve numeric type if modelValue was a number
|
||||
if (typeof props.modelValue === 'number') {
|
||||
emit('update:modelValue', value === '' ? '' : Number(value))
|
||||
} else {
|
||||
|
|
@ -53,8 +55,6 @@ const inputClasses = computed(() => [
|
|||
},
|
||||
])
|
||||
|
||||
// Only bind value when modelValue is explicitly provided (not undefined)
|
||||
// This allows the component to be used without v-model for native input behavior
|
||||
const inputBindings = computed(() => {
|
||||
const bindings: Record<string, unknown> = {}
|
||||
if (props.modelValue !== undefined) {
|
||||
|
|
@ -63,7 +63,6 @@ const inputBindings = computed(() => {
|
|||
return bindings
|
||||
})
|
||||
|
||||
// Expose input element for direct access (needed for browser autofill workarounds)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
defineExpose({
|
||||
get value() {
|
||||
|
|
@ -77,34 +76,57 @@ defineExpose({
|
|||
|
||||
<template>
|
||||
<div :class="fieldClasses">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="label"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div :class="controlClasses">
|
||||
<slot :id="inputId">
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...inputBindings }"
|
||||
:class="inputClasses"
|
||||
:disabled="disabled || undefined"
|
||||
@input="handleInput"
|
||||
>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.addon"
|
||||
class="control"
|
||||
>
|
||||
<slot name="addon" />
|
||||
</div>
|
||||
|
||||
<template v-if="layout === 'two-col'">
|
||||
<label
|
||||
v-if="label"
|
||||
class="two-col"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<slot :id="inputId">
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...inputBindings }"
|
||||
:class="inputClasses"
|
||||
:disabled="disabled || undefined"
|
||||
@input="handleInput"
|
||||
>
|
||||
</slot>
|
||||
</label>
|
||||
<div
|
||||
v-if="$slots.addon"
|
||||
class="control"
|
||||
>
|
||||
<slot name="addon" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="label"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<div :class="controlClasses">
|
||||
<slot :id="inputId">
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...inputBindings }"
|
||||
:class="inputClasses"
|
||||
:disabled="disabled || undefined"
|
||||
@input="handleInput"
|
||||
>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.addon"
|
||||
class="control"
|
||||
>
|
||||
<slot name="addon" />
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
v-if="error"
|
||||
class="help is-danger"
|
||||
|
|
@ -113,3 +135,21 @@ defineExpose({
|
|||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
label.two-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
label.two-col > span,
|
||||
label.two-col :deep(input),
|
||||
label.two-col :deep(.input),
|
||||
label.two-col :deep(.select),
|
||||
label.two-col :deep(.timezone-select),
|
||||
label.two-col :deep(.multiselect) {
|
||||
flex: 0 0 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue