diff --git a/packages/react/src/components/F0Button/__stories__/F0Button.mdx b/packages/react/src/components/F0Button/__stories__/F0Button.mdx index 8d5c93efe0..438cd759c4 100644 --- a/packages/react/src/components/F0Button/__stories__/F0Button.mdx +++ b/packages/react/src/components/F0Button/__stories__/F0Button.mdx @@ -141,6 +141,12 @@ Icons reinforce the label meaning. Use leading icons for the most common case, t +### Counter + + + +Counters surface a quantity tied to the action — items to review, pending approvals — right on the button, so the number and its call to action stay together. Set `counterValue` and a pill appears after the label, sized to the button and tucked flush against its trailing edge. It adapts to the surface it sits on: a dark pill on the primary field, and on critical only while hovered, where the field darkens enough to wash a light pill out; elsewhere it stays a neutral grey. A count of zero shows nothing, so the same button reads cleanly whether or not there is anything to count. + ### States diff --git a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx index 6e4b982289..5f6fc11e60 100644 --- a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx +++ b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx @@ -557,3 +557,28 @@ export const WithDataTestId: Story = { await expect(canvas.getByTestId("my-test-button")).toBeInTheDocument() }, } + +export const Counter: Story = { + tags: ["no-sidebar"], + render: (args) => ( +
+ + + + + + + +
+ ), +} diff --git a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx index b89aaf17db..f13980f387 100644 --- a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx +++ b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx @@ -97,4 +97,61 @@ describe("F0Button", () => { expect(button).not.toBeDisabled() expect(onError).toHaveBeenCalled() }) + + describe("counter", () => { + it("renders the counter value", () => { + render() + expect(screen.getByText("3")).toBeInTheDocument() + }) + + it("tightens the button's right padding when a counter is present", () => { + render() + expect(screen.getByRole("button").className).toContain("[&_.main]:!pr-2") + }) + + it("keeps padding symmetric when there is no counter", () => { + render() + expect(screen.getByRole("button").className).not.toContain("!pr-2") + }) + + it("shows nothing when the count is 0 — no pill, no padding change", () => { + render() + expect(screen.queryByText("0")).not.toBeInTheDocument() + expect(screen.getByRole("button").className).not.toContain("!pr-2") + }) + + it("uses the smaller counter on sm and the larger one on md/lg", () => { + const { container: sm } = render( + + ) + const { container: lg } = render( + + ) + const counterClass = (c: HTMLElement) => + Array.from(c.querySelectorAll("div")).find((d) => + d.className.includes("rounded") + )?.className ?? "" + expect(counterClass(sm)).toContain("min-w-4") + expect(counterClass(lg)).toContain("min-w-5") + }) + + const counterWrapper = () => screen.getByText("3").closest("span") + + it("gives the primary counter a dark pill", () => { + render() + expect(counterWrapper()?.className).toContain("dark") + }) + + it("keeps the counter neutral on promote", () => { + render() + expect(counterWrapper()?.className).not.toContain("dark") + }) + + it("darkens the critical counter only on hover", async () => { + render() + expect(counterWrapper()?.className).not.toContain("dark") + await userEvent.hover(screen.getByRole("button")) + expect(counterWrapper()?.className).toContain("dark") + }) + }) }) diff --git a/packages/react/src/components/F0Button/internal-types.ts b/packages/react/src/components/F0Button/internal-types.ts index 526818c552..078706f847 100644 --- a/packages/react/src/components/F0Button/internal-types.ts +++ b/packages/react/src/components/F0Button/internal-types.ts @@ -50,7 +50,7 @@ export type ButtonInternalProps = Pick< */ variant?: ActionButtonVariant /** - * The filters'counter value to display. + * A count shown in a neutral counter to the right of the label. */ counterValue?: number /** diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index ed4a5a266a..ff28ac06f6 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -76,6 +76,15 @@ const ButtonInternal = forwardRef< const shouldHideLabel = hideLabel || emoji const buttonLabel = (label ?? "").toString() + // A count of 0 (or no value) shows nothing: no pill, no padding change. + const hasCounter = counterValue !== undefined && counterValue > 0 + // The counter matches the button height — 20px on md/lg, 16px on sm. + const counterSize = size === "sm" ? "sm" : "md" + // A dark counter pill: always on the primary (default) solid field, and on + // critical only while hovered, where the field darkens enough to wash out the + // light pill. Other variants (including promote) keep the neutral counter. + const counterIsDark = + variant === "default" || (variant === "critical" && isHovered) const buttonFontSize = fontSize ?? size const iconNode = icon ? ( @@ -123,6 +132,17 @@ const ButtonInternal = forwardRef< className={cn( "max-w-full", block && "w-full", + // A trailing counter has its own bordered edge, so the button's right + // padding tightens 4px (Figma "ButtonCounter"); the left is unchanged. + // Important because the override and the size variant's `px` both target + // `.main` as arbitrary variants, which tailwind-merge leaves unmerged — + // so the cascade, not class order, has to decide, and `!` guarantees it. + hasCounter && + { + sm: "[&_.main]:!pr-1", + md: "[&_.main]:!pr-2", + lg: "[&_.main]:!pr-3", + }[size], withoutDisabledAppearance && disabled && "disabled:pointer-events-none disabled:opacity-100 disabled:cursor-default [&[aria-disabled=true]]:opacity-100 [&[aria-disabled=true]]:cursor-default", @@ -171,8 +191,17 @@ const ButtonInternal = forwardRef< )} {iconPosition === "right" && iconNode} {append}{" "} - {counterValue && ( - + {hasCounter && ( + + + )}