alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
342 lines (273 loc) • 12.1 kB
Markdown
---
name: alouette-dialogs
description: >
Content above the screen. Modal is the general dialog: the caller controls
whether it is open, and it is dismissed by the backdrop, its close button,
Escape or the Android back gesture, with its footer pinned below a scrolling
body. AlertDialog is the confirmation built on it — ask, acknowledge, or
demand a decision that cannot be dismissed — with icon-fixed presets
(QuestionAlertDialog, WarningAlertDialog, InfoAlertDialog,
SuccessAlertDialog); confirming may be async, and the dialog stays open,
locked, until it settles, rendering a failure in place. Popover is the
chrome-less escape hatch for content that must escape a clipping ancestor —
but a set of actions off a trigger is Menu + MenuItem, never a hand-built
Popover. Load when adding a modal, confirmation, alert dialog, or a dropdown
escaping a clipping container.
type: core
library: alouette
library_version: "22.11.0"
requires:
- alouette-theming
- alouette-actions
sources:
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/Modal.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/AlertDialog.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/Popover.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/Popover.web.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/PortalAccentScope.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/Modal.stories.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/AlertDialog.stories.tsx"
---
This skill builds on alouette-theming (accents) and alouette-actions (the footer
buttons). Read them first.
# alouette — Dialogs
`Modal` is a themed, accessible overlay you control with `visible` / `onClose`.
`AlertDialog` builds on it for the confirm/alert/required decision pattern with a
prebuilt footer. Both render through react-native's `Modal`, animate a fade, and
dismiss on backdrop press, the close button, the Android back button, and Escape
(web).
## Setup
```tsx
import { Modal, Button } from "alouette";
function Example() {
const [open, setOpen] = useState(false);
return (
<>
<Button text="Open" onPress={() => setOpen(true)} />
<Modal
visible={open}
onClose={() => setOpen(false)}
title="Details"
footer={<Button text="Done" onPress={() => setOpen(false)} />}
>
<Text>Body content.</Text>
</Modal>
</>
);
}
```
## Core Patterns
### Modal header, body and footer
`title` is **required**: it renders as the heading and labels the dialog for
assistive tech (`aria-labelledby`). `size` is `"sm" | "md" | "lg"` (panel
max-width + padding; default `md`). Optional `icon` tints with the accent before
the title. Use `hideCloseButton` to drop the corner close button (the modal stays
dismissible via backdrop/Escape/back), `role="alertdialog"` for an interruption
that requires an explicit response, and `aria-describedby` to point at the
element describing the dialog.
The header sits **outside** the scroll box, so the title and the close button
stay put while the body scrolls under them (the body scrolls at ~70% viewport
height). `footer` is the actions row and stays pinned to the bottom of that
body — `position: sticky` inside the scroll content on web, laid out below the
scroll box on native, since Yoga has no sticky. It grows a top border only while
content is scrolled under it, and loses it at the end of the scroll. That is
built in: don't rebuild it with an absolutely-positioned bar or a scroll
listener.
### Accent across the portal
`accent` themes the whole panel. A modal renders through a portal, i.e. outside
the themed DOM subtree, so `Modal` rebuilds the scope inside with
`PortalAccentScope` (also exported, for any other portalled overlay). An
`AccentScope` placed _around_ the `<Modal>` element in your tree does **not**
reach the panel — pass `accent` to the modal itself.
### AlertDialog — confirmations
Prefer the icon-fixed presets; each takes every `AlertDialog` prop except `icon`.
The `variant` selects the footer:
- `"confirm"` (default) — `onConfirm` + `onCancel`, a Cancel and a Confirm button.
- `"alert"` — `onClose`, a single acknowledge button.
- `"required"` — `onConfirm`, a single action; **cannot** be dismissed by
backdrop/Escape/back (the user must respond).
`accent` defaults to `"danger"` (themes the icon and primary button).
```tsx
import { QuestionAlertDialog, WarningAlertDialog } from "alouette";
<QuestionAlertDialog
visible={confirming}
title="Delete project?"
confirmText="Delete"
onConfirm={handleDelete}
onCancel={() => setConfirming(false)}
>
This permanently removes the project and its data.
</QuestionAlertDialog>
<WarningAlertDialog
variant="alert"
visible={notice}
title="Session expired"
onClose={() => setNotice(false)}
>
Please sign in again.
</WarningAlertDialog>
```
`confirmDisabled` disables the primary button (e.g. while a form is invalid).
Button labels default to Confirm/Cancel (confirm), OK (alert/required).
### Async confirmations
`onConfirm` (confirm and required variants) may return a promise. The dialog then
runs the same state machine as `ActionButton`: the button shows a spinner, the
dialog locks (Cancel disabled, backdrop/Escape/Android-back stop dismissing) and
unlocks when the promise settles. Do **not** hand-roll this with
`confirmDisabled` and local state.
Pass `errorToMessage` to turn a rejection into a message shown full width in the
footer (a flat `ErrorMessage`, since the dialog panel is already raised), with
the dialog left open so the action can be retried; without it a failure only
flips the button to its failed state. There is no default formatter — a library
one could only hardcode an English string.
Close the dialog yourself once the promise resolves — the dialog never closes
itself on success.
```tsx
<WarningAlertDialog
visible={confirming}
title="Delete project"
confirmText="Delete"
errorToMessage={(error) =>
error instanceof Error ? error.message : t("unknownError")
}
onConfirm={async () => {
await deleteProject();
setConfirming(false);
}}
onCancel={() => setConfirming(false)}
>
This permanently removes the project and its data.
</WarningAlertDialog>
```
### Popover — content above a clipping ancestor
`Popover` is the low-level overlay behind `Select` and `InputTextAutocomplete`:
it renders `children` above everything, outside the clipping of any
`overflow-hidden` ancestor (`Surface` is one by design). It brings **no** panel
chrome — no title, no close button, no padding — so wrap the content in a
`Surface` yourself.
```tsx
import { Popover, Surface, IconButton } from "alouette";
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
<View ref={anchorRef}>
<IconButton aria-label="More" onPress={() => setOpen(true)} … />
</View>
<Popover
open={open}
anchorRef={anchorRef}
aria-label="Actions"
onClose={() => setOpen(false)}
>
<Surface variant="highlight" shadow="l" size="sm">{menu}</Surface>
</Popover>;
```
The two platforms present it differently, and that is deliberate:
- **web** — portals into `document.body` and positions itself under
`anchorRef`. `align` picks the anchor edge it lines up with (`"start"` by
default, `"end"` for a panel hanging off a small trigger near the right edge)
and `width` its sizing: `"anchor"` (default, as wide as the anchor — what a
field dropdown wants) or `"content"`, sizing to its own content for a menu
whose trigger is narrower than its items. Both are anchored-web only, like
`anchorRef` itself. It follows the anchor through page and nested
scrolling, sits above react-native-web's own `Modal` layer (so a popover inside
a dialog is not hidden behind it), and closes on Escape or a press outside the
content and the anchor.
- **native** — `anchorRef` is **ignored** and the content is presented as an
overlay in a transparent `Modal`, because a `Modal` resigns the keyboard of
whatever is behind it, so an anchored dropdown over a focused input is not
renderable. `placement` picks the presentation: `"center"` (default) for
content of fixed height, `"top"` to pin it below the status bar so the first
row stays put while the content resizes. Web falls back to the same overlay
when no `anchorRef` is given.
`accent` themes the content through `PortalAccentScope`; pass `accent="none"` to
render it on the neutral mode tokens under an accented ancestor. `aria-label`
names the overlay.
For a list of actions hanging off a trigger, use `Menu` + `MenuItem`
(alouette-actions/SKILL.md) rather than assembling this yourself: it is this
`Popover` plus the `menu`/`menuitem` roles, the trigger's `aria-haspopup` /
`aria-expanded`, arrow-key roving focus and close-on-select.
## Common Mistakes
### HIGH Labelling a Modal with aria-label instead of title
Wrong:
```tsx
<Modal visible={open} onClose={close} aria-label="Image preview"><Image … /></Modal>
```
Correct:
```tsx
<Modal visible={open} onClose={close} title="Image preview"><Image … /></Modal>
```
`title` is a required `ModalProps` prop and there is no `aria-label`: every modal
gets a visible heading, which is also its accessible name (`aria-labelledby`).
`aria-describedby` is the only aria prop `Modal` takes.
Source: packages/alouette/src/ui/containers/Modal.tsx
### MEDIUM Putting the action buttons in children instead of footer
Wrong:
```tsx
<Modal visible={open} onClose={close} title="Details">
<Text>…</Text>
<HStack className="justify-end gap-m">
<Button text="Done" onPress={close} />
</HStack>
</Modal>
```
Correct:
```tsx
<Modal
visible={open}
onClose={close}
title="Details"
footer={<Button text="Done" onPress={close} />}
>
<Text>…</Text>
</Modal>
```
A row placed in `children` scrolls away with the body. `footer` stays pinned at
the bottom of the scroll box on both platforms, right-aligns its buttons, and
takes on the separator border while content scrolls under it.
Source: packages/alouette/src/ui/containers/Modal.tsx
### MEDIUM Wrapping the Modal element in AccentScope
Wrong:
```tsx
<AccentScope accent="danger">
<Modal visible={open} onClose={close} title="Delete project">
…
</Modal>
</AccentScope>
```
Correct:
```tsx
<Modal accent="danger" visible={open} onClose={close} title="Delete project">
…
</Modal>
```
The panel renders through a portal, outside the themed subtree, so a surrounding
scope never reaches it. `Modal` rebuilds the theme inside with
`PortalAccentScope` from its own `accent` prop.
Source: packages/alouette/src/ui/containers/PortalAccentScope.tsx
### HIGH Building a confirmation out of a raw Modal
Wrong: a `Modal` with hand-placed Cancel/Confirm buttons and manual `role`.
Correct:
```tsx
<QuestionAlertDialog
visible={open}
title="Discard changes?"
onConfirm={discard}
onCancel={close}
/>
```
`AlertDialog` sets `role="alertdialog"`, wires the description to
`aria-describedby`, hides the close button, and builds the footer for the chosen
`variant`. Reach for `Modal` directly only for non-decision content.
Source: packages/alouette/src/ui/containers/AlertDialog.tsx
### MEDIUM Expecting a required dialog to close on backdrop/Escape
`variant="required"` intentionally ignores backdrop, Escape, and the Android back
button — only its explicit action closes it. Use it for must-respond
interruptions (accept terms, forced sign-out), not for ordinary dialogs, which
should stay dismissible (`confirm` / `alert`).
Source: packages/alouette/src/ui/containers/AlertDialog.tsx
### LOW Passing icon to a preset dialog
The presets (`QuestionAlertDialog`, `WarningAlertDialog`, `InfoAlertDialog`,
`SuccessAlertDialog`) fix the icon; their props are `AlertDialogUsageProps` =
every `AlertDialog` prop except `icon`. Use the base `AlertDialog` when you need
a custom icon.
Source: packages/alouette/src/ui/containers/AlertDialog.tsx