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
2 changes: 1 addition & 1 deletion packages/propel/src/components/pill/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from "./icon-pill";
export * from "./pill";
export * from "./pill-switch";
// Re-export the atomic elements parts so a fully custom pill is importable from this convenience.
// Re-export PillLabel (+ magnitude types) for composition alongside the component wrappers.
export { PillLabel, type PillLabelProps, type PillMagnitude } from "../../elements/pill";
186 changes: 174 additions & 12 deletions packages/propel/src/components/pill/pill.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const MAGNITUDES = ["sm", "md", "lg"] as const;
// Module-scope spies so the `play` function can assert against the same references the
// `render` wires to the buttons.
const clickSpies = { onClick: fn(), onLoadingClick: fn() };
const iconPillLoadingSpy = fn();

const meta = {
title: "Components/Pill",
Expand Down Expand Up @@ -50,22 +51,91 @@ export const Magnitudes: Story = {
};

/**
* `PillButton` states. Default / hover / active darken the chip's fill + border (hover and active
* forced via the pseudo-states addon); `disabled` and `loading` drop to a transparent fill with a
* dimmed label, and `loading` swaps the inline-start node for a spinner.
* `PillButton` states for both emphases. Hover / active forced via the pseudo-states addon;
* `disabled` and `loading` drop to a transparent fill with a dimmed label; `loading` shows a
* spinner after the label (Figma).
*/
export const States: Story = {
parameters: {
controls: { disable: true },
pseudo: { hover: ["#pill-hover"], active: ["#pill-active"] },
pseudo: {
hover: ["#pill-outline-hover", "#pill-soft-hover"],
active: ["#pill-outline-active", "#pill-soft-active"],
},
},
render: () => (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<PillButton
magnitude="md"
emphasis="outline"
startIcon={<Icon icon={Tag} />}
label="outline"
/>
<PillButton
id="pill-outline-hover"
magnitude="md"
emphasis="outline"
startIcon={<Icon icon={Tag} />}
label="outline hover"
/>
<PillButton
id="pill-outline-active"
magnitude="md"
emphasis="outline"
startIcon={<Icon icon={Tag} />}
label="outline active"
/>
<PillButton
magnitude="md"
emphasis="outline"
startIcon={<Icon icon={Tag} />}
disabled
label="outline disabled"
/>
<PillButton magnitude="md" emphasis="outline" loading label="outline loading" />
</div>
<div className="flex items-center gap-3">
<PillButton magnitude="md" emphasis="soft" startIcon={<Icon icon={Tag} />} label="soft" />
<PillButton
id="pill-soft-hover"
magnitude="md"
emphasis="soft"
startIcon={<Icon icon={Tag} />}
label="soft hover"
/>
<PillButton
id="pill-soft-active"
magnitude="md"
emphasis="soft"
startIcon={<Icon icon={Tag} />}
label="soft active"
/>
<PillButton
magnitude="md"
emphasis="soft"
startIcon={<Icon icon={Tag} />}
disabled
label="soft disabled"
/>
<PillButton magnitude="md" emphasis="soft" loading label="soft loading" />
</div>
</div>
),
};

/** Both fill treatments: `outline` (≈ secondary) and `soft` (≈ tertiary). */
export const Emphases: Story = {
parameters: { controls: { disable: true } },
render: () => (
<div className="flex items-center gap-3">
<PillButton magnitude="md" startIcon={<Icon icon={Tag} />} label="Default" />
<PillButton id="pill-hover" magnitude="md" startIcon={<Icon icon={Tag} />} label="Hover" />
<PillButton id="pill-active" magnitude="md" startIcon={<Icon icon={Tag} />} label="Active" />
<PillButton magnitude="md" startIcon={<Icon icon={Tag} />} disabled label="Disabled" />
<PillButton magnitude="md" loading label="Loading" />
<PillButton
magnitude="md"
emphasis="outline"
startIcon={<Icon icon={Tag} />}
label="outline"
/>
<PillButton magnitude="md" emphasis="soft" startIcon={<Icon icon={Tag} />} label="soft" />
</div>
),
};
Expand All @@ -75,11 +145,21 @@ export const States: Story = {
* choices (e.g. display properties in a settings menu).
*/
export const Switch: Story = {
parameters: { controls: { disable: true } },
parameters: {
controls: { disable: true },
pseudo: { hover: ["#pill-switch-hover"] },
},
render: () => (
<div className="flex items-center gap-3">
<PillSwitch magnitude="md" startIcon={<Icon icon={Tag} />} label="Off" />
<PillSwitch magnitude="md" startIcon={<Icon icon={Check} />} defaultPressed label="On" />
<PillSwitch
id="pill-switch-hover"
magnitude="md"
startIcon={<Icon icon={Tag} />}
label="Hover"
/>
<PillSwitch magnitude="md" startIcon={<Icon icon={Tag} />} disabled label="Disabled" />
</div>
),
};
Expand Down Expand Up @@ -107,8 +187,42 @@ export const Icons: Story = {
};

/**
* Clicking a `PillButton` fires its handler, and a `loading` pill blocks the click while staying
* focusable (`aria-busy`). Tagged out of the sidebar/docs/manifest but still run under `test`.
* Labels past the width cap (PillButton 150px, PillSwitch 120px) truncate with an ellipsis.
* `PillLabel` sets a native `title` from the string label so hover recovers the full text.
*/
export const TruncatedLabel: Story = {
parameters: { controls: { disable: true } },
render: () => (
<div className="flex items-center gap-3">
<PillButton
magnitude="md"
startIcon={<Icon icon={Tag} />}
label="Engineering Infrastructure Team"
/>
<PillSwitch
magnitude="md"
startIcon={<Icon icon={Tag} />}
label="Engineering Infrastructure Team"
/>
</div>
),
play: async ({ canvas }) => {
const buttons = canvas.getAllByRole("button", { name: "Engineering Infrastructure Team" });
await expect(buttons).toHaveLength(2);
for (const button of buttons) {
// Icon is also a `<span>`; the label is the one carrying `title`.
const label = button.querySelector("span[title]");
await expect(label).toHaveAttribute("title", "Engineering Infrastructure Team");
await expect(label!.scrollWidth).toBeGreaterThan(label!.clientWidth);
}
},
};

/**
* Clicking a `PillButton` fires its handler. A `loading` pill is `aria-busy` + `aria-disabled` and
* blocks clicks, but stays focusable (NOT natively `disabled`) so assistive tech can announce busy.
* Spinner sits after the label (Figma). Tagged out of sidebar/docs/manifest; still run under
* `test`.
*/
export const ButtonClicks: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
Expand All @@ -129,8 +243,23 @@ export const ButtonClicks: Story = {

const busy = canvas.getByRole("button", { name: "Busy" });
await expect(busy).toHaveAttribute("aria-busy", "true");
await expect(busy).toHaveAttribute("aria-disabled", "true");
// Soft-disabled: remains in the tab order and focusable.
await expect(busy).not.toBeDisabled();
busy.focus();
await expect(busy).toHaveFocus();

await userEvent.click(busy);
await expect(onLoadingClick).not.toHaveBeenCalled();

// Loading spinner sits after the label (Figma).
const label = busy.querySelector("span[title='Busy']");
const spinner = busy.querySelector("svg");
await expect(label).not.toBeNull();
await expect(spinner).not.toBeNull();
await expect(
label!.compareDocumentPosition(spinner!) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
},
};

Expand All @@ -150,3 +279,36 @@ export const SwitchToggles: Story = {
await expect(toggle).toHaveAttribute("aria-pressed", "false");
},
};

/**
* A `loading` `IconPill` is `aria-busy` + `aria-disabled` and blocks activation, but stays
* focusable (NOT natively `disabled`). Tagged out of sidebar/docs/manifest; still run under
* `test`.
*/
export const IconLoadingBlocks: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
render: () => (
<IconPill
magnitude="md"
aria-label="Add item"
loading
onClick={iconPillLoadingSpy}
icon={<Icon icon={Plus} />}
/>
),
play: async ({ canvas }) => {
iconPillLoadingSpy.mockClear();

const button = canvas.getByRole("button", { name: "Add item" });
await expect(button).toHaveAttribute("aria-busy", "true");
await expect(button).toHaveAttribute("aria-disabled", "true");
await expect(button).not.toBeDisabled();

button.focus();
await expect(button).toHaveFocus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("[Space]");
await userEvent.click(button);
await expect(iconPillLoadingSpy).not.toHaveBeenCalled();
},
};
24 changes: 15 additions & 9 deletions packages/propel/src/components/pill/pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,37 @@ import { LoaderCircle } from "lucide-react";
import type * as React from "react";

import {
type PillButtonEmphasis,
PillButton as PillButtonElement,
type PillButtonProps as PillButtonElementProps,
PillLabel,
} from "../../elements/pill";
import { Spinner } from "../../internal/spinner";

export type PillButtonProps = Omit<PillButtonElementProps, "children"> & {
export type { PillButtonEmphasis };

export type PillButtonProps = Omit<PillButtonElementProps, "children" | "emphasis"> & {
/** Fill treatment. Defaults to `outline` (≈ Button secondary). `soft` ≈ Button tertiary. */
emphasis?: PillButtonEmphasis;
/** Element rendered before the label (inline-start), e.g. `<Icon icon={Tag} />`. */
startIcon?: React.ReactNode;
/** Element rendered after the label (inline-end), e.g. `<Icon icon={X} />`. */
endIcon?: React.ReactNode;
/** Visible pill label. */
label: string;
/** Busy state: swaps the inline-start node for a spinner and blocks clicks. */
/** Busy state: hides start/end icons, shows a spinner after the label, and blocks clicks. */
loading?: boolean;
};

/**
* The ready-made pill button: grafts Base UI's `Button` behavior onto the styled `PillButton`
* container (behavior outer, the styled button as the render target), with an optional leading node
* (or a spinner while `loading`), the `PillLabel`, and an optional trailing node. `loading`
* disables the button while keeping it focusable (`aria-busy`).
* container (behavior outer, the styled button as the render target), with an optional leading
* node, the `PillLabel`, an optional trailing node, and a trailing spinner while `loading`.
* `loading` disables the button while keeping it focusable (`aria-busy`).
*/
export function PillButton({
magnitude,
emphasis = "outline",
startIcon,
endIcon,
loading = false,
Expand All @@ -38,20 +44,20 @@ export function PillButton({
return (
<BaseButton
{...props}
render={<PillButtonElement magnitude={magnitude} />}
render={<PillButtonElement magnitude={magnitude} emphasis={emphasis} />}
disabled={disabled || loading}
focusableWhenDisabled={loading ? true : undefined}
aria-busy={loading ? true : undefined}
>
{!loading ? startIcon : null}
<PillLabel>{label}</PillLabel>
{loading ? (
<Spinner>
<LoaderCircle />
</Spinner>
) : (
startIcon
endIcon
)}
<PillLabel>{label}</PillLabel>
{!loading ? endIcon : null}
</BaseButton>
);
}
6 changes: 3 additions & 3 deletions packages/propel/src/elements/pill/icon-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ export type IconPillProps = Omit<useRender.ComponentProps<"button">, "className"
IconPillVariantProps;

/**
* The icon-only square pill container: a styled `<button>` with no baked content. Compose a
* `PillIcon` (or `PillSpinner`) inside it. Base-UI-agnostic — graft the Base UI `Button` behavior
* in `components` via `<BaseButton render={<IconPill/>} />` (which also supplies the required
* The icon-only square pill container: a styled `<button>` with no baked content. Compose an `Icon`
* (or `Spinner` while busy) inside it. Base-UI-agnostic — graft the Base UI `Button` behavior in
* `components` via `<BaseButton render={<IconPill/>} />` (which also supplies the required
* `aria-label`).
*/
export function IconPill({ magnitude, render, ...props }: IconPillProps) {
Expand Down
14 changes: 11 additions & 3 deletions packages/propel/src/elements/pill/pill-label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ import { pillLabelVariants } from "./variants";

export type PillLabelProps = Omit<useRender.ComponentProps<"span">, "className" | "style">;

/** The pill's single-line label. Shrinks and truncates instead of wrapping. */
export function PillLabel({ render, ...props }: PillLabelProps) {
const defaultProps: useRender.ElementProps<"span"> = { className: pillLabelVariants() };
/**
* The pill's single-line label. Shrinks and truncates instead of wrapping. When `children` is a
* string, bakes a native `title` so hover recovers the full text past the 120px cap; pass `title`
* explicitly to override (or `title=""` to suppress).
*/
export function PillLabel({ render, children, title, ...props }: PillLabelProps) {
const defaultProps: useRender.ElementProps<"span"> = {
className: pillLabelVariants(),
children,
title: title !== undefined ? title : typeof children === "string" ? children : undefined,
};
return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) });
}
8 changes: 4 additions & 4 deletions packages/propel/src/elements/pill/pill-switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ export type PillSwitchProps = Omit<useRender.ComponentProps<"button">, "classNam
/**
* The toggle pill container. Renders a `<button>` by default carrying the selected look as its
* pressed state (the cva styles `data-pressed`); compose a `PillLabel` and optional
* leading/trailing `PillIcon` inside it. It does NOT embed any toggle behavior, keeping
* `elements/pill` independent of the Toggle primitive: a switch composition grafts the behavior on
* in `components` by rendering the styled button as the `Toggle`'s render target — `<Toggle
* render={<PillSwitch/>} />`.
* leading/trailing `Icon` inside it. It does NOT embed any toggle behavior, keeping `elements/pill`
* independent of the Toggle primitive: a switch composition grafts the behavior on in `components`
* by rendering the styled button as the `Toggle`'s render target — `<Toggle render={<PillSwitch/>}
* />`.
*/
export function PillSwitch({ magnitude, render, ...props }: PillSwitchProps) {
return useRender({
Expand Down
Loading