UNPKG

alouette

Version:

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

431 lines (335 loc) 15.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. 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. Load when adding buttons, text links, a menu of actions, or custom pressable elements. type: core library: alouette requires: - alouette-theming sources: - "christophehurpeau/alouette:packages/alouette/src/ui/actions/Button.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/actions/ActionButton.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/actions/IconButton.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/actions/PressableBox.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/actions/PressableListItem.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/actions/LinkText.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/actions/Menu.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/actions/MenuItem.tsx" --- This skill builds on alouette-theming. Read it first for the accent model. # alouette — Actions Buttons and pressables carry interactive token states (hover/focus/active/ disabled) automatically. `variant` is `"contained" | "list" | "outlined" | "ghost" | "soft"`; `size` is `"sm" | "md"`; `accent` is `"brand"` (default) `| "danger" | "info" | "success" | "warning" | "neutral"`. **Differentiate buttons by `accent`, not by `variant`.** `contained` 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"` — the same raised ground and shadow on the neutral tokens — never a lighter `variant`. ## Setup ```tsx import { Button } from "alouette"; import { CheckRegularIcon } from "alouette-icons/phosphor-icons/CheckRegularIcon"; <Button text="Save" icon={<CheckRegularIcon />} onPress={save} />; ``` ## Core Patterns ### Button accents ```tsx <Button text="Save" /> {/* contained, brand */} <Button accent="neutral" text="Cancel" /> {/* contained, neutral */} <Button accent="danger" text="Delete" /> <Button size="sm" text="Small" /> ``` `accent="neutral"` resolves to the plain mode theme (alouette-theming/SKILL.md), so the button keeps the contained material — ground, shadow, hover/focus/press states — on the grayscale palette. The neutral theme is an accent, not the absence of one: the fill takes the same scale steps a colored accent does, so it is a dark gray ground under the same white `text-on-accent` label. That is the Cancel of a confirmation, the Close of a modal, the Add of a field array, the "Sign up" beside a "Log in": a real button that does not compete with the accented one. `AlertDialog` and the form editor modals already build their footers this way. ### The other variants are chrome, not secondary buttons ```tsx <Button variant="soft" text="Docs" /> <IconButton variant="ghost" 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, so the label keeps its own 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. `ghost` is 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. `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 or destructive-but-secondary action is `accent="neutral"` (or `accent="danger"`), 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 <Surface> <ActionButton text="Delete" errorMessageVariant="flat" onPress={remove} errorToMessage={toMessage} /> </Surface> ``` 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/XRegularIcon"; <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. ### 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 } from "alouette-icons/phosphor-icons/ArrowLeftDuotoneIcon"; import { ArrowLeftRegularIcon } from "alouette-icons/phosphor-icons/ArrowLeftRegularIcon"; <Button text="Back" icon={<ArrowLeftRegularIcon />} activeIcon={<ArrowLeftDuotoneIcon />} onPress={goBack} />; ``` ### Pressable surfaces `PressableBox` is a themed, pressable container (`variant`, `accent`, `forceStyle`). `PressableListItem` is a row with a trailing caret, on the `list` variant: a card lifted off the surface whose ground is a tone of the theme — the card steps when neutral, the accent's pale tints in light mode and its own dark ground in dark mode, never the accent's fill. Its label and caret are `text-on-list`, 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. ```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> ``` `withFocusVisibleOutline={false}` drops the focus ring 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. It 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="ghost" {...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`. ## 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 `outlined` / `ghost` as the secondary button Wrong: ```tsx <Button variant="outlined" text="Cancel" onPress={close} /> <Button text="Save" onPress={save} /> ``` Correct: ```tsx <Button accent="neutral" text="Cancel" onPress={close} /> <Button text="Save" onPress={save} /> ``` Both actions are buttons and must be made of the same material; what separates them is the accent, not the amount of button they get. `outlined` and `ghost` trade the ground and the shadow away, so the pair reads as one button and one half-drawn thing — and on a `Surface` an outlined button's `bg-highlight` fights the card it sits on. `accent="neutral"` keeps the material and drops only the color. Source: packages/alouette/src/ui/containers/AlertDialog.tsx, ui/actions/Button.tsx ### HIGH Using non-existent variant names Wrong: ```tsx <Button variant="ghost-contained" text="Cancel" /> <Button variant="primary" text="Save" /> ``` Correct: ```tsx <Button variant="ghost" text="Cancel" /> <Button accent="brand" text="Save" /> ``` `variant` is only `"contained" | "list" | "outlined" | "ghost" | "soft"`; the accent is chosen via the `accent` prop — `"brand" | "danger" | "info" | "success" | "warning" | "neutral"`. (`ghost` is a variant value, not a separate boolean prop.) 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