Better Vue.js inputs with Generics: The Select
Itās been a while since I talked about generic types in Vue components, especially since they were last introduced in Vue 3.3.0. Iāve been using them a lot lately and I wanted to share some of the use cases where I think they can be really useful, especially with Input components. We will be covering 3 main types of input components and how can you use generic types to make them more type-safe and pleasant to use. In this article, I will talk about the Select input and how to craft a strict and clear API.
The problem
Often when you are building a select input, you pass in the options as a prop to avoid having to enumerate the objects declaratively in the template. But something you may have done or noticed with some 3rd party libraries is you are limited to having options satisfying a certain shape or value type. It mostly revolves around presenting the option item, it must have a value and it also should have a label to show to the user.
So there are many ways to model the options array, a couple of those could be:
- An array of primitive values, like strings, here the string item serves as both the value and the label.
- Array of objects, each object always has distinct properties serving as a label and value, commonly they are named
label
andvalue
but it may vary depending on which 3rd party library you are using.
Both approaches have their cons, the first one is very limited as you must pass a value that is both presentable to the user and can be used within your data models as a value. Unless you are working with a simple form, this is not a good approach.
The second approach has more promise here, but you limit the developer who is using your component to always map their objects to the shape you are expecting. Since we are going with clarity through stricter types, this could be a great starting point, letās start with that.
Such a component could initially look like this:
vue<script setup lang="ts">
const props = defineProps<{
name: string;
label: string;
options: { label: string; value: string }[];
placeholder?: string;
}>();
const model = defineModel<string>({
default: '',
});
</script>
<template>
<div>
<label :for="name">{{ label }}</label>
<select :id="name" :name="name" v-model="model">
<option value="">
{{ placeholder || 'Select an option' }}
</option>
<option
v-for="opt in options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
</template>
Iāve baked in some nice defaults like a placeholder and a default value to keep the empty option selected. However, there are a few problems with such a component in terms of developer usability.
The first problem is: it emits string
as the selected value type, which is not always ideal.
Another issue is your options must conform to a specific shape, this is fine for many cases but limits and forces the developer to always map their objects to the shape you are expecting.
Here is a typical case where you have a collection of items of a certain shape, and you are forced to map it to be able to use it with the component:
tsconst users = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
];
const options = users.map((user) => ({
label: user.name,
value: user.id,
}));
Both of these issues make our component a bit of a pain to use, and we can do better with generic types.
Giving flexibility to props
Letās bring generic types into the mix and see how we can utilize them to improve this component.
First, I would like to make our component a bit flexible, so it can accept either an array of strings OR an array of objects of any shape.
vue<script
setup
lang="ts"
generic="TOption extends string | Record<string, unknown>"
>
const props = defineProps<{
name: string;
label: string;
options: TOption[];
placeholder?: string;
}>();
</script>
The template will break because we have no idea what the shape of the object is. But the is, we donāt have to know. We can let the user of our component decide how to present the option and how to map it to a value.
Letās tackle option labels first, we can introduce an optionValue
prop which is a function that takes in an option and returns a string to be used as the option label.
vue<script
setup
lang="ts"
generic="TOption extends string | Record<string, unknown>"
>
const props = defineProps<{
//...
options: TOption[];
optionLabel: (opt: TOption) => string;
}>();
// ...
</script>
<template>
<!-- ... -->
<option v-for="opt in options" :key="opt.value">
{{ optionLabel(opt) }}
</option>
<!-- ... -->
</template>
When you try to pass the optionLabel
to the props notice that you get type checks for the option argument. So regardless of what type of options you pass in, it will be inferred correctly and piped back to your props.
vue<script setup lang="ts">
const users = [
{ firstName: 'John', id: 1 },
{ firstName: 'Ahmed', id: 2 },
];
</script>
<template>
<InputSelect
:option-label="(opt) => opt.firstName"
:options="users"
/>
</template>
Letās tackle the optionās value next. We donāt want to force the user to choose a single prop to be used as the value, for example, maybe they want to select the id
as the value. Alternatively, they may want a different property or even the entire option object. So how do we solve this?
Since the model value could be different than the option value, that means we need another generic type to represent that relationship. That means we have another generic type to introduce, letās call it TValue
.
vue<script
setup
lang="ts"
generic="
TOption extends string | Record<string, unknown>,
TValue = TOption
"
>
const props = defineProps<{
name: string;
label: string;
options: TOption[];
placeholder?: string;
optionLabel: (opt: TOption) => string;
optionValue: (opt: TOption) => TValue;
}>();
//...
</script>
The TValue
generic type by default is the same as the TOption
, this allows some flexibility where if the user didnāt specify an optionValue
prop, then the value is the item itself.
To fix the reset of the component we need to make some changes to how we render the options and how we get each iteration keys, this is one way to do that:
vue<script
setup
lang="ts"
generic="
TOption extends string | Record<string, unknown>,
TValue = TOption
"
>
//...
const model = defineModel<TValue>();
// This helps us get the value of the option
// We can't rely on a falsy check to determine if the option is selected
// as the value could be 0
function optToValue(opt: TOption) {
if (props.optionValue) {
return props.optionValue(opt);
}
return opt;
}
// This helps us get a string key of the option
// You can use a different approach here
function toKey(opt: TOption): string {
return JSON.stringify(optToValue(opt));
}
</script>
<template>
<!-- ...-->
<option
v-for="opt in options"
:key="toKey(opt)"
:value="optToValue(opt)"
>
{{ optionLabel(opt) }}
</option>
<!-- ...-->
</template>
With that out of the way, now if you pass an optionValue
prop to the component, you will get type checks for the model value argument and you wonāt be able to bind it to an incorrect ref
type.
In the following snippet, I created a selectedValue
ref that is of type string
and tried to bind it to the component while specifying the id
as the value, and it errors out as expected.
vue<script setup lang="ts">
const users = [
{ firstName: 'John', id: 1 },
{ firstName: 'Ahmed', id: 2 },
];
const selectedValue = ref('');
</script>
<template>
<InputSelect
:option-label="(opt) => opt.firstName"
:option-value="
(opt) => opt.id // Errors out
"
:options="users"
/>
</template>
Bonus: a bit of UI
Letās spice our UI up a bit, ideally, we would like to be able to style our options and the popup menu to give the component a bit of style. Since the select
element is very limited when it comes to styling, we could use Open UIās <selectlist>
.
Heads up
If you are on the latest Chrome production release, open the chrome://flags page and enable the āExperimental Web Platform featuresā flag to get the <selectlist>
element to render correctly.
You can re-build the input with div
s and floating libraries like tippy or floating-UI for better cross-browser support but I chose the path of least resistance here. The same principles still apply in the next sections.
Adding the entirety of the component here will be massive, so here is a working example of the component with the UI and all the changes we made so far:
Now we have a good-looking component that is also type-safe, I went through the trouble of doing all that UI work just so we can now move to the next step and allow the user complete customization of the option content, which means slots are in order.
Typed Slots for the option content
Having an overridable option slot allows for complex rendering of the options, the user can choose to display an image, icon, or whatever they want based on the option value. Simply displaying string labels is not enough in many cases. This means we need to add an overridable option slot.
We can do that by wrapping our option content with <slot>
tag, and we will give it a name=option
to make it clear that it overrides the option content. here is how it would look:
vue<template>
<!-- ... -->
<option
v-for="opt in options"
:key="toKey(opt)"
:value="optToValue(opt)"
>
<slot name="option" :option="opt">
{{ optionLabel(opt) }}
<!-- ... -->
</slot>
</option>
<!-- ... -->
</template>
This allows us to keep the current render behavior of the options as a default while at the same time allowing the consumers of the component to override the content as they see fit.
Here is an example with some countriesā flags!
And the best part is we get the selected option preview for free because of the selectlistās selectedoption
element, so no extra work is needed there.
Notice that the option
slot prop is strictly typed, and you get auto-completion for the option object properties.
Conclusion
Weāve created a select input that takes any shape of options and allows the user to customize the option content. Weāve also made the component airtight in terms of type safety, and weāve done all that with the help of generic types.
Generic types usefulness is not only limited to props, but you can also use them with slots and events and quite commonly with v-model
events.
And you saw a glimpse of the promise of Open UI and how it can help us with powerful and less JavaScripty components.
I hope this article was helpful and you learned something new. I will be covering more input components next week, so stay tuned for that.