UNPKG

alouette

Version:

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

552 lines (443 loc) • 21.4 kB
--- name: alouette-actions description: > Everything the user presses. Button and IconButton are the primary actions; ActionButton is the one to reach for when pressing starts an async operation — it runs the promise and shows the spinner, the success state and the failure message itself. ExternalLinkButton and InternalLinkButton are the link-shaped buttons, LinkText and ExternalLinkText the inline links inside a sentence, and Menu + MenuItem the dropdown that collects secondary actions behind one trigger. SplitButton is one button whose caret picks which of several alternative actions it runs (a merge method). PressableBox and PressableListItem are the bases for a custom pressable surface: they carry the hover, focus, press and disabled affordance so it never has to be rebuilt by hand. A disabled button says why with disabledReason, and Tooltip describes any other element. Load when adding buttons, text links, a menu of actions, a split button or button with options, a tooltip, or custom pressable elements. sources: - packages/alouette/src/ui/actions/Button.tsx - packages/alouette/src/ui/actions/ActionButton.tsx - packages/alouette/src/ui/actions/IconButton.tsx - packages/alouette/src/ui/actions/PressableBox.tsx - packages/alouette/src/ui/actions/PressableListItem.tsx - packages/alouette/src/ui/actions/LinkText.tsx - packages/alouette/src/ui/actions/Menu.tsx - packages/alouette/src/ui/actions/MenuItem.tsx - packages/alouette/src/ui/actions/SplitButton.tsx - packages/alouette/src/ui/actions/SplitButtonOption.tsx - packages/alouette/src/ui/containers/Tooltip.tsx - packages/alouette/src/ui/containers/Tooltip.web.tsx - packages/alouette/src/ui/containers/mergeTooltipTriggerProps.ts --- This skill builds on alouette-theming. Read it first for the accent model. # alouette — Actions Buttons and pressables carry interactive token states (hover/active/disabled, plus the keyboard focus ring) automatically. `variant` is `"tonal" | "filled" | "outlined" | "soft"`; `size` is `"sm" | "md" | "lg"` (38/44/52px — `lg` is the prominent call to action of a landing page or a hero, never a size for an action among others); `accent` is `"brand" | "danger" | "info" | "success" | "warning" | "neutral"`. Unset, a `Button` takes the accent of the nearest accent scope (a `Modal`, `Message`, `Box` or `AccentScope` with an `accent`), and `"brand"` outside one. **The secondary action is neutral `soft`.** `tonal` (the default) is the material an action button is made of; the accent says how loud it is. The secondary action beside a call to action is `accent="neutral" variant="soft"` — a neutral text button that gives way to the primary — never a neutral `tonal` twin of it, and never `outlined`. ## Setup ```tsx import { Button } from "alouette"; import { CheckRegularIcon } from "alouette-icons/phosphor-icons/Check"; <Button text="Save" icon={<CheckRegularIcon />} onPress={save} />; ``` ## Core Patterns ### Button accents ```tsx <Button text="Save" /> {/* tonal, brand */} <Button accent="neutral" text="Menu" /> {/* tonal, neutral */} <Button accent="neutral" variant="soft" text="Cancel" /> {/* the secondary action */} <Button accent="danger" text="Delete" /> <Button variant="filled" text="Publish" /> {/* the accent's fill */} <Button size="sm" text="Small" /> <Button size="lg" variant="filled" text="Get started" /> {/* the hero call to action */} ``` `tonal` is a ground **lighter than the page it sits on**, lifted by `shadow-s`: the lightest card step when neutral, the accent's pale tints in light mode and its own dark ground in dark mode. The hue is carried by the `text-on-tonal` ink (label and icon) — the accent itself on a light ground, the sharp ink on a dark one — because a tint pale enough for dark ink cannot carry it (a pale red reads as pink). `accent="neutral"` resolves to the plain mode theme (alouette-theming/SKILL.md), so the button keeps the tonal material — ground, shadow, hover/focus/press states — on the grayscale palette: a white card with sharp ink. That is a lone action that must not carry the accent: a `Menu` trigger, the Add of a field array, a `Modal` footer's single "Close". Beside a primary action it becomes `variant="soft"`: a neutral `tonal` twin reads as a second equal choice, and on a white `bg-highlight` ground — a `Modal` or `AlertDialog` panel, the `AppHeader` bar — its ground is the ground itself and dissolves. The form editor modals' Cancel and the header's "Sign up" beside "Log in" are neutral `soft`. `filled` is the accent's own fill, flat, under the `text-on-accent` label — for the one action that must dominate its neighbours. The neutral theme is an accent, not the absence of one: its fill is the sharp ink turned into a ground, so a neutral `filled` button is black with a white label in light mode and white with a dark label in dark mode. Beside a `filled` call to action, the other action is neutral `soft`. **On an accented surface** (`<Box accent className="surface">`, a `GradientBackground`), a lone `tonal` button is always `accent="neutral"`: in light mode an accented `tonal` ground is the accented surface's own step, so it dissolves into it. The accented action there is `filled`, or `soft` for chrome. What counts is the ground, not the theme: a `bg-highlight` panel stays neutral inside an accented theme, so an accented `Modal` or `AlertDialog` keeps a `tonal` confirm (with a neutral `soft` Cancel). On web, `PressableBox` warns outside production when an accented `tonal` pressable has the same ground as the surface behind it. ```tsx <Box accent="danger" className="surface"> <Button accent="neutral" variant="soft" text="Keep" onPress={keep} /> <Button variant="filled" accent="danger" text="Delete" onPress={remove} /> </Box> ``` ### `soft` beyond the secondary action, and `outlined` ```tsx <Button variant="soft" text="Docs" /> <IconButton variant="soft" icon={<XRegularIcon />} aria-label="Close" /> ``` `soft` has no ground and no border at rest: the affordance is a fill arriving on hover/focus/press, and that fill is a tone of the surrounding surface rather than the accent. A `PressableBox` row composed on it (a header pressable, a menu row) therefore keeps its own label color — never flip it to `text-on-accent`. It is what a header pressable or a menu row wants, where a border tint reads as noise, and the icon-only affordance inside a component's own frame — the modal close, the message dismiss, the remove button of a field-array row — where a second ground inside the frame would read as a nested card. With no ground to carry the accent, the soft `Button` carries it in its label (`text-accent`, underlined — a text button), and the soft `IconButton` glyph turns `text-accent` on hover/focus/press. `outlined` has no default use. Reach for it only for a pressable that must not carry a ground and is not chrome — a row of equal-weight actions in a dense toolbar. A secondary action is `accent="neutral" variant="soft"`, not `outlined`. ### Async action — prefer ActionButton For a direct action whose `onPress` is async (delete, retry, publish — not a form submit, which uses `FormSubmitButton`), use `ActionButton`. It runs the promise, derives the button `state` for you (spinner while pending, terminal icon on settle), and renders an inline `ErrorMessage` on rejection. Map the error to a string via the required `errorToMessage`. ```tsx import { ActionButton } from "alouette"; <ActionButton accent="danger" text="Delete" onPress={async () => deleteItem(id)} errorToMessage={(error) => error instanceof Error ? error.message : "Failed" } />; ``` `ActionButton` omits `onPress`/`state` from `ButtonProps` and manages them itself — don't pass `state` to it. The failure message is a raised `ErrorMessage`. When the button already sits inside a raised surface (a `surface` card, a modal panel), pass `errorMessageVariant="flat"` so the message doesn't read as a card on a card: ```tsx <Box className="surface"> <ActionButton text="Delete" errorMessageVariant="flat" onPress={remove} errorToMessage={toMessage} /> </Box> ``` Leave it unset everywhere else — see alouette-feedback for when `flat` is warranted. ### Manual loading state Reach for `Button`'s `state` prop directly only when `ActionButton` doesn't fit (state driven externally, e.g. by a form or store). `state` drives a transient overlay and disables the button: `"loading"` shows an indeterminate spinner, `"success"` a check, `"failed"` a warning icon (the spinner plays its finish animation before the terminal icon). Any non-`undefined` `state` disables presses. ```tsx import { Button, type ButtonState } from "alouette"; <Button text="Save" state={submitState} onPress={save} />; // submitState: ButtonState | undefined = "loading" | "success" | "failed" ``` Don't drive loading UI yourself (spinner + manual `disabled`) — use `ActionButton`, or set `state`. ### Icon-only button ```tsx import { IconButton } from "alouette"; import { XRegularIcon } from "alouette-icons/phosphor-icons/X"; <IconButton icon={<XRegularIcon />} aria-label="Close" onPress={close} />; ``` `size` is `"sm" | "md"` or a number (custom diameter in px); `iconSize="fill"` makes the icon take 80% of the button. `variant` is `"tonal" | "filled" | "soft"` — an icon-only button has no `outlined`. ### Disabled with a reason When an action is unavailable for a reason the user should know, pass `disabledReason` to `Button`, `IconButton` or `ActionButton` rather than `disabled`. A non-empty reason disables the button, adds the reason to its accessible description, and on web shows it in a `Tooltip` on hover. Like any disabled button it leaves the tab order (react-native-web sets the native `disabled` attribute), so a screen reader gets the reason from the description. ```tsx <Button text="Archive" disabledReason={ isDeploying ? "A deployment of this project is still running" : null } onPress={archive} /> ``` Don't wrap a disabled button in a DOM element carrying `title`: react-native-web forwards no `title`, the wrapper is web-only, and the reason then has to be repeated in `aria-label`. For any other element (an icon-only button's label, a truncated value), `Tooltip` takes a `render` prop and hands it the props to spread on the described element: ```tsx import { Tooltip } from "alouette"; <Tooltip content="Archive the project" render={(triggerProps) => ( <IconButton {...triggerProps} icon={<ArchiveRegularIcon />} aria-label="Archive" /> )} />; ``` The trigger props carry the hover and focus handlers as well as `aria-describedby`, so spread them last, or keep the element's own with `mergeTooltipTriggerProps(ownProps, triggerProps)`, which joins the descriptions and runs both handlers. `content={null}` renders the element alone in the same tree, so toggling it never remounts the element. The panel opens on hover (not on a tap) and on keyboard focus, above the element or below it when there is no room above, and stays open while the pointer is on it. Don't nest two tooltips around one element. Native has no hover: `Tooltip` shows no panel there and only announces the content as the element's accessibility hint. ### Icon weight on interaction `Button`, `IconButton` and `MenuItem` take an optional `activeIcon` next to `icon` — usually the duotone twin of the same glyph — rendered while the control is hovered, focused or pressed. It is an accent on top of the affordance, not a replacement: the background and border still come from the `interactive-*` tokens, and a disabled control never swaps. ```tsx import { ArrowLeftDuotoneIcon, ArrowLeftRegularIcon, } from "alouette-icons/phosphor-icons/ArrowLeft"; <Button text="Back" icon={<ArrowLeftRegularIcon />} activeIcon={<ArrowLeftDuotoneIcon />} onPress={goBack} />; ``` ### Pressable surfaces `PressableBox` is a themed, pressable container (`variant`, `accent`, `forceStyle`; `tonal` by default; `raised={false}` drops the tonal shadow for a segment of a group that casts one). `PressableListItem` is a row with a trailing caret, on the same `tonal` material as a button: a card lifted off the surface whose ground is a tone of the theme, never the accent's fill. Its label and caret are `text-on-tonal`, the ink that carries the accent where the ground cannot. These, and the link components below, are how something becomes interactive — never by wrapping a display-only component (`Badge`, `Bullet`, `Text`) in a `Link` or `Pressable` (alouette-styling/SKILL.md). ```tsx import { PressableBox, PressableListItem, Text } from "alouette"; <PressableBox onPress={open}> <Text>Custom card</Text> </PressableBox> <PressableListItem onPress={open}> <Text>Row label</Text> </PressableListItem>; ``` A `PressableListItem` holding more than a title needs `aria-label` ("Open Add dark mode"), else the row is announced as every label, badge and date it holds. `actions` pins buttons to the row's bottom end; each is a nested pressable that takes its own press (the row's `onPress` does not fire), so give each its own handler. On web a `role="button"` row is a `<button>`, so actions nest a button in a button: pass `role="listitem"` (inside a `role="list"`) or `"menuitem"` for valid markup. `href` makes the row a real `<a>` — only on a row with no `actions` and no link in its children, which would nest inside the anchor. The row has no outer margin, so it lines up with the content around it: the list holding the rows spaces them (`<View className="gap-xs">`). ```tsx <PressableListItem aria-label="Open Add dark mode" actions={<Button size="sm" text="Merge" onPress={merge} />} onPress={open} > <Text className="font-body-bold">Add dark mode</Text> </PressableListItem> ``` Source: packages/alouette/src/ui/actions/PressableListItem.tsx `PressableBox` carries `group`, so a child can style itself from the pressable's state (`group-hover:`, `group-active:`) — that is how `InteractiveIcon` swaps a glyph. It also forwards its `ref`, as `Button` and `IconButton` do, so a button can anchor a `Popover` or a `Menu`. `href` makes it a link: react-native-web renders a real `<a>` (native ignores it and routes from `onPress` — expo Router's `<Link asChild>` injects both), and the default `role` becomes `"link"` instead of `"button"`. Every pressable built on it inherits this — `Button`, `IconButton`, `AppHeaderSignIn` — and a component needing another role passes its own (`MenuItem` stays a `menuitem`). ```tsx <PressableBox href="/login"> <Text>Log in</Text> </PressableBox> ``` The keyboard focus ring is the `focus-ring` utility in the accent ink, on `focus-visible` only — a mouse click never leaves a ring or a changed ground behind. A `tonal` or `soft` pressable rings 2px inside its edge, `filled` and `outlined` 2px outside it. `withFocusVisibleOutline="inset"` moves the ring inside for a pressable whose parent clips (a `surface` is `overflow-hidden`), and `withFocusVisibleOutline={false}` drops it for a row of a list that already paints its cursor (a menu item, a listbox option), where the outline would ring whatever the pointer crosses. `false` emits `outline-solid outline-0`, not `outline-none` — react-native-css drops `outline-style: none`, so the UA ring would survive. ### Link buttons ```tsx import { ExternalLinkButton, ExternalLinkText, InternalLinkButton, LinkText } from "alouette"; <ExternalLinkButton href="https://example.com" text="Open" /> <InternalLinkButton href="/settings" text="Settings" /> ``` `LinkText` and `ExternalLinkText` are the inline counterparts — one underlined text link treatment, for a link inside a text flow rather than a call to action. `LinkText` goes to an in-app destination (`href` renders a real `<a>` on web, native routes from `onPress`; `icon` is leading), `ExternalLinkText` leaves the app and adds the trailing arrow. `size` is `"sm" | "md"` on both. Primary navigation between screens is a `NavBar`, not a text link (alouette-navigation/SKILL.md); for full external-link control (in-app browser vs new tab), see alouette-external-links/SKILL.md. ```tsx <LinkText href="/settings" text="Account settings" /> ``` ### Menu of secondary actions `Menu` is a pressable that opens a list of actions: anchored under its trigger on web, an overlay on native. Prefer it over a row of buttons for actions that are secondary, rare or destructive. The trigger is composed through `render` — spread the params it gives you (they carry the `ref`, `onPress`, `aria-haspopup` and `aria-expanded`) onto any pressable. `label` names the menu; `header` renders above the items, outside the `menu` element. ```tsx import { IconButton, Menu, MenuItem, Separator } from "alouette"; <Menu label="Document actions" render={(triggerProps) => ( <IconButton icon={<DotsThreeRegularIcon />} aria-label="Document actions" variant="soft" {...triggerProps} /> )} > <MenuItem label="Rename" icon={<PencilRegularIcon />} onPress={rename} /> <Separator /> <MenuItem label="Delete" accent="danger" onPress={remove} /> </Menu>; ``` A `MenuItem` closes the menu after its `onPress`. `accent` colors its label and icon (`danger` for a destructive action — the row itself stays a plain surface). `href` renders a real anchor on web, so expo Router's `<Link asChild>` composes with it, and a disabled item drops the `href`. `description` adds a line under the label; `checked` makes the rows a set of choices (`menuitemradio`), and the menu then opens on the checked one. One button choosing which of several actions it runs is a `SplitButton`: [references/split-button.md](references/split-button.md). On web the panel hangs from the trigger's end edge and sizes to its items. A full-width trigger takes `align="start"`, or `width="anchor"` to make the panel exactly as wide; a trigger pinned at the bottom of the viewport (a sidebar's account row) takes `side="top"`. ## Common Mistakes ### HIGH Passing the button label as children instead of text Wrong: ```tsx <Button>Save</Button> ``` Correct: ```tsx <Button text="Save" /> ``` `Button` renders its label from the required `text` prop (plus an optional `icon` prop); children are ignored, so `<Button>Save</Button>` shows no label. Source: packages/alouette/src/ui/actions/Button.tsx ### HIGH A tonal or `outlined` secondary button Wrong: ```tsx <Button accent="neutral" text="Cancel" onPress={close} /> <Button text="Save" onPress={save} /> <Button variant="outlined" text="Cancel" onPress={close} /> <Button text="Save" onPress={save} /> ``` Correct: ```tsx <Button accent="neutral" variant="soft" text="Cancel" onPress={close} /> <Button text="Save" onPress={save} /> ``` The secondary action gives way to the primary one. A neutral `tonal` Cancel keeps the ground and the shadow, so the pair reads as two equal choices — and on a white `bg-highlight` panel (a `Modal` footer) its ground is the panel's white and dissolves. An `outlined` one draws a frame that competes, and on a `surface` card its `bg-highlight` fights the card it sits on. Neutral `soft` is a text button in the ambient ink: present, and plainly second. Source: packages/alouette/src/ui/containers/AlertDialog.tsx, ui/actions/Button.tsx ### HIGH Using non-existent variant names Wrong: ```tsx <Button variant="contained" text="Cancel" /> <Button variant="primary" text="Save" /> ``` Correct: ```tsx <Button accent="neutral" variant="soft" text="Cancel" /> <Button accent="brand" text="Save" /> ``` `variant` is only `"tonal" | "filled" | "outlined" | "soft"` (`IconButton`: no `outlined`); `contained`, `list` and `ghost` no longer exist. The accent is chosen via the `accent` prop — `"brand" | "danger" | "info" | "success" | "warning" | "neutral"`. Source: packages/alouette/src/ui/actions/PressableBox.tsx, ui/actions/Button.tsx ### MEDIUM IconButton without aria-label Wrong: ```tsx <IconButton icon={<XRegularIcon />} onPress={close} /> ``` Correct: ```tsx <IconButton icon={<XRegularIcon />} aria-label="Close" onPress={close} /> ``` `IconButton` types `aria-label` as required because it has no text label; omitting it is an accessibility failure and a type error. Source: packages/alouette/src/ui/actions/IconButton.tsx ### HIGH IconButton triggering an async action instead of ActionButton Wrong: ```tsx <IconButton icon={<TrashRegularIcon />} aria-label="Delete" onPress={async () => deleteItem(id)} /> ``` Correct: ```tsx <ActionButton accent="danger" text="Delete" icon={<TrashRegularIcon />} onPress={async () => deleteItem(id)} errorToMessage={(error) => error instanceof Error ? error.message : "Failed" } /> ``` An icon alone doesn't communicate intent reliably — a visible text label is required for any action with consequences. An `IconButton` also has no async affordance: it won't show a spinner while the promise is pending or surface a rejection. Use `ActionButton` (text + optional `icon`) so the intent is explicit and the pending/error states are handled. Source: packages/alouette/src/ui/actions/ActionButton.tsx, ui/actions/IconButton.tsx