Composition
Change the rendered element with as and asChild, wrap a part once, and extend the props interfaces it exports.
Every part takes as and asChild, and every props interface is exported, so
you can put a Mirror part inside a component of your own without restating what
it already accepts.
Import a part
Every part is exported twice, as a flat component and as a member of a namespace object. Templates use the dotted form, because it reads as the anatomy and it survives a rename.
<script setup lang="ts">
import { Select } from '@maas/mirror/vue'
</script>
<template>
<Select.Root>
<Select.Trigger />
</Select.Root>
</template>
Single-part components are the component itself: <Button>, never
<Button.Root>. A namespace therefore means the component has more than one
part, so an import tells you how many pieces to expect before you read the
template. Field.Label is the one member that is not its own component, since
it is Label re-exported so that a field’s anatomy reads in one piece.
If you are using Nuxt, the module registers every export globally with an M
prefix, so the same parts are <m-select-root> and <m-select-trigger> with
nothing to import.
Swap the element with as
as changes the element or component a part renders, keeping the behaviour and
the state attributes.
<template>
<Button as="a" href="/docs">Read the docs</Button>
</template>
Hand the behaviour to a child with asChild
asChild renders no element of its own. It merges everything it would have
rendered, attributes, listeners and data-* state, onto the single child you
provide.
<template>
<Button as-child>
<nuxt-link to="/settings">Settings</nuxt-link>
</Button>
</template>
Give it exactly one element child, because everything merges onto the first element it finds and any further siblings render without attributes, listeners or state.
Configure a part through options
A component that owns state takes a bundled options object on its root rather
than a long prop list, so positioning, dismissal, focus and transition settings
sit in one place instead of being repeated across the parts that use them.
<template>
<Popover.Root
:options="{
floating: { side: 'bottom', sideOffset: 8 },
dismiss: { escape: false },
}"
/>
</template>
A direct prop on a nested part is the escape hatch and wins over the matching key in the object. Reach for one where a single instance needs a different value, and leave the rest on the root. Each component page lists which of its props override which option.
Controlled and uncontrolled
Every part that owns a value takes the same pair. modelValue makes it
controlled, defaultValue seeds it and leaves it uncontrolled, and v-model
is the controlled form written short.
<template>
<Switch.Root :default-value="true" />
<Switch.Root v-model="notifications" />
</template>
Open state follows the same rule where a component has one: open with
update:open, or defaultOpen.
Which of the two applies is decided once, during setup, by whether modelValue
is undefined at that moment. A ref that starts out undefined and is filled
in later leaves the part uncontrolled, and later writes to that ref do not move
it.
// wrong: undefined at mount, so the part never becomes controlled
const value = ref<string | undefined>()
// right: give it an initial value, or key the component so it remounts
const value = ref('')
Wrap a part once
Once a class list repeats at every call site, it belongs in a wrapper. Put it
in one component per control, such as app/components/AppButton.vue, and give
that component the props your project wants to talk in, such as the intent
below.
<script setup lang="ts">
import { Button } from '@maas/mirror/vue'
interface AppButtonProps {
intent?: 'primary' | 'danger'
}
const { intent = 'primary' } = defineProps<AppButtonProps>()
</script>
<template>
<Button class="app-button" :class="`-${intent}`">
<slot />
</Button>
</template>
The wrapper is where a prop matrix belongs. Mirror ships no variant, mode or
size prop and will not add one, so declare yours here, where the names can be
the ones your project already uses. Changing one is then an edit to your own
code rather than a request to ours, and it reaches every call site at once.
Extend the exported props
Every part exports a props interface named after it, so a wrapper extends rather than duplicates.
import type { ButtonProps, SelectTriggerProps } from '@maas/mirror/vue'
interface AppButtonProps extends ButtonProps {
intent?: 'primary' | 'danger'
}
interface AppSelectTriggerProps extends SelectTriggerProps {
tone?: 'neutral' | 'danger'
}
Forward the rest with v-bind, so a prop the part gains in a later release
reaches it through the wrapper with no edit to the wrapper.
<script setup lang="ts">
import { Button, type ButtonProps } from '@maas/mirror/vue'
const { intent = 'primary', ...rest } = defineProps<AppButtonProps>()
</script>
<template>
<Button v-bind="rest" class="app-button" :class="`-${intent}`">
<slot />
</Button>
</template>
Render your own element polymorphically
Primitive and PrimitiveSlot are re-exported from @maas/mirror/vue, so a
wrapper of your own can take as and asChild the same way a Mirror part
does, without a second package.
<script setup lang="ts">
import { Primitive, type PrimitiveProps } from '@maas/mirror/vue'
const { as = 'div', asChild } = defineProps<PrimitiveProps>()
</script>
<template>
<primitive :as="as" :as-child="asChild" class="app-panel">
<slot />
</primitive>
</template>
Reach a module from outside its subtree
A module keeps its state in an app-scoped store keyed by id, so anything in
the app can drive it. Pass an id and read it back with the matching
composable.
<script setup lang="ts">
import { useMirrorSelect } from '@maas/mirror/vue'
const { isOpen, open, close, toggle, value, setValue } =
useMirrorSelect('framework')
</script>
Give a module an id only when something outside its subtree has to address
it. See TypeScript for the full list of composables and
the types they return.
Text direction
Every component that reacts to the horizontal arrow keys takes a dir option,
but you rarely want to repeat it on each of them. Wrap the app, or the part of
it that runs right to left, in a DirectionProvider instead and they all
follow.
<script setup lang="ts">
import { DirectionProvider, Tabs } from '@maas/mirror/vue'
</script>
<template>
<DirectionProvider direction="rtl">
<Tabs.Root>…</Tabs.Root>
</DirectionProvider>
</template>
The provider renders nothing and sets no attribute, so dir="rtl" on the
<html> element or on whichever wrapper you own is still yours to set. If you
already set it, you can skip the provider altogether: a component without a
dir option reads the closest dir attribute above it once it is mounted. In
full, a component takes its own dir option first, then the nearest
DirectionProvider, then the closest dir attribute, and defaults to ltr.
The attribute has three blind spots the provider covers, so reach for the
provider when any of them applies to you. An attribute lives in the DOM and can
only be read after mount, so a server render and the first client paint fall
back to left to right; the provider answers while the markup is being
rendered. A portalled popup moves to the end of <body>, out of whichever
wrapper carries your attribute, while the provider follows the component tree
and reaches it anyway. And the attribute is read once on mount, while the
provider is reactive, so changing its direction at runtime turns every
component below it around.
The same resolution is available on its own through useDirection, which
returns a computed 'ltr' | 'rtl' for a component of yours.
Further reading
- Styling: the state a part writes, and the four ways to read it.
- TypeScript: the props interfaces, the options types and the composables, listed by name.