UNPKG

alouette

Version:

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

342 lines (273 loc) 12.1 kB
--- 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