@payfit/unity-components
Version:
349 lines (286 loc) • 10.3 kB
Markdown
---
name: unity-overlays
description: >
Load when adding Unity dialogs, side panels, bottom sheets, tooltips,
popovers, or menus. Use it to choose modal versus non-modal overlays and keep
disabled-control tooltip behavior accessible.
type: core
library: '@payfit/unity-components'
library_version: '2.x'
sources:
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/dialog/Dialog.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/side-panel/SidePanel.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/tooltip/Tooltip.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/popover/Popover.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/menu/Menu.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/promo-dialog/PromoDialog.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/bottom-sheet/BottomSheet.tsx'
---
## Setup
Overlays do not require a global provider. Each overlay owns its open state
through its own trigger (`DialogTrigger`, `PopoverTrigger`, `MenuTrigger`,
`TooltipTrigger`) or via the controlled `isOpen` / `onOpenChange` pair. Mount
the trigger and the overlay side-by-side as siblings — `DialogTrigger` reads
exactly two children: the trigger element and the overlay.
```tsx
import {
Button,
Dialog,
DialogActions,
DialogButton,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@payfit/unity-components'
export function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
return (
<DialogTrigger>
<Button variant="danger">Delete</Button>
<Dialog>
<DialogTitle>Delete employee?</DialogTitle>
<DialogContent>This cannot be undone.</DialogContent>
<DialogActions>
<DialogButton variant="close">Cancel</DialogButton>
<DialogButton variant="danger" onPress={onConfirm}>
Delete
</DialogButton>
</DialogActions>
</Dialog>
</DialogTrigger>
)
}
```
## Modal vs non-modal — picking the right one
| Need | Component | Reason |
| ------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------ |
| Confirmation, blocking form, decision required | `Dialog` | modal: focus trap, scroll lock, backdrop, Escape closes |
| Detail view that should not interrupt page flow | `SidePanel` | modal but side-anchored; same focus trap and Escape behavior |
| Mobile-first sheet, edit on small screens | `BottomSheet` | modal anchored to viewport bottom |
| Feature announcement with hero illustration | `PromoDialog` | modal with required `PromoDialogHero` slot |
| Hover/focus hint on an enabled control | `Tooltip` | non-interactive, hover/focus activated, single string |
| Interactive informational content (links, fields) | `Popover` | non-modal, focusable content, requires `title` |
| Dropdown list of actions | `Menu` (+ `MenuTrigger`/`MenuContent`/`RawMenuItem`) | non-modal, keyboard-navigable list |
| Inline term definition | `DefinitionTooltip` | non-modal, inline anchor |
Modal overlays all build on `react-aria-components`' `ModalOverlay` — they
manage focus, restoration, scroll lock, and Escape automatically.
## Core Patterns
### Dialog with DialogTrigger + DialogActions
```tsx
import {
Button,
Dialog,
DialogActions,
DialogButton,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@payfit/unity-components'
export function ArchiveDialog({ onArchive }: { onArchive: () => void }) {
return (
<DialogTrigger>
<Button>Archive</Button>
<Dialog size="md">
<DialogTitle>Archive this employee?</DialogTitle>
<DialogContent>
They will no longer appear in active lists. You can restore them
later.
</DialogContent>
<DialogActions>
<DialogButton variant="close">Cancel</DialogButton>
<DialogButton variant="confirm" onPress={onArchive}>
Archive
</DialogButton>
</DialogActions>
</Dialog>
</DialogTrigger>
)
}
```
### SidePanel for detail views
```tsx
import { useState } from 'react'
import {
Button,
SidePanel,
SidePanelContent,
SidePanelFooter,
SidePanelHeader,
} from '@payfit/unity-components'
export function EmployeeDetailPanel({ employee }: { employee: Employee }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onPress={() => setIsOpen(true)}>View details</Button>
<SidePanel isOpen={isOpen} onOpenChange={setIsOpen}>
<SidePanelHeader>{employee.name}</SidePanelHeader>
<SidePanelContent>
<p>{employee.position}</p>
<p>{employee.team}</p>
</SidePanelContent>
<SidePanelFooter>
<Button variant="secondary" onPress={() => setIsOpen(false)}>
Close
</Button>
</SidePanelFooter>
</SidePanel>
</>
)
}
```
### Popover for interactive informational content
`title` is required (use `isTitleSrOnly` if you don't want it visible).
Children may be focusable; the Popover is non-modal so the page stays
interactive behind it.
```tsx
import { Button, Popover, PopoverTrigger } from '@payfit/unity-components'
export function HelpPopover() {
return (
<PopoverTrigger>
<Button variant="tertiary">What is a working agreement?</Button>
<Popover title="Working agreement">
<p>
A working agreement defines when, where and how an employee works. See
the <a href="/handbook/working-agreement">handbook</a> for details.
</p>
</Popover>
</PopoverTrigger>
)
}
```
### Menu for action lists
`Menu` expects exactly two children: a trigger and a content block. Use
`MenuTrigger` / `MenuContent` / `RawMenuItem` for the standard composition.
```tsx
import {
IconButton,
Menu,
MenuContent,
MenuTrigger,
RawMenuItem,
} from '@payfit/unity-components'
export function RowActions({
onEdit,
onArchive,
}: {
onEdit: () => void
onArchive: () => void
}) {
return (
<Menu>
<MenuTrigger>
<IconButton icon="MoreFilled" title="Actions" />
</MenuTrigger>
<MenuContent>
<RawMenuItem onAction={onEdit}>Edit</RawMenuItem>
<RawMenuItem onAction={onArchive}>Archive</RawMenuItem>
</MenuContent>
</Menu>
)
}
```
## Common Mistakes
### CRITICAL Wrap a disabled control in Tooltip
Wrong:
```tsx
<Tooltip title="Disabled because …">
<Button isDisabled>Submit</Button>
</Tooltip>
```
Correct:
```tsx
<Button isDisabled>Submit</Button>
<Text variant="bodySmall" color="content.neutral.low">Disabled because …</Text>
```
Unity enforces the WCAG rule that disabled controls must not carry tooltips (the disabled element is not keyboard-focusable, so the tooltip is unreachable). This is enforced at the library level, not just policy.
Source: components/tooltip/Tooltip.tsx; maintainer interview (enforced by Unity)
### MEDIUM Use Dialog for a non-blocking hint
Wrong:
```tsx
<DialogTrigger>
<Button>Info</Button>
<Dialog>
<DialogContent>FYI…</DialogContent>
</Dialog>
</DialogTrigger>
```
Correct:
```tsx
<PopoverTrigger>
<Button>Info</Button>
<Popover title="Info">FYI…</Popover>
</PopoverTrigger>
// or, for hover-only:
<Tooltip title="FYI…"><Button>Info</Button></Tooltip>
```
Dialog is modal (focus trap, scroll lock, backdrop) — heavy for a simple informational popover.
Source: components/dialog/Dialog.tsx:162-196 (ModalOverlay)
### MEDIUM Use Tooltip for interactive content
Wrong:
```tsx
<Tooltip
title={
<>
<Button>Delete</Button>
<Button>Edit</Button>
</>
}
>
<IconButton icon={MoreFilled} />
</Tooltip>
```
Correct:
```tsx
<Menu>
<MenuTrigger>
<IconButton icon={MoreFilled} />
</MenuTrigger>
<MenuContent>
<RawMenuItem>Delete</RawMenuItem>
<RawMenuItem>Edit</RawMenuItem>
</MenuContent>
</Menu>
```
Tooltip is display-only; placing interactive elements inside breaks focus and event handling.
Source: components/tooltip/Tooltip.tsx (no interactive support); components/menu/Menu.tsx
### MEDIUM Fight the modal focus trap with custom useEffect focus moves
Wrong:
```tsx
useEffect(() => {
if (open) triggerRef.current?.focus()
}, [open])
```
Correct:
```tsx
// Let Dialog manage focus; use autoFocus on a specific element inside if needed:
<Dialog isOpen={open} onOpenChange={setOpen}>
<input autoFocus />
</Dialog>
```
Dialog manages focus and restoration via React Aria. Calling triggerRef.focus() inside a useEffect produces unpredictable jumps.
Source: components/dialog/Dialog.tsx:162-196
### MEDIUM PromoDialog without PromoDialogHero
Wrong:
```tsx
<PromoDialog>
<PromoDialogTitle>Feature</PromoDialogTitle>
<PromoDialogContent>…</PromoDialogContent>
</PromoDialog>
```
Correct:
```tsx
<PromoDialog>
<PromoDialogHero>
<Illustration src={MyIllustration} />
</PromoDialogHero>
<PromoDialogTitle>Feature</PromoDialogTitle>
<PromoDialogContent>…</PromoDialogContent>
</PromoDialog>
```
PromoDialog validates at render and console.errors if the hero is missing; dialog still renders but the layout breaks.
Source: components/promo-dialog/PromoDialog.tsx:230-236 (console.error guard)
## See also
- `unity-migrate-from-midnight` — the disabled+tooltip trap is the most
common Midnight-era pattern that Unity blocks; the migration skill covers
the rewrite.
- `unity-layout-and-styling` — overlay content uses the same `uy:` utility
classes (Flex/Grid, spacing, `uy:data-[hovered=true]:…`).