Post contents
Let's assume that we have a component system in our company's codebase. In this component system, there's a OurButton
component:
<OurButton (click)="openDialog()">Confirm</OurButton>
This component works great, but after some time of the company using it you get a new ticket:
Is there any way to have the
OurButton
component render an<a>
tag instead of a<button>
?
While you could break out the styling and behavior of <OurButton>
to a new component - say <OurLinkButton>
- you remember that React has a way to change the rendered HTML element to another one using an as
property.
I wonder if there's a way to do this in Vue as well?
Luckily for us, there is!
Dynamic Component Casting
JSX allows us to cast an element to a dynamic value doing the following:
const Tag = props.as || "button";<Tag/>
Vue supports JSX as well, but it's not the standard way of writing Vue components. Is there a way to do this in an SFC component?
SFCs also support this functionality, but using a different API:
<script setup>const props = defineProps(['as']); const Component = props.as || "button";</script><template> <component :is="Component"></component></template>
This will render the tag passed to under the as
property, or default to the button
element otherwise.
Passing Props
In JSX, we can pass properties through to a child by using an API like such:
const props = {href: "https://oceanbit.dev"};<a {...props}/>
In SFCs, however, the same effect is achieved using a different syntax:
<script setup>const props = {href: "https://oceanbit.dev"};</script><template> <a v-bind="props"></a></template>
TypeScript Support
This is great, but how do I get TypeScript support for the properties of the tag I want passed in?
For example, if I pass
as="button"
, I don't want to allowhref
to be passed; but support it whenas="a"
is passed.
Great question!
Let's start by understanding what built-in types TypeScript's dom
API has built-in. While looking into the supported types, we can find ourselves a list of all HTMLElement
's tag names and their associated properties and methods:
interface HTMLElementTagNameMap { "a": HTMLAnchorElement; "applet": HTMLAppletElement; "area": HTMLAreaElement; "audio": HTMLAudioElement; "base": HTMLBaseElement; // ...}
We can then use this, in combination with Vue's generic
's support to build out this API:
<!-- OurButton.vue --><script setup lang="ts" generic="T extends keyof HTMLElementTagNameMap = 'button'">const props = defineProps< Partial<HTMLElementTagNameMap[T]> & { as?: T; }>();const Component = props.as || "button";</script><template> <component :is="Component" v-bind="props"> <slot /> </component></template>
Vue's language service today has a bug that prevents this from working as-expected today. You can work around this for now by removing the
Partial<HTMLElementTagNameMap[T]>
from the props type.
Once this is done, we can use the OurButton
component like so:
<!-- Usage --><OurButton as="a" href="https://oceanbit.dev"> This looks like a button, but is a link</OurButton>