Skip to content

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.

View source View as Markdown

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.

EventPayload
alert-dialog:openChange{ id: string, open: boolean, reason: AlertDialogChangeReason }
autocomplete:openChange{ id: string, open: boolean }
combobox:openChange{ id: string, open: boolean }
dialog:openChange{ id: string, open: boolean, reason: DialogChangeReason }
popover:openChange{ id: string, open: boolean, reason: PopoverChangeReason }
preview-card:openChange{ id: string, open: boolean, reason: PreviewCardChangeReason }
select:openChange{ id: string, open: boolean }
tooltip:openChange{ id: string, open: boolean, reason: TooltipChangeReason }

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.

EventsPartWhere the transition is namedDefault
alert-dialog:*content, backdropoptions.transition.content and .backdrop on AlertDialog.Rootnone
autocomplete:*content, backdropoptions.transition.content and .backdrop on Autocomplete.Rootmirror-autocomplete-content, mirror-autocomplete-backdrop
avatar:*imageoptions.transition on Avatar.Rootnone
checkbox:*indicatoroptions.transition on Checkbox.Rootmirror-checkbox-indicator
collapsible:*contentoptions.transition on Collapsible.Rootnone
combobox:*content, backdropoptions.transition.content and .backdrop on Combobox.Rootmirror-combobox-content, mirror-combobox-backdrop
dialog:*content, backdropoptions.transition.content and .backdrop on Dialog.Rootnone
popover:*content, backdropoptions.transition.content and .backdrop on Popover.Rootnone
preview-card:*content, backdropoptions.transition.content and .backdrop on PreviewCard.Rootnone
radio:*indicator, plus valueThe transition prop on Radio.Indicatormirror-radio-indicator
scroll-area:*scrollbar, plus orientationThe transition prop on ScrollArea.Scrollbarnone
select:*content, backdropoptions.transition.content and .backdrop on Select.Rootmirror-select-content, mirror-select-backdrop
tabs:*content, plus valueThe transition prop on Tabs.Contentnone
tooltip:*content, backdropoptions.transition.content and .backdrop on Tooltip.Rootmirror-tooltip-content, mirror-tooltip-backdrop

* 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.