Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ Icons reinforce the label meaning. Use leading icons for the most common case, t
</table>
</Unstyled>

### Counter

<Canvas of={Stories.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

<Canvas of={Stories.States} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div className="flex flex-wrap items-center justify-center gap-4">
<F0Button {...args} variant="default" label="Default" counterValue={3} />
<F0Button {...args} variant="outline" label="Outline" counterValue={3} />
<F0Button {...args} variant="neutral" label="Neutral" counterValue={3} />
<F0Button {...args} variant="ghost" label="Ghost" counterValue={3} />
<F0Button
{...args}
variant="critical"
label="Critical"
counterValue={3}
/>
<F0Button {...args} variant="promote" label="Promote" counterValue={3} />
<F0Button
{...args}
variant="outlinePromote"
label="Outline promote"
counterValue={3}
/>
</div>
),
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,61 @@ describe("F0Button", () => {
expect(button).not.toBeDisabled()
expect(onError).toHaveBeenCalled()
})

describe("counter", () => {
it("renders the counter value", () => {
render(<F0Button label="To review" counterValue={3} />)
expect(screen.getByText("3")).toBeInTheDocument()
})

it("tightens the button's right padding when a counter is present", () => {
render(<F0Button label="To review" size="md" counterValue={3} />)
expect(screen.getByRole("button").className).toContain("[&_.main]:!pr-2")
})

it("keeps padding symmetric when there is no counter", () => {
render(<F0Button label="Review" size="md" />)
expect(screen.getByRole("button").className).not.toContain("!pr-2")
})

it("shows nothing when the count is 0 — no pill, no padding change", () => {
render(<F0Button label="Review" size="md" counterValue={0} />)
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(
<F0Button label="To review" size="sm" counterValue={3} />
)
const { container: lg } = render(
<F0Button label="To review" size="lg" counterValue={3} />
)
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(<F0Button variant="default" label="To review" counterValue={3} />)
expect(counterWrapper()?.className).toContain("dark")
})

it("keeps the counter neutral on promote", () => {
render(<F0Button variant="promote" label="To review" counterValue={3} />)
expect(counterWrapper()?.className).not.toContain("dark")
})

it("darkens the critical counter only on hover", async () => {
render(<F0Button variant="critical" label="To review" counterValue={3} />)
expect(counterWrapper()?.className).not.toContain("dark")
await userEvent.hover(screen.getByRole("button"))
expect(counterWrapper()?.className).toContain("dark")
})
})
})
2 changes: 1 addition & 1 deletion packages/react/src/components/F0Button/internal-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
33 changes: 31 additions & 2 deletions packages/react/src/components/F0Button/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -171,8 +191,17 @@ const ButtonInternal = forwardRef<
)}
{iconPosition === "right" && iconNode}
{append}{" "}
{counterValue && (
<Counter value={counterValue} size="sm" type="selected" />
{hasCounter && (
<span
className={cn(
"ml-1 inline-flex items-center",
// Scoping the dark theme to just the counter gives it a dark
// pill regardless of the app theme.
counterIsDark && "dark"
)}
>
<Counter value={counterValue} size={counterSize} type="default" />
</span>
)}
</div>
</Action>
Expand Down
Loading