Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/propel/.storybook/preview.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
@import "../tailwind.css";

/* Make the preview canvas adapt to the active theme so the toolbar toggle is
visually meaningful (bg + text follow light/dark/contrast). */
visually meaningful (bg + text follow light/dark/contrast).
Use `layer-2` (not `canvas`): in light mode `--bg-canvas` === `--bg-layer-3`, so
tertiary button rest fills (and anything else keyed to `layer-3`) vanish on the
default Storybook page — Figma button sheets sit on white/`layer-2`. */
body {
@apply bg-canvas text-primary;
@apply bg-layer-2 text-primary;
}
10 changes: 6 additions & 4 deletions packages/propel/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,10 @@ existed).
`[&>svg]:size-*`. A family gets its OWN icon part only for genuinely distinct styling (toast/
banner/alert-dialog tones, avatar's scale, autocomplete/combobox focus-brightening); an icon slot
that is only a tint/size of the shared chrome is the internal `Icon` (`tint`/`magnitude` axes,
defaulting to `inherit`), composed by the `components` ready-made — icon-shaped controls
(`IconButton`, `Toggle`, `ToolbarButton`, `ToolbarToggle`) wrap their bare svg children in it
themselves, so consumers pass a bare glyph.
defaulting to `inherit`). Ready-mades expose that slot as an `icon` / `startIcon` / `endIcon`
**node prop** — consumers pass the public `<Icon icon={Plus} />` (or another already-wrapped
node). Icon-shaped controls (`IconButton`, `Toggle`, `ToolbarButton`, `ToolbarToggle`) render the
provided node as-is; they do **not** wrap bare lucide glyphs themselves.
- **`<Part>Indicator`** — a state-reflecting part. Keep Base UI's own names where they exist
(`ItemIndicator` for a selected marker, `Tabs`/`Progress`/`Meter`/`Slider` `Indicator` for
geometry). Disclosure carets are ONE internal `DisclosureIndicator` (pass a **chevron-down**;
Expand All @@ -217,7 +218,8 @@ not a part at all — compose the internal `NodeSlot` in `components` instead (`
Consumer-provided node **props** follow the same convention: name the prop for the slot it fills,
Base UI style (`placeholder`, `title`, `description`), never a `Node` suffix — `icon` fills the
family's single `Icon` slot; `startIcon`/`endIcon` fill dual `Icon` slots (bare `start`/`end` is
the logical-direction vocabulary, like Drawer's `side`); `trailing` fills `MenuItemTrailing`;
the logical-direction vocabulary, like Drawer's `side`). Pass the public `<Icon icon={Plus} />`
into those props (ready-mades do not wrap bare glyphs). `trailing` fills `MenuItemTrailing`;
`meta` fills `MenuLabelMeta`; a consumer-provided control is named for its role (`decrement`,
`increment`).

Expand Down
143 changes: 141 additions & 2 deletions packages/propel/src/components/anchor-button/anchor-button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ArrowUpRight, Plus } from "lucide-react";
import { expect, fn } from "storybook/test";
import * as React from "react";
import { expect, fn, userEvent as baseUserEvent, waitFor } from "storybook/test";

import { Icon } from "../icon";
import { AnchorButton, type AnchorButtonMagnitude, type AnchorButtonProminence } from "./index";
Expand Down Expand Up @@ -115,8 +116,146 @@ export const WithIcons: Story = {
),
};

/** The `loading` spinner replaces the leading icon and dims the label. */
/** The `loading` spinner trails the label and mutes via the link chrome. */
export const Loading: Story = {
args: { prominence: "primary", magnitude: "md", loading: true },
render: (args) => <AnchorButton {...args} label="Saving…" />,
};

/**
* An action that enters `loading` after click: spinner + `aria-busy`, blocks re-fires, keeps
* keyboard focus while soft-disabled, then settles once the work resolves.
*/
export const AsyncSubmit: Story = {
parameters: { controls: { disable: true } },
render: function Render(args) {
const [submitting, setSubmitting] = React.useState(false);
return (
<AnchorButton
{...args}
loading={submitting}
label={submitting ? "Submitting" : "Submit"}
onClick={() => {
setSubmitting(true);
window.setTimeout(() => setSubmitting(false), 1500);
}}
/>
);
},
};

/**
* Tab moves focus onto the control, then **Enter** activates it (fires `onClick`). Native
* `<button>` semantics — guards that the Base UI graft keeps them.
*/
export const EnterActivates: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn() },
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole("button", { name: "Show more" });
await userEvent.tab();
await expect(button).toHaveFocus();
await userEvent.keyboard("{Enter}");
await expect(args.onClick).toHaveBeenCalledOnce();
},
};

/** With the control focused, **Space** activates it (fires `onClick`). */
export const SpaceActivates: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn() },
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole("button", { name: "Show more" });
await userEvent.tab();
await expect(button).toHaveFocus();
await userEvent.keyboard("[Space]");
await expect(args.onClick).toHaveBeenCalledOnce();
},
};

/** A `disabled` AnchorButton does not fire `onClick`. */
export const DisabledBlocksClick: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn(), disabled: true },
play: async ({ args, canvas }) => {
const button = canvas.getByRole("button", { name: "Show more" });
await expect(button).toBeDisabled();
const user = baseUserEvent.setup({ pointerEventsCheck: 0 });
await user.click(button);
await expect(args.onClick).not.toHaveBeenCalled();
},
};

/**
* A `disabled` AnchorButton is removed from the tab order: Tab does not land on it and keyboard
* activation (Enter/Space) never fires `onClick`.
*/
export const DisabledNotKeyboardActivatable: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn(), disabled: true },
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole("button", { name: "Show more" });
await userEvent.tab();
await expect(button).not.toHaveFocus();
button.focus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("[Space]");
await expect(args.onClick).not.toHaveBeenCalled();
},
};

/** A `loading` AnchorButton stays focusable but keyboard activation does not fire `onClick`. */
export const LoadingNotKeyboardActivatable: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn(), loading: true },
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole("button", { name: "Show more" });
await userEvent.tab();
await expect(button).toHaveFocus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("[Space]");
await expect(args.onClick).not.toHaveBeenCalled();
},
};

/**
* A `loading` AnchorButton shows the spinner, is `aria-busy` + `aria-disabled`, and blocks clicks —
* but stays focusable (not natively `disabled`) so assistive tech can announce the busy state.
*/
export const LoadingBlocksClick: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn(), loading: true },
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole("button", { name: "Show more" });
await expect(button).toHaveAttribute("aria-busy", "true");
await expect(button).toHaveAttribute("aria-disabled", "true");
await expect(button).not.toBeDisabled();
await expect(button.querySelector("svg")).not.toBeNull();
button.focus();
await expect(button).toHaveFocus();
await userEvent.click(button);
await expect(args.onClick).not.toHaveBeenCalled();
},
};

/**
* The async-submit cycle keeps focus: activating flips into `loading` without dropping focus, and
* once the work settles the control is interactive again with focus still in place.
*/
export const AsyncSubmitKeepsFocus: Story = {
...AsyncSubmit,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const button = canvas.getByRole("button", { name: "Submit" });
await userEvent.tab();
await expect(button).toHaveFocus();
await userEvent.keyboard("{Enter}");
await expect(button).toHaveAttribute("aria-busy", "true");
await expect(button).toHaveAccessibleName("Submitting");
await expect(button).toHaveFocus();
await waitFor(() => expect(button).not.toHaveAttribute("aria-busy"), { timeout: 3000 });
await expect(button).not.toBeDisabled();
await expect(button).toHaveAccessibleName("Submit");
await expect(button).toHaveFocus();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,15 @@ export function AnchorButton({
focusableWhenDisabled={loading ? true : undefined}
aria-busy={loading ? true : undefined}
>
{!loading ? startIcon : null}
<AnchorButtonLabel>{label}</AnchorButtonLabel>
{loading ? (
<Spinner>
<LoaderCircle />
</Spinner>
) : (
startIcon
endIcon
)}
<AnchorButtonLabel>{label}</AnchorButtonLabel>
{!loading ? endIcon : null}
</BaseButton>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Button as BaseButton } from "@base-ui/react/button";
import * as React from "react";

import {
ButtonGroupButton as ButtonGroupButtonElement,
type ButtonGroupButtonMagnitude,
type ButtonGroupButtonProps as ButtonGroupButtonElementProps,
} from "../../elements/button-group";
import { ButtonGroupContext } from "./button-group-context";

export type ButtonGroupButtonProps = Omit<
ButtonGroupButtonElementProps,
"children" | "magnitude"
> & {
/**
* Set `false` when `render` swaps the underlying tag away from `<button>` (e.g. an `<a>`): Base
* UI then adds `role="button"`, tab focus, and Enter/Space activation.
*/
nativeButton?: boolean;
/**
* Size override. Inside a `ButtonGroup` the group's `magnitude` is used (so you can omit it);
* standalone it defaults to `md`.
*/
magnitude?: ButtonGroupButtonMagnitude;
/** Element rendered before the label (inline-start), e.g. `<Icon icon={Plus} />`. */
startIcon?: React.ReactNode;
/** Element rendered after the label (inline-end), e.g. `<Icon icon={ArrowRight} />`. */
endIcon?: React.ReactNode;
/** Visible button label. */
label: string;
};

/**
* The ready-made group segment: grafts Base UI's `Button` behavior onto the styled
* `ButtonGroupButton` (behavior outer, the styled button as the render target), lays out an
* optional `startIcon`/`endIcon` beside the label, and takes its `magnitude` from the surrounding
* `ButtonGroup` via context.
*/
export function ButtonGroupButton({
magnitude,
startIcon,
endIcon,
label,
render,
nativeButton,
...props
}: ButtonGroupButtonProps) {
const groupMagnitude = React.useContext(ButtonGroupContext);
return (
<BaseButton
{...props}
nativeButton={nativeButton}
render={
<ButtonGroupButtonElement
magnitude={magnitude ?? groupMagnitude ?? "md"}
// The consumer's render swaps the underlying element; the styled part stays the
// className owner (behavior part outer, styled part as the render target, rule 1a).
render={render}
/>
}
>
{startIcon}
{label}
{endIcon}
</BaseButton>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as React from "react";

import { type ButtonGroupButtonMagnitude } from "../../elements/button-group";

/**
* Set by the components `ButtonGroup` so every `ButtonGroupButton` inside it shares one
* `magnitude`. Lives in the components tier: a context is cross-tree coordination — composition —
* not a single-element `elements` concern.
*/
export const ButtonGroupContext = React.createContext<ButtonGroupButtonMagnitude | undefined>(
undefined,
);
Loading