From 65ef0d2d5aca3a3888f40f26617473da747fe4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Tue, 25 Aug 2026 12:09:39 +0200 Subject: [PATCH 1/9] feat(F0Button): render the counter in the neutral default type The button's counter was the "selected" (teal) type, which reads as a toggled state rather than a plain count. A count next to a label is neutral information, so it now uses the default grey counter, with no way to pick the louder selected or bold types. Note: this also restyles the OneFilterPicker applied-filters counter, the only other caller, from teal to grey. --- packages/react/src/components/F0Button/internal-types.ts | 2 +- packages/react/src/components/F0Button/internal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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..149b309a4b 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -172,7 +172,7 @@ const ButtonInternal = forwardRef< {iconPosition === "right" && iconNode} {append}{" "} {counterValue && ( - + )} From 01336949cd3aecb477afaa45584e99606b9c4bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Tue, 25 Aug 2026 12:10:48 +0200 Subject: [PATCH 2/9] feat(F0Button): add 2px of breathing room before the counter --- packages/react/src/components/F0Button/internal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index 149b309a4b..169f9e04f6 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -172,7 +172,9 @@ const ButtonInternal = forwardRef< {iconPosition === "right" && iconNode} {append}{" "} {counterValue && ( - + + + )} From e08eaad8dc9edb511f958c78b79f94857f651396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Tue, 25 Aug 2026 12:57:45 +0200 Subject: [PATCH 3/9] fix(F0Button): center the counter pill in the button height The margin wrapper was a block line-box taller than the counter, so the pill sat about a pixel below center. Shrinking the wrapper to the counter with inline-flex lets the button's items-center place it exactly. --- packages/react/src/components/F0Button/internal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index 169f9e04f6..13176cb8be 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -172,7 +172,7 @@ const ButtonInternal = forwardRef< {iconPosition === "right" && iconNode} {append}{" "} {counterValue && ( - + )} From 44802da42871178f41f3e3f45493886f90b94309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Tue, 25 Aug 2026 13:32:33 +0200 Subject: [PATCH 4/9] fix(F0Button): widen the gap before the counter to 4px --- packages/react/src/components/F0Button/internal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index 13176cb8be..e8d21856c3 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -172,7 +172,7 @@ const ButtonInternal = forwardRef< {iconPosition === "right" && iconNode} {append}{" "} {counterValue && ( - + )} From ff1f2fd6b73ea693ba3ec32981ca9e685fa89675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Tue, 25 Aug 2026 13:40:08 +0200 Subject: [PATCH 5/9] feat(F0Button): give the counter a dark pill on the primary variant The default (primary) button is a solid dark-red field where the light grey counter washes out. Scoping the dark theme to just the counter on that variant gives it a dark pill with light text, for contrast, regardless of the app's current theme. --- packages/react/src/components/F0Button/internal.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index e8d21856c3..56c9f04bae 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -172,7 +172,15 @@ const ButtonInternal = forwardRef< {iconPosition === "right" && iconNode} {append}{" "} {counterValue && ( - + )} From 775b04ffb676696b431161d4a5b2637fd3c4d65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Thu, 27 Aug 2026 10:40:16 +0200 Subject: [PATCH 6/9] feat(F0Button): tighten the right padding when a counter is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the Figma "ButtonCounter" spec, a button with a trailing counter drops 4px of right padding at every size (sm 8→4, md 12→8, lg 16→12); the left padding is unchanged, and buttons without a counter keep their symmetric padding. --- packages/react/src/components/F0Button/internal.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index 56c9f04bae..1f010f133d 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -123,6 +123,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. + counterValue !== undefined && + { + 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", From 2cb761981d7bec82fc29adebdd6e37095aa8446a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Thu, 27 Aug 2026 10:42:21 +0200 Subject: [PATCH 7/9] test(F0Button): cover the counter render and counter-aware right padding --- .../F0Button/__tests__/F0Button.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx index b89aaf17db..5177e9e821 100644 --- a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx +++ b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx @@ -97,4 +97,21 @@ 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") + }) + }) }) From 5f564f23ead1e41feaf137c72ec5a007e8687c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Fri, 28 Aug 2026 10:49:07 +0200 Subject: [PATCH 8/9] feat(F0Button): scale the counter, hide it at 0, dark on all solid variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The counter now tracks the button height: 20px on md/lg, 16px on sm. - A count of 0 (or no value) renders nothing — no stray "0", no pill, and the right-padding tightening no longer fires for an absent counter. - The dark pill now covers every solid colour field (default, critical, promote), so the counter stays legible on critical, not just primary. - Documents the counter behaviour in a Storybook story. --- .../F0Button/__stories__/F0Button.stories.tsx | 49 +++++++++++++++++++ .../F0Button/__tests__/F0Button.test.tsx | 27 ++++++++++ .../src/components/F0Button/internal.tsx | 20 +++++--- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx index 6e4b982289..4f4fac8fbd 100644 --- a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx +++ b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx @@ -557,3 +557,52 @@ export const WithDataTestId: Story = { await expect(canvas.getByTestId("my-test-button")).toBeInTheDocument() }, } + +/** + * A button can carry a numeric `counterValue`, shown as a pill after the label. + * + * - Sizes track the button: 20px on `md`/`lg`, 16px on `sm`. + * - A count of `0` (or no value) renders nothing — no pill, and no padding change. + * - On solid colour fields (`default`, `critical`, `promote`) the counter is a + * dark pill so it stays legible; on light variants it is a neutral grey pill. + * - The button's right padding tightens 4px when a counter is present, since the + * counter's own border supplies that edge; the left padding is unchanged. + */ +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 5177e9e821..7db96e6d59 100644 --- a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx +++ b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx @@ -113,5 +113,32 @@ describe("F0Button", () => { 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") + }) + + it("gives the counter a dark pill on solid colour variants", () => { + render() + const wrapper = screen.getByText("3").closest("span") + expect(wrapper?.className).toContain("dark") + }) }) }) diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index 1f010f133d..f1ba0b4124 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -76,6 +76,13 @@ 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" + // Solid colour fields wash out a light counter, so it becomes a dark pill. + const counterOnSolidField = + variant === "default" || variant === "critical" || variant === "promote" const buttonFontSize = fontSize ?? size const iconNode = icon ? ( @@ -128,7 +135,7 @@ const ButtonInternal = forwardRef< // 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. - counterValue !== undefined && + hasCounter && { sm: "[&_.main]:!pr-1", md: "[&_.main]:!pr-2", @@ -182,17 +189,16 @@ const ButtonInternal = forwardRef< )} {iconPosition === "right" && iconNode} {append}{" "} - {counterValue && ( + {hasCounter && ( - + )} From 6e891e4cda9b4dbf24202d2600c46341589cf694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Moreno?= Date: Fri, 28 Aug 2026 12:43:50 +0200 Subject: [PATCH 9/9] feat(F0Button): refine the counter and document it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The counter darkens only where a light pill would wash out: always on the primary field, on critical only while hovered. Promote and the other variants keep the neutral counter. - The docs page gains a Counter section under Sizes — a preview of every variant with a counter, and guidance on what it is for and how it adapts. --- .../F0Button/__stories__/F0Button.mdx | 6 ++ .../F0Button/__stories__/F0Button.stories.tsx | 60 ++++++------------- .../F0Button/__tests__/F0Button.test.tsx | 19 +++++- .../src/components/F0Button/internal.tsx | 14 +++-- 4 files changed, 48 insertions(+), 51 deletions(-) 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 4f4fac8fbd..5f6fc11e60 100644 --- a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx +++ b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx @@ -558,51 +558,27 @@ export const WithDataTestId: Story = { }, } -/** - * A button can carry a numeric `counterValue`, shown as a pill after the label. - * - * - Sizes track the button: 20px on `md`/`lg`, 16px on `sm`. - * - A count of `0` (or no value) renders nothing — no pill, and no padding change. - * - On solid colour fields (`default`, `critical`, `promote`) the counter is a - * dark pill so it stays legible; on light variants it is a neutral grey pill. - * - The button's right padding tightens 4px when a counter is present, since the - * counter's own border supplies that edge; the left padding is unchanged. - */ 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 7db96e6d59..f13980f387 100644 --- a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx +++ b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx @@ -135,10 +135,23 @@ describe("F0Button", () => { expect(counterClass(lg)).toContain("min-w-5") }) - it("gives the counter a dark pill on solid colour variants", () => { + 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() - const wrapper = screen.getByText("3").closest("span") - expect(wrapper?.className).toContain("dark") + 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.tsx b/packages/react/src/components/F0Button/internal.tsx index f1ba0b4124..ff28ac06f6 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -80,9 +80,11 @@ const ButtonInternal = forwardRef< 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" - // Solid colour fields wash out a light counter, so it becomes a dark pill. - const counterOnSolidField = - variant === "default" || variant === "critical" || variant === "promote" + // 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 ? ( @@ -193,9 +195,9 @@ const ButtonInternal = forwardRef<