alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
481 lines (379 loc) • 17.4 kB
Markdown
---
name: alouette-actions
description: >
Everything the user presses. Button and IconButton are the actions;
ActionButton is the one to reach for when pressing starts an async operation
— it runs the promise and shows its pending, success and failure states. DecisionBar is the row of actions ending a decision
(a dialog footer, a form's Cancel and Save) and owns their order. The
secondary action beside a primary one is a neutral soft text button, never a
second tonal one. ExternalLinkButton and InternalLinkButton are link-shaped
buttons; LinkText and ExternalLinkText the links inside a sentence.
PressableBox and PressableListItem are the bases of a custom pressable card
or row: nothing becomes interactive by being wrapped. A disabled button says why with
disabledReason, and Tooltip describes any other element. Menus of actions
are alouette-menus. Load when adding buttons, an async action, dialog or form
actions, text links, a tooltip, or a custom pressable card or list row.
sources:
- packages/alouette/src/ui/actions/Button.tsx
- packages/alouette/src/ui/actions/ActionButton.tsx
- packages/alouette/src/ui/actions/DecisionBar.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/LinkTextFrame.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 hue is carried by the `text-on-tonal` ink, not the ground.
- `accent="neutral"` keeps the tonal material on the grayscale palette: a lone
action that must not carry the accent (a `Menu` trigger, a field array's Add,
a `Modal` footer's single "Close"). Beside a primary action it becomes
`variant="soft"`.
- `filled` is the accent's own fill, flat, for the one action that must
dominate; the neutral `filled` button is black in light mode, white in dark.
- **On an accented surface** (`<Box accent className="surface">`, a
`GradientBackground`) a lone `tonal` button is `accent="neutral"`, and the
accented action is `filled` or `soft`: an accented `tonal` ground is the
surface's own step and dissolves. A `bg-highlight` panel (`Modal`,
`AlertDialog`) is a neutral ground, so it keeps an accented `tonal` confirm.
On web, `PressableBox` warns outside production when that happens.
- `soft` has no ground at rest: the secondary action, chrome where a border
reads as noise (header pressables, menu rows), and an icon-only control
inside a component's own frame (a modal close, a message dismiss). `outlined`
has no default use.
```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>
```
Why each holds (the ink on a pale tint, the dissolving ground, the soft label
and glyph tints) and when `outlined` fits:
[references/button-materials.md](references/button-materials.md).
### 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`. Any other element (an icon-only
button's label, a truncated value) is described by a `Tooltip`, whose `render`
hands the props to spread on it; native shows no panel and announces the
content as the accessibility hint. The trigger props, `mergeTooltipTriggerProps`
and placement: [references/tooltip.md](references/tooltip.md).
### 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 the themed, pressable container every custom pressable
starts from (`tonal` by default, the same `variant`s as a button);
`PressableListItem` is a row with a trailing caret on the same `tonal`
material. 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 aria-label="Open Add dark mode" onPress={open}>
<Text className="font-body-bold">Add dark mode</Text>
</PressableListItem>;
```
A `PressableListItem` holding more than a title needs `aria-label`, else the
row is announced as every label, badge and date it holds. `href` makes either
one a real `<a>` on web with the `link` role. Rows carry their own ground and
shadow, so a list of them stands on the screen ground — never inside a
`surface` card — and the list spaces them (`<View className="gap-xs">`).
`PressableBox` carries `group` for children styled from its state, and the
keyboard focus ring is drawn for you on `focus-visible` only.
Row `actions`, valid nesting roles, `raised={false}`, `href` routing and the
focus-ring options: [references/pressable-surfaces.md](references/pressable-surfaces.md).
### 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 behind a leading external-link arrow (`icon={null}` drops it). `size` is
`"sm" | "md"` on both. Inside a `Text` or `Paragraph` either one renders as a
nested `Text`, so it wraps with the sentence and sits on its baseline on both
platforms; on its own line it is a pressable row carrying the focus ring. 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
Secondary, rare or destructive actions go behind one trigger in a `Menu` of
`MenuItem`s rather than in a row of buttons, and one button choosing which of
several alternative actions it runs is a `SplitButton`: both are
alouette-menus/SKILL.md.
### Decision bar
The actions ending a decision — a `Modal` footer, an inline form's Cancel and
Save, a reply box — are a `DecisionBar`, never a hand-built row. It
right-aligns them in one order whatever the call site's: the dismiss action,
`alternative`, then `primary`. A lone "Close" is a `DecisionBar` with only a
`primary`.
```tsx
import { ActionButton, DecisionBar } from "alouette";
<DecisionBar
dismissText="Discard"
onDismiss={close}
alternative={
<ActionButton
accent="neutral"
text="Save draft"
onPress={saveDraft}
errorToMessage={toMessage}
/>
}
primary={
<ActionButton text="Publish" onPress={publish} errorToMessage={toMessage} />
}
/>;
```
- `primary` is required: an `ActionButton` for an async action, a
`FormSubmitButton` in a `Form`, a plain `Button` for a synchronous one.
- `onDismiss` makes the bar render the dismiss action itself, as the neutral
`soft` text button labelled `dismissText` ("Cancel" by default), so a
dismissal always looks the same. `emphasis="dismiss"` turns it neutral
`filled` when backing out is the expected answer — pass a `soft` primary then.
`dismissDisabled` locks it while the primary is pending.
- `alternative` is one other way to commit ("Save draft" beside "Publish").
Several alternatives are a `SplitButton` or a `Menu`.
- It holds only actions that end the decision. An editor's tools (attach,
insert a template) are not part of it: lay the editor's toolbar beside it.
## 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 A hand-built row of decision buttons
Wrong:
```tsx
<View className="flex-row gap-xs">
<Button text="Save" onPress={save} />
<Button accent="neutral" variant="soft" text="Cancel" onPress={close} />
</View>
```
Correct:
```tsx
<DecisionBar
dismissText="Cancel"
onDismiss={close}
primary={<Button text="Save" onPress={save} />}
/>
```
Each hand-built row picks its own alignment and order, and nothing catches it:
each one is correct on its own and passes review, and the app ends up with
Save before Cancel in one form and after it in the next. `DecisionBar` sets the
order from its props, not from the children's order.
Source: packages/alouette/src/ui/actions/DecisionBar.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