From 95d9af2a813945fff0ec5794607ae475343a0a56 Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Wed, 15 Jul 2026 15:00:35 +0530 Subject: [PATCH 1/5] Add TruncatedLabel story and enhance PillLabel for ellipsis support - Introduced a new `TruncatedLabel` story demonstrating the behavior of labels exceeding the 120px cap, which now truncate with an ellipsis. - Updated `PillLabel` to automatically set a native `title` attribute for string children, allowing full text recovery on hover. - Enhanced documentation to clarify the new functionality and ensure consistent user experience across pill components. --- .../src/components/pill/pill.stories.tsx | 32 +++++++++++++++++++ .../propel/src/elements/pill/pill-label.tsx | 14 ++++++-- .../propel/src/elements/pill/pill.stories.tsx | 28 ++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/propel/src/components/pill/pill.stories.tsx b/packages/propel/src/components/pill/pill.stories.tsx index ae2d780a..5c428121 100644 --- a/packages/propel/src/components/pill/pill.stories.tsx +++ b/packages/propel/src/components/pill/pill.stories.tsx @@ -106,6 +106,38 @@ export const Icons: Story = { ), }; +/** + * Labels past the 120px cap 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 - View" + /> + } + label="Engineering Infrastructure Team - Toggle" + /> +
+ ), + 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, and a `loading` pill blocks the click while staying * focusable (`aria-busy`). Tagged out of the sidebar/docs/manifest but still run under `test`. diff --git a/packages/propel/src/elements/pill/pill-label.tsx b/packages/propel/src/elements/pill/pill-label.tsx index 913ea7f2..5807e256 100644 --- a/packages/propel/src/elements/pill/pill-label.tsx +++ b/packages/propel/src/elements/pill/pill-label.tsx @@ -5,8 +5,16 @@ import { pillLabelVariants } from "./variants"; export type PillLabelProps = Omit, "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) }); } diff --git a/packages/propel/src/elements/pill/pill.stories.tsx b/packages/propel/src/elements/pill/pill.stories.tsx index af8b8f0b..8fa84906 100644 --- a/packages/propel/src/elements/pill/pill.stories.tsx +++ b/packages/propel/src/elements/pill/pill.stories.tsx @@ -219,6 +219,34 @@ export const Icons: Story = { ), }; +/** + * Labels past the 120px cap truncate with an ellipsis. `PillLabel` bakes a native `title` from the + * string children so hover recovers the full text. + */ +export const TruncatedLabel: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ + + + + Engineering Infrastructure Team – View + + + + + + Engineering Infrastructure Team – Toggle + +
+ ), +}; + /** * CSS canary (rule 2b): asserts the pinned `data-pressed` selector actually compiled — the pressed * switch's fill (`data-pressed:bg-layer-2-selected`) computes differently from the resting fill From a9f3d16160bfb2675992ae608ac66994d97bfd39 Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Wed, 15 Jul 2026 17:33:36 +0530 Subject: [PATCH 2/5] Add debug stories for PillSwitch, PillButton, and IconPill components - Introduced temporary debug stories for `PillSwitch`, `PillButton`, and `IconPill` to facilitate visual QA, showcasing various states (Default, Hover, Active, Disabled, Loading) across different magnitudes. - Added a `plusIcon` helper function for consistent icon rendering in the debug stories. - Updated the `TruncatedLabel` story to reflect a simplified label for better clarity. - Marked debug stories with tags to exclude them from autodocs and manifest generation, ensuring they remain for testing purposes only. --- .../src/components/pill/pill.stories.tsx | 204 +++++++++++++++++- 1 file changed, 202 insertions(+), 2 deletions(-) diff --git a/packages/propel/src/components/pill/pill.stories.tsx b/packages/propel/src/components/pill/pill.stories.tsx index 5c428121..77b031a2 100644 --- a/packages/propel/src/components/pill/pill.stories.tsx +++ b/packages/propel/src/components/pill/pill.stories.tsx @@ -106,6 +106,206 @@ export const Icons: Story = { ), }; +const plusIcon = () => ; + +/** + * TEMP debug matrix (Figma PillSwitch): columns = magnitude, rows = Off / Hover / Pressed. Delete + * when visual QA done. + */ +export const DebugPillSwitch: Story = { + tags: ["!autodocs", "!manifest"], + parameters: { + controls: { disable: true }, + pseudo: { + hover: MAGNITUDES.map((magnitude) => `#debug-switch-hover-${magnitude}`), + }, + }, + render: () => ( +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ ), +}; + +/** + * TEMP debug matrix (Figma PillButton): columns = magnitude, rows = Default / Hover / Active / + * Disabled / Loading. Figma also shows a second outline/ghost look group — not in the current API + * (one chrome only). Delete when visual QA done. + */ +export const DebugPillButton: Story = { + tags: ["!autodocs", "!manifest"], + parameters: { + controls: { disable: true }, + pseudo: { + hover: MAGNITUDES.map((magnitude) => `#debug-button-hover-${magnitude}`), + active: MAGNITUDES.map((magnitude) => `#debug-button-active-${magnitude}`), + }, + }, + render: () => ( +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ ), +}; + +/** + * TEMP debug matrix (Figma IconPill): columns = magnitude, rows = Default / Hover / Active / + * Disabled / Loading. Delete when visual QA done. + */ +export const DebugIconPill: Story = { + tags: ["!autodocs", "!manifest"], + parameters: { + controls: { disable: true }, + pseudo: { + hover: MAGNITUDES.map((magnitude) => `#debug-icon-hover-${magnitude}`), + active: MAGNITUDES.map((magnitude) => `#debug-icon-active-${magnitude}`), + }, + }, + render: () => ( +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+
+ ), +}; + /** * Labels past the 120px cap truncate with an ellipsis. `PillLabel` sets a native `title` from the * string label so hover recovers the full text. @@ -117,12 +317,12 @@ export const TruncatedLabel: Story = { } - label="Engineering Infrastructure Team - View" + label="Engineering Infrastructure Team" /> } - label="Engineering Infrastructure Team - Toggle" + label="Engineering Infrastructure Team" /> ), From 04ddebf1bf02326ce3a324f7cd5b3c19085c1fad Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Wed, 15 Jul 2026 18:20:07 +0530 Subject: [PATCH 3/5] Enhance PillButton and Pill stories with emphasis support - Added `emphasis` prop to `PillButton`, allowing for `outline` and `soft` styles, enhancing visual differentiation. - Updated `PillButton` stories to demonstrate both emphasis treatments across various states, ensuring comprehensive visual QA. - Modified existing stories to include emphasis in their configurations, improving consistency and clarity in the component's usage. - Adjusted documentation to reflect the new emphasis feature and its implications for styling and behavior. --- .../src/components/pill/pill.stories.tsx | 227 ++---------------- packages/propel/src/components/pill/pill.tsx | 24 +- .../propel/src/elements/pill/pill.stories.tsx | 50 +++- packages/propel/src/elements/pill/pill.tsx | 6 +- packages/propel/src/elements/pill/variants.ts | 45 ++-- 5 files changed, 110 insertions(+), 242 deletions(-) diff --git a/packages/propel/src/components/pill/pill.stories.tsx b/packages/propel/src/components/pill/pill.stories.tsx index 77b031a2..f91ad1b8 100644 --- a/packages/propel/src/components/pill/pill.stories.tsx +++ b/packages/propel/src/components/pill/pill.stories.tsx @@ -52,7 +52,7 @@ 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. + * dimmed label, and `loading` shows a spinner after the label (Figma). */ export const States: Story = { parameters: { @@ -70,6 +70,22 @@ export const States: Story = { ), }; +/** Both fill treatments: `outline` (≈ secondary) and `soft` (≈ tertiary). */ +export const Emphases: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ } + label="outline" + /> + } label="soft" /> +
+ ), +}; + /** * `PillSwitch` is a toggle: the selected look is its pressed state. Use it for segmented on/off * choices (e.g. display properties in a settings menu). @@ -106,206 +122,6 @@ export const Icons: Story = { ), }; -const plusIcon = () => ; - -/** - * TEMP debug matrix (Figma PillSwitch): columns = magnitude, rows = Off / Hover / Pressed. Delete - * when visual QA done. - */ -export const DebugPillSwitch: Story = { - tags: ["!autodocs", "!manifest"], - parameters: { - controls: { disable: true }, - pseudo: { - hover: MAGNITUDES.map((magnitude) => `#debug-switch-hover-${magnitude}`), - }, - }, - render: () => ( -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- ), -}; - -/** - * TEMP debug matrix (Figma PillButton): columns = magnitude, rows = Default / Hover / Active / - * Disabled / Loading. Figma also shows a second outline/ghost look group — not in the current API - * (one chrome only). Delete when visual QA done. - */ -export const DebugPillButton: Story = { - tags: ["!autodocs", "!manifest"], - parameters: { - controls: { disable: true }, - pseudo: { - hover: MAGNITUDES.map((magnitude) => `#debug-button-hover-${magnitude}`), - active: MAGNITUDES.map((magnitude) => `#debug-button-active-${magnitude}`), - }, - }, - render: () => ( -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- ), -}; - -/** - * TEMP debug matrix (Figma IconPill): columns = magnitude, rows = Default / Hover / Active / - * Disabled / Loading. Delete when visual QA done. - */ -export const DebugIconPill: Story = { - tags: ["!autodocs", "!manifest"], - parameters: { - controls: { disable: true }, - pseudo: { - hover: MAGNITUDES.map((magnitude) => `#debug-icon-hover-${magnitude}`), - active: MAGNITUDES.map((magnitude) => `#debug-icon-active-${magnitude}`), - }, - }, - render: () => ( -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- {MAGNITUDES.map((magnitude) => ( - - ))} -
-
- ), -}; - /** * Labels past the 120px cap truncate with an ellipsis. `PillLabel` sets a native `title` from the * string label so hover recovers the full text. @@ -363,6 +179,15 @@ export const ButtonClicks: Story = { await expect(busy).toHaveAttribute("aria-busy", "true"); 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(); }, }; 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/pill.stories.tsx b/packages/propel/src/elements/pill/pill.stories.tsx index 8fa84906..97d05b9e 100644 --- a/packages/propel/src/elements/pill/pill.stories.tsx +++ b/packages/propel/src/elements/pill/pill.stories.tsx @@ -10,7 +10,7 @@ const MAGNITUDES: PillMagnitude[] = ["sm", "md", "lg"]; // elements-tier story (rule 2b): a pure UI-configuration showcase. The styled pill containers // (PillButton / PillSwitch / IconPill) and inline parts (PillLabel, internal Icon/Spinner slots) -// render DIRECTLY — no Base UI grafts — with every visual axis (`magnitude`) shown and every +// render DIRECTLY — no Base UI grafts — with every visual axis (`magnitude` / `emphasis`) shown and every // visual state pinned statically via the attributes the cvas key off: `disabled` is the native // attribute, busy pins the `aria-busy` (+ `aria-disabled`) the ready-made pills set while // `loading`, and the switch's selected look pins the `data-pressed=""`/`aria-pressed` Base UI's @@ -21,7 +21,7 @@ const meta = { title: "Elements/Pill", component: PillButton, subcomponents: { PillSwitch, IconPill, PillLabel }, - args: { magnitude: "md", children: "Add label" }, + args: { magnitude: "md", emphasis: "outline", children: "Add label" }, render: ({ children, ...props }) => ( @@ -53,7 +53,7 @@ export const Magnitudes: Story = { render: () => (
{MAGNITUDES.map((magnitude) => ( - + @@ -64,12 +64,32 @@ export const Magnitudes: Story = { ), }; +/** Both fill treatments: `outline` (≈ Button secondary) and `soft` (≈ Button tertiary). */ +export const Emphases: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ + + + + outline + + + + + + soft + +
+ ), +}; + /** * Every visual state of `PillButton`, pinned statically. Hover / active / focus-visible are CSS * pseudo-classes forced by the pseudo-states addon; `disabled` is the native attribute the * `disabled:` palette keys off; busy pins the `aria-busy` (+ `aria-disabled`) the ready-made pill - * sets while `loading` — it swaps the leading node for the internal `Spinner` slot and keeps the - * button focusable instead of natively disabling it. + * sets while `loading` — spinner sits after the label (Figma) and keeps the button focusable. */ export const States: Story = { parameters: { @@ -82,41 +102,41 @@ export const States: Story = { }, render: () => (
- + Default - + Hover - + Active - + Focus - + Disabled - + + Busy - Busy
), @@ -227,7 +247,11 @@ export const TruncatedLabel: Story = { parameters: { controls: { disable: true } }, render: () => (
- + diff --git a/packages/propel/src/elements/pill/pill.tsx b/packages/propel/src/elements/pill/pill.tsx index 6d3d5e02..c3773e12 100644 --- a/packages/propel/src/elements/pill/pill.tsx +++ b/packages/propel/src/elements/pill/pill.tsx @@ -3,7 +3,7 @@ import { useRender } from "@base-ui/react/use-render"; import { pillButtonVariants, type PillButtonVariantProps } from "./variants"; -export type { PillMagnitude } from "./variants"; +export type { PillButtonEmphasis, PillMagnitude } from "./variants"; export type PillButtonProps = Omit, "className" | "style"> & PillButtonVariantProps; @@ -14,9 +14,9 @@ export type PillButtonProps = Omit, "classNam * Base-UI-agnostic — graft the Base UI `Button` behavior in `components` via `} />`. */ -export function PillButton({ magnitude, render, ...props }: PillButtonProps) { +export function PillButton({ magnitude, emphasis, render, ...props }: PillButtonProps) { const defaultProps: useRender.ElementProps<"button"> = { - className: pillButtonVariants({ magnitude }), + className: pillButtonVariants({ magnitude, emphasis }), type: "button", }; return useRender({ defaultTagName: "button", render, props: mergeProps(defaultProps, props) }); diff --git a/packages/propel/src/elements/pill/variants.ts b/packages/propel/src/elements/pill/variants.ts index a3b1f68f..c4dcda32 100644 --- a/packages/propel/src/elements/pill/variants.ts +++ b/packages/propel/src/elements/pill/variants.ts @@ -1,4 +1,4 @@ -import { cva, cx } from "class-variance-authority"; +import { cva, cx, type VariantProps } from "class-variance-authority"; import { type StrictVariantProps } from "../../internal/variant-props"; @@ -12,9 +12,9 @@ import { type StrictVariantProps } from "../../internal/variant-props"; // - Leading/trailing node position (inline-start / inline-end), sized per size step // // "Depends (adjustable)" → props: label (children), leading/trailing node, magnitude, -// selected/unselected (PillSwitch pressed state), disabled, and which interactive part -// (PillButton vs PillSwitch vs IconPill). magnitude has no sensible default, so it is a -// required prop on every container part — no cva `defaultVariants`. +// emphasis (PillButton outline|soft), selected/unselected (PillSwitch pressed state), disabled, +// and which interactive part (PillButton vs PillSwitch vs IconPill). magnitude/emphasis have no +// sensible default on elements, so they are required — no cva `defaultVariants`. // Shared structural base baked into every pill container cva below. const pillBase = @@ -32,22 +32,35 @@ const labelPillMagnitude = { // ─── Containers (one styled element each) ──────────────────────────────────── -export const pillButtonVariants = cva( - [ - labelPillBase, - "cursor-pointer border-subtle-1 bg-layer-2 text-secondary", - "hover:border-strong hover:bg-layer-2-hover", - "active:border-strong active:bg-layer-2-active active:text-primary", - "disabled:cursor-not-allowed disabled:border-subtle-1 disabled:bg-layer-transparent disabled:text-disabled", - "aria-busy:cursor-default aria-busy:border-subtle-1 aria-busy:bg-layer-transparent aria-busy:text-disabled", - ], - { - variants: { magnitude: labelPillMagnitude }, +export const pillButtonVariants = cva([labelPillBase, "cursor-pointer text-secondary"], { + variants: { + magnitude: labelPillMagnitude, + // Figma PillButton mirrors Button prominence chrome (control-chrome): + // `outline` ≈ secondary (bordered + layer-2), `soft` ≈ tertiary (borderless + layer-3). + // Disabled/loading stay transparent per the pill Figma (not button's layer-disabled fill). + emphasis: { + outline: [ + "border-subtle-1 bg-layer-2", + "hover:border-strong hover:bg-layer-2-hover", + "active:border-strong active:bg-layer-2-active active:text-primary", + "disabled:cursor-not-allowed disabled:border-subtle-1 disabled:bg-layer-transparent disabled:text-disabled", + "aria-busy:cursor-default aria-busy:border-subtle-1 aria-busy:bg-layer-transparent aria-busy:text-disabled", + ], + soft: [ + "border-transparent bg-layer-3", + "hover:bg-layer-3-hover", + "active:bg-layer-3-active active:text-primary", + "disabled:cursor-not-allowed disabled:bg-layer-transparent disabled:text-disabled", + "aria-busy:cursor-default aria-busy:bg-layer-transparent aria-busy:text-disabled", + ], + }, }, -); +}); // No `defaultVariants` today, so every axis is required. export type PillButtonVariantProps = StrictVariantProps; +type PillButtonVariantConfig = VariantProps; +export type PillButtonEmphasis = NonNullable; export const pillSwitchVariants = cva( [ From 500646dd90df5432b5c49ac1aed9835407a09d22 Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Wed, 15 Jul 2026 18:45:25 +0530 Subject: [PATCH 4/5] Refactor Pill components and stories for improved clarity and functionality - Updated `PillButton`, `PillSwitch`, and `IconPill` components to enhance their descriptions and ensure consistency in usage. - Reorganized stories to demonstrate new emphasis styles (`outline` and `soft`) and various states (Default, Hover, Active, Disabled, Loading) for better visual QA. - Improved accessibility features by ensuring `aria-busy` and `aria-disabled` attributes are correctly applied during loading states. - Enhanced documentation to reflect changes and clarify component behavior, ensuring a consistent user experience across the Pill components. --- packages/propel/src/components/pill/index.tsx | 2 +- .../src/components/pill/pill.stories.tsx | 131 ++++++++++++++-- .../propel/src/elements/pill/icon-pill.tsx | 6 +- .../propel/src/elements/pill/pill-switch.tsx | 8 +- .../propel/src/elements/pill/pill.stories.tsx | 142 +++++++++++------- packages/propel/src/elements/pill/pill.tsx | 2 +- packages/propel/src/elements/pill/variants.ts | 4 +- 7 files changed, 219 insertions(+), 76 deletions(-) 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 f91ad1b8..9da73e5f 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,75 @@ 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` shows a spinner after the label (Figma). + * `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="Default" /> - } label="Hover" /> - } label="Active" /> - } disabled label="Disabled" /> - +
+
+ } + label="outline" + /> + } + label="outline hover" + /> + } + label="outline active" + /> + } + disabled + label="outline disabled" + /> + +
+
+ } label="soft" /> + } + label="soft hover" + /> + } + label="soft active" + /> + } + disabled + label="soft disabled" + /> + +
), }; @@ -91,11 +145,21 @@ export const Emphases: 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" />
), }; @@ -155,8 +219,10 @@ export const TruncatedLabel: 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`. + * 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"], @@ -177,6 +243,12 @@ 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(); @@ -207,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/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 `