I was using Histoire to test some components and came across some strange behavior in one of my components.
I have a Vue component called RadioButton. I also have a RadioButtonGroup component. Both use vee-validate. When I use the RadioButtonGroup and click a radio input, only 1 update:modelValue event is fired, which is what I want.
However, when I use the RadioButton component in a v-for loop, all inputs with the same name
attribute emit the update:modelValue event. And it would kind of make sense if two events fired, 1 where a radio got checked and one where a radio got unchecked but it doesn't do that. All of the radio inputs with the given name fire the event, this could be 2 or 8.
RadioButton.vue
<template>
<label
:for="id"
class="elab-ui-radio">
<input
:id="id"
type="radio"
:name="name"
:value="val"
ref="radio"
:checked="modelValue === val"
:disabled="disabled"
@click="onClick"
/>
{{ label }}
</label>
</template>
<script lang="ts" setup>
import {toRefs} from 'vue';
import {useField} from 'vee-validate';
const props = defineProps({
label: {
type: String,
default: ''
},
name: {
type: String,
default: ''
},
modelValue: {
type: String
},
val: {
type: String,
default: ''
},
id: {
type: String,
default: ''
},
disabled: {
type: Boolean,
required: false,
default: false
},
rules: {
type: [String, Object],
required: false
},
validateOnMount: {
type: Boolean,
required: false
}
});
const { name, rules } = toRefs(props);
const { handleChange } = useField(
name,
rules,
{
validateOnMount: props.validateOnMount
}
);
const onClick = (value: any) => {
handleChange(value)
}
</script>
RadioButtonGroup
<template>
<div class="elab-ui-radio-group">
<span class="group-label">
{{ label }}
<required-indicator v-if="rules"/>
<ToolTip v-if="note && note.length>0" :content="note">
<font-awesome-icon icon="fas fa-exclamation-circle" />
</ToolTip>
</span>
<RadioButton
v-for="(item, key) in items"
:key="key"
:label="getItemLabel(item)"
:val="getItemValue(item)"
:id="'radio-option-' + getItemLabel(item)"
:name="name"
:disabled="disabled"
v-model="value"
:rules="rules"
:validate-on-mount="validateOnMount"/>
<ErrorMessage :name="name" as="div" class="error-message" />
</div>
</template>
<script lang="ts" setup>
import {ErrorMessage, useField} from 'vee-validate';
import {toRefs, watch} from 'vue';
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
import {library} from '@fortawesome/fontawesome-svg-core';
import {faExclamationCircle} from '@fortawesome/free-solid-svg-icons';
import RequiredIndicator from '../layout/RequiredIndicator.vue';
import RadioButton from '../../../components/forms/input/RadioButton.vue';
import ToolTip from '../../../components/elements/ToolTip.vue';
export interface RadioButtonItem {
label: string,
value: any
}
const props = defineProps({
name: {
type: String,
required: true
},
label: String,
modelValue: {
required: true
},
items: {
type: Array,
default: () => []
},
note: {
type: String,
default: '',
required: false
},
dataTest: {
type: String
},
rules: {
type: [Object, String],
default: ''
},
validateOnMount: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
})
library.add(faExclamationCircle);
const { name, rules } = toRefs(props);
const {value, handleChange, errorMessage, meta} = useField(
name,
rules,
{
validateOnMount: props.validateOnMount
}
);
const getItemValue = function (item: string | RadioButtonItem): string {
if (typeof item === 'string') {
return item;
}
return item.value ? item.value : item.label;
};
const getItemLabel = function (item: string | RadioButtonItem): string {
if (typeof item === 'string') {
return item;
}
return item.label ? item.label : item.value;
};
watch(() => props.modelValue, (newVal) => {
handleChange(newVal);
});
const onChange = (value: any) => {
handleChange(value);
};
</script>
The way i'm testing these components is in Histoire. The state
variable is a function that returns the following object.
function radioButtonGroupInitState() {
return {
label: 'Click for memory check',
options: [
{label: 'HDD', value: '2TB'},
{label: 'SSD', value: '512GB'},
{label: 'RAM', value: '16GB'},
{label: 'Floppy', value: '1MB'}
],
modelValue: '',
required: true,
validateOnMount: true,
disabled: false,
note: ''
}
}
Using RadioButtonGroup
<Form>
<RadioButtonGroup
v-model="state.modelValue"
:items="state.options"
:label="state.label"
name="radioButtonGroup"
:note="state.note"
:disabled="state.disabled"
:rules="state.required ? 'required' : ''"
:validate-on-mount="state.validateOnMount"
@update:modelValue="logEvent('Radio changed', $event)"/>
Modelled value: {{ state.modelValue }}
</Form>
Clicking input in RadioButtonGroup
Using RadioButton with v-for
<Form class="custom-radio-form">
<h4>{{state.label}}</h4>
<div
class="custom-radio-wrapper"
v-for="(option, key) in state.options"
:key="key" >
<RadioButton
:label="option.label"
:val="option.value"
:id="'radio-option-' + option.label"
name="custom-radio-group"
:disabled="state.disabled"
v-model="state.modelValue"
:rules="state.required ? 'required' : ''"
:validate-on-mount="state.validateOnMount"
@update:modelValue="logEvent('option-'+option.label, $event)"/>
</div>
<ErrorMessage name="custom-radio-group" as="div"/>
<p>Model value: {{ state.modelValue }}</p>
</Form>
Clicking RadioButton with v-for
I have a feeling it has something to do with vee-validate in the RadioButton component and the handleChange function firing in each RadioButton instance and that causing all components to think that they must update the model value.
So it seemed I was right. The vee-validate function in RadioButton.vue were causing the issue. Each time a change occurred in the radio inputs the
handleChange
function was called, which in turn emitted theupdate:modelValue
event. The way I fixed that was removing vee-validate from the RadioButton component and only using it in the components or other code that implemented the RadioButton component.I only really changed the script part as seen below:
Now instead of using
handleChange
I just emit the value of the radio button that was clicked.