Emitter
Every module broadcasts its open changes and its transition hooks on one typed, app-wide emitter. Read this for the events, their payloads and when the transition hooks fire.
A module broadcasts what happens to it on one emitter that every part of your
app can listen to, so a component that renders none of a dialog’s parts can
still react when that dialog has finished opening. The emitter is typed: the
event name decides the payload type, and a listener on dialog:afterEnter
receives a dialog payload and nothing else.
<script setup lang="ts">
import { useMirrorEmitter } from '@maas/mirror/vue'
const emitter = useMirrorEmitter()
emitter.on('dialog:afterEnter', ({ id, part }) => {
if (id === DialogId.EditProfile && part === 'content') {
startTour()
}
})
</script>
Reach for it when the listener and the module have no shared ancestor, or when
several modules should feed one place, such as an analytics call that logs
every popup that closes. Inside a module’s own subtree, the slot props and the
update:* emits on the root are closer to hand.
Listen
useMirrorEmitter returns on, off and emit. Call it inside
<script setup> or another setup context: the emitter belongs to the Vue app
instance, so every component in the app meets on the same channel and two
server-rendered requests never share one. Called outside a component, it
returns an emitter nothing else is connected to.
on(event, handler) registers a handler for one event. Every payload carries
the instance id, so a listener that cares about one instance compares it.
emitter.on('select:openChange', ({ id, open }) => {
if (id === SelectId.Country) {
countryOpen.value = open
}
})
A handler registered in setup outlives the component unless you remove it.
Pass the same function to off when the component unmounts.
<script setup lang="ts">
import { onUnmounted } from 'vue'
import { useMirrorEmitter } from '@maas/mirror/vue'
import type { MirrorEmitterEvents } from '@maas/mirror/vue'
const emitter = useMirrorEmitter()
function handleOpenChange({ id, open }: MirrorEmitterEvents['popover:openChange']) {
if (id === PopoverId.Share) {
shareOpen.value = open
}
}
emitter.on('popover:openChange', handleOpenChange)
onUnmounted(() => emitter.off('popover:openChange', handleOpenChange))
</script>
emit is there for your own code to fire a module’s events into the same
channel, for example from a test. Mirror’s parts emit for themselves, so you
do not call it to make a component work.
Open changes
Eight modules broadcast <module>:openChange whenever they open or close,
whether a press, a key, a v-model write or a composable call caused it. The
payload carries the instance id and the new open state. Five of them add
the reason the state changed for, typed per module.
Each component page whose payload carries a reason lists its values under
its own Emitter heading.
Transition lifecycle
A part that appears and disappears under a Vue transition broadcasts the six
transition hooks as events: beforeEnter, enter and afterEnter on the way
in, beforeLeave, leave and afterLeave on the way out, each namespaced by
module, so a dialog’s popup fires dialog:beforeEnter through
dialog:afterLeave. afterEnter fires once the enter transition has finished,
and afterLeave once the element is out of the DOM, which makes them the
moments to measure the popup, move focus somewhere unusual or start something
that should wait for the motion.
Every payload carries the instance id and the part that is transitioning.
A module with two transitioning parts reports content or backdrop. A part
that is rendered several times under one root adds the value that tells them
apart: Tabs.Content adds its value, ScrollArea.Scrollbar its
orientation, and Radio.Indicator the value of the radio it sits in.
emitter.on('tabs:afterLeave', ({ id, part, value }) => {
if (id === TabsId.Settings) {
savedScroll[value] = viewport.value?.scrollTop ?? 0
}
})
The hooks belong to the Vue transition, so they fire only while a transition
is named. Where a component ships a default name, the hooks fire out of the
box. Where the name is unset by default, the part appears and disappears at
once and broadcasts none of these events until you name one, in your CSS and
in the option or prop below. Two more cases fire nothing: a part that is
already present when it first renders, since Vue does not run an enter
transition on the first render, and a part kept in the DOM with forceMount,
since it never enters or leaves.
* stands for the six hooks, so tabs:* is tabs:beforeEnter, tabs:enter,
tabs:afterEnter, tabs:beforeLeave, tabs:leave and tabs:afterLeave.
An enter that is interrupted by a close does not reach afterEnter; the leave
hooks follow instead, so a listener waiting for the popup to have settled
should watch afterEnter and afterLeave together. Checkbox.Indicator,
Radio.Indicator, Select, Combobox, Autocomplete and Tooltip ship a
default name, so their hooks fire from the start; the others wait for a name.
Animation covers how a name maps to your CSS.
TypeScript
The whole event map is MirrorEmitterEvents, and each module exports its own
slice as <Component>Events next to a <Component>LifecyclePayload for the
transition payload. TransitionLifecycleHook is the union of the six hook
names.
import type { MirrorEmitterEvents, TransitionLifecycleHook } from '@maas/mirror/vue'
import type { DialogEvents, DialogLifecyclePayload } from '@maas/mirror/vue/Dialog'
type DialogEnter = DialogEvents['dialog:afterEnter']
const hooks: Array<TransitionLifecycleHook> = ['beforeEnter', 'afterLeave']
A handler typed against MirrorEmitterEvents['tabs:afterLeave'] accepts the
value the payload carries, and a handler typed against a dialog event does
not, since a dialog has one popup per instance and no value to add.
Further reading
- Animation: the transition names and what stays in the DOM.
- TypeScript: where the exported types live.