Overlays
Four families that all open something over the page, and all take their trigger the same way. Dropdown, Drawer and Modal make their first child the trigger — there is no trigger slot — and Toast is the one that has no trigger at all, because a queue is what opens it.
import { Drawer, Dropdown, Modal, ToastProvider, ToastQueue } from "ropav";overlays.vue
<script setup lang="ts">
import type { CollectionKey } from "ropav";
import {
Button,
Drawer,
DrawerBackdrop,
DrawerBody,
DrawerContent,
DrawerDialog,
DrawerFooter,
DrawerHandle,
DrawerHeader,
DrawerHeading,
Dropdown,
DropdownMenu,
DropdownPopover,
Label,
MenuItem,
Modal,
ModalBackdrop,
ModalBody,
ModalClose,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
Switch,
SwitchContent,
SwitchControl,
SwitchGroup,
SwitchThumb,
ToastProvider,
ToastQueue,
} from "ropav";
import { shallowRef } from "vue";
const queue = new ToastQueue({ maxVisibleToasts: 3 });
const isDrawerOpen = shallowRef(false);
const say = (title: string, variant: "danger" | "default" | "success" = "default") => {
queue.add({ title, variant });
};
const settings = [
{ label: "Email me on every reply", name: "replies" },
{ label: "Weekly digest", name: "digest" },
];
// A menu reports which item was chosen; the items themselves do not each need a handler.
const onAction = (key: CollectionKey) => {
say(`Chose ${String(key)}`);
};
const apply = () => {
isDrawerOpen.value = false;
say("Preferences saved", "success");
};
</script>
<template>
<div class="row">
<Dropdown>
<Button variant="secondary">Actions</Button>
<DropdownPopover>
<DropdownMenu aria-label="Document actions" @action="onAction">
<MenuItem id="rename" text-value="Rename">
<Label>Rename</Label>
</MenuItem>
<MenuItem id="duplicate" text-value="Duplicate">
<Label>Duplicate</Label>
</MenuItem>
</DropdownMenu>
</DropdownPopover>
</Dropdown>
<Drawer v-model:is-open="isDrawerOpen">
<Button variant="secondary">Preferences</Button>
<DrawerBackdrop>
<DrawerContent placement="right">
<DrawerDialog>
<DrawerHandle />
<DrawerHeader>
<DrawerHeading>Preferences</DrawerHeading>
</DrawerHeader>
<DrawerBody>
<SwitchGroup>
<Switch
v-for="setting in settings"
:key="setting.name"
:name="setting.name"
value="on"
>
<SwitchContent>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
{{ setting.label }}
</SwitchContent>
</Switch>
</SwitchGroup>
</DrawerBody>
<DrawerFooter>
<Button variant="tertiary" @click="isDrawerOpen = false">Cancel</Button>
<Button @click="apply">Apply</Button>
</DrawerFooter>
</DrawerDialog>
</DrawerContent>
</DrawerBackdrop>
</Drawer>
<Modal>
<Button variant="danger-soft">Delete</Button>
<ModalBackdrop>
<ModalContainer>
<ModalDialog class="dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalHeading>Delete this document?</ModalHeading>
</ModalHeader>
<ModalBody>Everything in it goes with it. This cannot be undone.</ModalBody>
<ModalFooter>
<ModalClose>
<Button variant="tertiary">Cancel</Button>
</ModalClose>
<ModalClose>
<Button variant="danger" @click="say('Document deleted', 'danger')">Delete</Button>
</ModalClose>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
<ToastProvider placement="bottom" :queue />
</div>
</template>
<style scoped>
.row {
display: flex;
flex-wrap: wrap;
gap: calc(var(--rp-spacing) * 3);
align-items: center;
}
.dialog {
@media (width >= 40rem) {
max-width: 360px;
}
}
</style>The trigger is the first child
<Dropdown><Button>Actions</Button><DropdownPopover>…</DropdownPopover></Dropdown> is the whole arrangement. The behaviour is handed down to whatever pressable sits inside rather than built into a trigger component, so the button stays an ordinary button. DropdownTrigger, DrawerTrigger and ModalTrigger exist for markup that does not press on its own.
Closing it yourself
ModalClose wraps a control and closes the dialog when it is pressed, which covers the common case. When the closing is a consequence of something else — a save that succeeded — hold the state instead: the drawer above takes v-model:is-open, so Apply can close it and queue a toast in the same handler.
A menu reports the choice once
DropdownMenu emits action with the id of the item chosen, so the items do not each carry a handler. That is also why every MenuItem needs an id.
The queue outlives the toast
A ToastQueue is made once and lives as long as the component; ToastProvider renders whatever is in it. Nothing needs to be open for queue.add to work, which is what makes it the right thing to report into from a dialog that is closing.
Inside one part of the page
Every overlay renders at the end of body and treats the whole document as the page behind it. When the app is one region of a larger page — a panel on a page it shares with other content, a widget embedded in someone else's site — wrap it in PortalProvider, which renders no element of its own:
<script setup lang="ts">
import { PortalProvider } from "ropav/portal-provider";
import { useTemplateRef } from "vue";
const frame = useTemplateRef<HTMLElement>("frame");
</script>
<template>
<section ref="frame" class="app-frame">
<PortalProvider :container="frame" :root="frame">
<slot />
</PortalProvider>
</section>
</template>
<style>
/* A containing block for `position: fixed`, so a backdrop covers the frame and not the window. */
.app-frame {
contain: layout;
}
</style>containeris where every overlay below renders: modals, alert dialogs, drawers, popovers, menus, the listboxes of pickers, tooltips and toast regions. A component's ownportal-containerstill wins. An element other than the body has to be a containing block forposition: fixed— atransform, orcontain: layoutorpaint— sized to the area the overlays cover, because backdrops fill it and modals size to it rather than to the visual viewport.rootis the part of the page they belong to, usually an element holding both the app and the container. A modal makes only the rootinert, holds focus and outside presses only within it, and leaves content added later outside it alone; a popover or tooltip flips and shifts to stay inside it. The rest of the page stays live, and a modal in one root is not stacked over a modal in another — Esc closes each in its own.
Leaving a prop out of a nested provider inherits the enclosing one's; null goes back to the body and the whole document. A template ref is null on the first render, so an overlay already open then starts in the body and moves once the ref is set. The scroll lock a modal takes is still page-wide.
Accessibility
- An overlay traps focus while open and hands it back to the trigger on close — all three do this, and none of it needs wiring.
- Esc closes each of them, and a press outside does too.
DropdownMenutakes anaria-label, because the button that opened it is not its name.