diff --git a/packages/propel/src/components/pill/index.tsx b/packages/propel/src/components/pill/index.tsx index ccb97d34..4ed981cf 100644 --- a/packages/propel/src/components/pill/index.tsx +++ b/packages/propel/src/components/pill/index.tsx @@ -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"; diff --git a/packages/propel/src/components/pill/pill.stories.tsx b/packages/propel/src/components/pill/pill.stories.tsx index ae2d780a..25e1f0d4 100644 --- a/packages/propel/src/components/pill/pill.stories.tsx +++ b/packages/propel/src/components/pill/pill.stories.tsx @@ -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", @@ -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: () => ( +
+
+ } + label="outline" + /> + } + label="outline hover" + /> + } + label="outline active" + /> + } + disabled + label="outline disabled" + /> + +
+
+ } label="soft" /> + } + label="soft hover" + /> + } + label="soft active" + /> + } + disabled + label="soft disabled" + /> + +
+
+ ), +}; + +/** Both fill treatments: `outline` (≈ secondary) and `soft` (≈ tertiary). */ +export const Emphases: Story = { + parameters: { controls: { disable: true } }, render: () => (
- } label="Default" /> - } label="Hover" /> - } label="Active" /> - } disabled label="Disabled" /> - + } + label="outline" + /> + } label="soft" />
), }; @@ -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: () => (
} label="Off" /> } defaultPressed label="On" /> + } + label="Hover" + /> + } disabled label="Disabled" />
), }; @@ -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: () => ( +
+ } + label="Engineering Infrastructure Team" + /> + } + label="Engineering Infrastructure Team" + /> +
+ ), + 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 ``; 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"], @@ -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(); }, }; @@ -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: () => ( + } + /> + ), + 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(); + }, +}; diff --git a/packages/propel/src/components/pill/pill.tsx b/packages/propel/src/components/pill/pill.tsx index f077921c..4eb284eb 100644 --- a/packages/propel/src/components/pill/pill.tsx +++ b/packages/propel/src/components/pill/pill.tsx @@ -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 & { +export type { PillButtonEmphasis }; + +export type PillButtonProps = Omit & { + /** Fill treatment. Defaults to `outline` (≈ Button secondary). `soft` ≈ Button tertiary. */ + emphasis?: PillButtonEmphasis; /** Element rendered before the label (inline-start), e.g. ``. */ startIcon?: React.ReactNode; /** Element rendered after the label (inline-end), e.g. ``. */ 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, @@ -38,20 +44,20 @@ export function PillButton({ return ( } + render={} disabled={disabled || loading} focusableWhenDisabled={loading ? true : undefined} aria-busy={loading ? true : undefined} > + {!loading ? startIcon : null} + {label} {loading ? ( ) : ( - startIcon + endIcon )} - {label} - {!loading ? endIcon : null} ); } diff --git a/packages/propel/src/elements/pill/icon-pill.tsx b/packages/propel/src/elements/pill/icon-pill.tsx index 86e29936..6e0be481 100644 --- a/packages/propel/src/elements/pill/icon-pill.tsx +++ b/packages/propel/src/elements/pill/icon-pill.tsx @@ -7,9 +7,9 @@ export type IconPillProps = Omit, "className" IconPillVariantProps; /** - * The icon-only square pill container: a styled `