diff --git a/packages/ui/src/v2/IconButton/IconButton.stories.tsx b/packages/ui/src/v2/IconButton/IconButton.stories.tsx new file mode 100644 index 000000000..ad118556a --- /dev/null +++ b/packages/ui/src/v2/IconButton/IconButton.stories.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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": , + "size": 2, + "variant": "solid", + "color": "gold", + "highContrast": false, + "loading": false, + "disabled": false, + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Sizes: Story = { + render: () => ( +
+ {sizes.map(size => ( + + + + ))} +
+ ), +}; + +export const Variants: Story = { + render: () => ( +
+ {variants.map(variant => ( + + + + ))} +
+ ), +}; + +export const Colors: Story = { + render: () => ( +
+ {variants.map(variant => ( +
+ {colors.map(color => ( + + + + ))} +
+ ))} +
+ ), +}; + +export const HighContrast: Story = { + render: () => ( +
+ {colors.map(color => ( + + + + ))} +
+ ), +}; + +export const States: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const Skeleton: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/packages/ui/src/v2/IconButton/IconButton.tsx b/packages/ui/src/v2/IconButton/IconButton.tsx new file mode 100644 index 000000000..48ca14172 --- /dev/null +++ b/packages/ui/src/v2/IconButton/IconButton.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, "color"> + & VariantProps + & { + // 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 ( + + ); +} diff --git a/packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx b/packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx new file mode 100644 index 000000000..d38f7d8eb --- /dev/null +++ b/packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, "children"> & VariantProps; + +// 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 ; +} diff --git a/packages/ui/src/v2/IconButton/variants.ts b/packages/ui/src/v2/IconButton/variants.ts new file mode 100644 index 000000000..054d21510 --- /dev/null +++ b/packages/ui/src/v2/IconButton/variants.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, + }, +});