Add v2 IconButton component
Implement the IconButton component from the Probo Radix UI Figma: a square, icon-only <button> sharing Button's size 1-4 and variant/color/highContrast matrix, with a loading spinner state. The single icon is passed as children; callers provide an aria-label. Corner radius is bound to size. Includes a paired IconButtonSkeleton. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
112
packages/ui/src/v2/IconButton/IconButton.stories.tsx
Normal file
112
packages/ui/src/v2/IconButton/IconButton.stories.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { GearIcon } from "@phosphor-icons/react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import { IconButton } from "./IconButton";
|
||||
import { IconButtonSkeleton } from "./IconButtonSkeleton";
|
||||
|
||||
const sizes = [1, 2, 3, 4] as const;
|
||||
const variants = ["classic", "solid", "soft", "surface", "outline", "ghost"] as const;
|
||||
const colors = ["neutral", "gold", "red", "green", "amber", "sky"] as const;
|
||||
|
||||
export default {
|
||||
title: "v2/IconButton",
|
||||
component: IconButton,
|
||||
args: {
|
||||
"aria-label": "Settings",
|
||||
"children": <GearIcon />,
|
||||
"size": 2,
|
||||
"variant": "solid",
|
||||
"color": "gold",
|
||||
"highContrast": false,
|
||||
"loading": false,
|
||||
"disabled": false,
|
||||
},
|
||||
} satisfies Meta<typeof IconButton>;
|
||||
|
||||
type Story = StoryObj<typeof IconButton>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{sizes.map(size => (
|
||||
<IconButton key={size} size={size} aria-label="Settings">
|
||||
<GearIcon />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{variants.map(variant => (
|
||||
<IconButton key={variant} variant={variant} aria-label="Settings">
|
||||
<GearIcon />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Colors: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
{variants.map(variant => (
|
||||
<div key={variant} className="flex items-center gap-3">
|
||||
{colors.map(color => (
|
||||
<IconButton key={color} variant={variant} color={color} aria-label="Settings">
|
||||
<GearIcon />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const HighContrast: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{colors.map(color => (
|
||||
<IconButton key={color} color={color} highContrast aria-label="Settings">
|
||||
<GearIcon />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
<IconButton loading aria-label="Loading"><GearIcon /></IconButton>
|
||||
<IconButton disabled aria-label="Settings"><GearIcon /></IconButton>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Skeleton: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
<IconButtonSkeleton size={2} />
|
||||
<IconButtonSkeleton size={3} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
48
packages/ui/src/v2/IconButton/IconButton.tsx
Normal file
48
packages/ui/src/v2/IconButton/IconButton.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { SpinnerGapIcon } from "@phosphor-icons/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { VariantProps } from "tailwind-variants/lite";
|
||||
|
||||
import { iconButton } from "./variants";
|
||||
|
||||
export type IconButtonProps
|
||||
= Omit<ComponentProps<"button">, "color">
|
||||
& VariantProps<typeof iconButton>
|
||||
& {
|
||||
// Shows a spinner in place of the icon and disables the button.
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
// Square, icon-only action (Radix "IconButton"). The single icon is passed as
|
||||
// children; always provide an `aria-label`. See contrib/claude/ui.md.
|
||||
export function IconButton(props: IconButtonProps) {
|
||||
const {
|
||||
size, variant, color, highContrast, className,
|
||||
loading = false, disabled, type = "button", children, ...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={iconButton({ size, variant, color, highContrast, className })}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <SpinnerGapIcon className="animate-spin" aria-hidden /> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
28
packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx
Normal file
28
packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import type { VariantProps } from "tailwind-variants/lite";
|
||||
|
||||
import { iconButtonSkeleton } from "./variants";
|
||||
|
||||
export type IconButtonSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof iconButtonSkeleton>;
|
||||
|
||||
// Loading placeholder paired with IconButton: a square pulse block matching its
|
||||
// size and radius.
|
||||
export function IconButtonSkeleton(props: IconButtonSkeletonProps) {
|
||||
const { size, className, ...rest } = props;
|
||||
|
||||
return <span aria-hidden className={iconButtonSkeleton({ size, className })} {...rest} />;
|
||||
}
|
||||
134
packages/ui/src/v2/IconButton/variants.ts
Normal file
134
packages/ui/src/v2/IconButton/variants.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { tv } from "tailwind-variants/lite";
|
||||
|
||||
// Square, icon-only sibling of Button (Radix "IconButton"). The variant × color
|
||||
// surface treatment mirrors Button's matrix; only the sizing differs (square,
|
||||
// no text padding).
|
||||
export const iconButton = tv({
|
||||
base: [
|
||||
"inline-flex shrink-0 items-center justify-center border border-transparent",
|
||||
"cursor-pointer outline-none transition-colors select-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
],
|
||||
variants: {
|
||||
// Corner radius is bound to size (per the Figma, radius is global/theme,
|
||||
// not a per-instance prop).
|
||||
size: {
|
||||
1: "size-6 rounded-2 [&_svg]:size-4",
|
||||
2: "size-8 rounded-2 [&_svg]:size-4",
|
||||
3: "size-10 rounded-3 [&_svg]:size-5",
|
||||
4: "size-12 rounded-3 [&_svg]:size-5",
|
||||
},
|
||||
variant: {
|
||||
classic: "",
|
||||
solid: "",
|
||||
soft: "",
|
||||
surface: "",
|
||||
outline: "",
|
||||
ghost: "",
|
||||
},
|
||||
color: {
|
||||
neutral: "",
|
||||
gold: "",
|
||||
red: "",
|
||||
green: "",
|
||||
amber: "",
|
||||
sky: "",
|
||||
},
|
||||
highContrast: {
|
||||
true: "",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// solid / classic
|
||||
{ variant: ["solid", "classic"], color: "neutral", class: "bg-sand-9 text-white hover:bg-sand-10" },
|
||||
{ variant: ["solid", "classic"], color: "gold", class: "bg-gold-9 text-white hover:bg-gold-10" },
|
||||
{ variant: ["solid", "classic"], color: "red", class: "bg-red-9 text-white hover:bg-red-10" },
|
||||
{ variant: ["solid", "classic"], color: "green", class: "bg-green-9 text-white hover:bg-green-10" },
|
||||
{ variant: ["solid", "classic"], color: "amber", class: "bg-amber-9 text-amber-12 hover:bg-amber-10" },
|
||||
{ variant: ["solid", "classic"], color: "sky", class: "bg-sky-9 text-sky-12 hover:bg-sky-10" },
|
||||
{ variant: "classic", class: "shadow-2" },
|
||||
{ variant: ["solid", "classic"], color: "neutral", highContrast: true, class: "bg-sand-12 text-sand-1 hover:bg-sand-12" },
|
||||
{ variant: ["solid", "classic"], color: "gold", highContrast: true, class: "bg-gold-12 text-gold-1 hover:bg-gold-12" },
|
||||
{ variant: ["solid", "classic"], color: "red", highContrast: true, class: "bg-red-12 text-red-1 hover:bg-red-12" },
|
||||
{ variant: ["solid", "classic"], color: "green", highContrast: true, class: "bg-green-12 text-green-1 hover:bg-green-12" },
|
||||
{ variant: ["solid", "classic"], color: "amber", highContrast: true, class: "bg-amber-12 text-amber-1 hover:bg-amber-12" },
|
||||
{ variant: ["solid", "classic"], color: "sky", highContrast: true, class: "bg-sky-12 text-sky-1 hover:bg-sky-12" },
|
||||
|
||||
// soft
|
||||
{ variant: "soft", color: "neutral", class: "bg-sand-3 text-sand-11 hover:bg-sand-4" },
|
||||
{ variant: "soft", color: "gold", class: "bg-gold-3 text-gold-11 hover:bg-gold-4" },
|
||||
{ variant: "soft", color: "red", class: "bg-red-3 text-red-11 hover:bg-red-4" },
|
||||
{ variant: "soft", color: "green", class: "bg-green-3 text-green-11 hover:bg-green-4" },
|
||||
{ variant: "soft", color: "amber", class: "bg-amber-3 text-amber-11 hover:bg-amber-4" },
|
||||
{ variant: "soft", color: "sky", class: "bg-sky-3 text-sky-11 hover:bg-sky-4" },
|
||||
|
||||
// surface
|
||||
{ variant: "surface", color: "neutral", class: "bg-sand-2 text-sand-11 border-sand-6 hover:bg-sand-3" },
|
||||
{ variant: "surface", color: "gold", class: "bg-gold-2 text-gold-11 border-gold-6 hover:bg-gold-3" },
|
||||
{ variant: "surface", color: "red", class: "bg-red-2 text-red-11 border-red-6 hover:bg-red-3" },
|
||||
{ variant: "surface", color: "green", class: "bg-green-2 text-green-11 border-green-6 hover:bg-green-3" },
|
||||
{ variant: "surface", color: "amber", class: "bg-amber-2 text-amber-11 border-amber-6 hover:bg-amber-3" },
|
||||
{ variant: "surface", color: "sky", class: "bg-sky-2 text-sky-11 border-sky-6 hover:bg-sky-3" },
|
||||
|
||||
// outline
|
||||
{ variant: "outline", color: "neutral", class: "text-sand-11 border-sand-7 hover:bg-sand-3" },
|
||||
{ variant: "outline", color: "gold", class: "text-gold-11 border-gold-7 hover:bg-gold-3" },
|
||||
{ variant: "outline", color: "red", class: "text-red-11 border-red-7 hover:bg-red-3" },
|
||||
{ variant: "outline", color: "green", class: "text-green-11 border-green-7 hover:bg-green-3" },
|
||||
{ variant: "outline", color: "amber", class: "text-amber-11 border-amber-7 hover:bg-amber-3" },
|
||||
{ variant: "outline", color: "sky", class: "text-sky-11 border-sky-7 hover:bg-sky-3" },
|
||||
|
||||
// ghost
|
||||
{ variant: "ghost", color: "neutral", class: "text-sand-11 hover:bg-sand-3" },
|
||||
{ variant: "ghost", color: "gold", class: "text-gold-11 hover:bg-gold-3" },
|
||||
{ variant: "ghost", color: "red", class: "text-red-11 hover:bg-red-3" },
|
||||
{ variant: "ghost", color: "green", class: "text-green-11 hover:bg-green-3" },
|
||||
{ variant: "ghost", color: "amber", class: "text-amber-11 hover:bg-amber-3" },
|
||||
{ variant: "ghost", color: "sky", class: "text-sky-11 hover:bg-sky-3" },
|
||||
|
||||
// high-contrast text for the tinted variants
|
||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "neutral", highContrast: true, class: "text-sand-12" },
|
||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "gold", highContrast: true, class: "text-gold-12" },
|
||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "red", highContrast: true, class: "text-red-12" },
|
||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "green", highContrast: true, class: "text-green-12" },
|
||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "amber", highContrast: true, class: "text-amber-12" },
|
||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "sky", highContrast: true, class: "text-sky-12" },
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 2,
|
||||
variant: "solid",
|
||||
color: "gold",
|
||||
highContrast: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const iconButtonSkeleton = tv({
|
||||
base: "inline-block shrink-0 animate-pulse bg-sand-3 align-middle",
|
||||
variants: {
|
||||
size: {
|
||||
1: "size-6 rounded-2",
|
||||
2: "size-8 rounded-2",
|
||||
3: "size-10 rounded-3",
|
||||
4: "size-12 rounded-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 2,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user