Skip to content

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.

ts
import { Drawer, Dropdown, Modal, ToastProvider, ToastQueue } from "ropav";
overlays.vue
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:

vue
<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>
  • container is where every overlay below renders: modals, alert dialogs, drawers, popovers, menus, the listboxes of pickers, tooltips and toast regions. A component's own portal-container still wins. An element other than the body has to be a containing block for position: fixed — a transform, or contain: layout or paint — sized to the area the overlays cover, because backdrops fill it and modals size to it rather than to the visual viewport.
  • root is the part of the page they belong to, usually an element holding both the app and the container. A modal makes only the root inert, 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.
  • DropdownMenu takes an aria-label, because the button that opened it is not its name.