Skip to content

Commit

Permalink
Create IconButton component(s)
Browse files Browse the repository at this point in the history
  • Loading branch information
psirenny authored May 29, 2024
1 parent ce0180c commit 83c1a54
Show file tree
Hide file tree
Showing 11 changed files with 274 additions and 31 deletions.
6 changes: 6 additions & 0 deletions .changeset/afraid-dancers-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@spear-ai/storybook": minor
"@spear-ai/ui": minor
---

Created IconButton component(s).
5 changes: 5 additions & 0 deletions .changeset/good-seas-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@spear-ai/ui": minor
---

Made Spinner component more composable by making it inherit the text color.
5 changes: 5 additions & 0 deletions .changeset/three-toys-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@spear-ai/ui": minor
---

Changed Storybook Spinner color from `step-9` to `step-11` for consistency with loading states inside of UI components.
2 changes: 1 addition & 1 deletion packages/storybook/src/components/dialog/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const PreviewDialog = (properties: {
role={isAlert ? "alertdialog" : undefined}
>
{hasCloseButton ? (
<DialogCloseIconButton className="end-3 top-3">
<DialogCloseIconButton>
<DialogCloseIconButtonIcon />
</DialogCloseIconButton>
) : null}
Expand Down
121 changes: 121 additions & 0 deletions packages/storybook/src/components/icon-button/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { ArrowTopRightIcon, PlusIcon } from "@radix-ui/react-icons";
import {
IconButton,
IconButtonIcon,
IconButtonSpinner,
LinkIconButton,
} from "@spear-ai/ui/components/icon-button";
import type { Meta, StoryObj } from "@storybook/react";
import { useIntl } from "react-intl";

const PreviewIconButton = (properties: {
color: "negative" | "neutral" | "positive" | "primary" | "x-negative" | "x-positive";
hasIcon: boolean;
isCircle: boolean;
isDisabled: boolean;
isLink: boolean;
isLoading: boolean;
isSkeleton: boolean;
size: "size-7" | "size-8" | "size-9";
variant: "ghost" | "outline" | "soft" | "solid";
}) => {
const { color, hasIcon, isCircle, isDisabled, isLink, isLoading, isSkeleton, size, variant } = properties;
const rounded = isCircle ? "rounded-full" : "";
const intl = useIntl();

if (isLink) {
return (
<LinkIconButton
aria-label={intl.formatMessage({ defaultMessage: "Fusce dignissim", id: "uEMDBb" })}
className={`${size} ${rounded}`}
color={color}
href="https://ui.spear.ai"
isDisabled={isDisabled}
isLoading={isLoading}
isSkeleton={isSkeleton}
rel="nofollow"
target="_blank"
variant={variant}
>
{isLoading ? <IconButtonSpinner /> : null}
{!isLoading && hasIcon ? (
<IconButtonIcon asChild>
<ArrowTopRightIcon className="rtl:-scale-x-100" />
</IconButtonIcon>
) : null}
</LinkIconButton>
);
}

return (
<IconButton
aria-label={intl.formatMessage({ defaultMessage: "Fusce dignissim", id: "uEMDBb" })}
className={`${size} ${rounded}`}
color={color}
isDisabled={isDisabled}
isLoading={isLoading}
isSkeleton={isSkeleton}
variant={variant}
>
{isLoading ? <IconButtonSpinner /> : null}
{!isLoading && hasIcon ? (
<IconButtonIcon asChild>
<PlusIcon />
</IconButtonIcon>
) : null}
</IconButton>
);
};

const meta = {
argTypes: {
variant: { control: { type: "select" }, options: ["solid"] },
},
component: PreviewIconButton,
} satisfies Meta<typeof PreviewIconButton>;

type Story = StoryObj<typeof meta>;

export const Standard: Story = {
args: {
color: "neutral",
hasIcon: true,
isCircle: false,
isDisabled: false,
isLink: false,
isLoading: false,
isSkeleton: false,
size: "size-8",
variant: "soft",
},
argTypes: {
color: {
control: {
type: "select",
},
options: ["neutral", "primary", "negative", "x-negative", "positive", "x-positive"],
},
size: {
control: {
labels: {
"size-7": "7",
"size-8": "8",
"size-9": "9",
},
type: "select",
},
options: ["size-7", "size-8", "size-9"],
},
variant: {
control: {
type: "select",
},
options: ["ghost", "outline", "soft", "solid"],
},
},
parameters: {
layout: "centered",
},
};

export default meta;
3 changes: 2 additions & 1 deletion packages/storybook/src/components/spinner/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { Meta, StoryObj } from "@storybook/react";

const PreviewSpinner = (properties: { isPrimary: boolean }) => {
const { isPrimary } = properties;
return <Spinner isPrimary={isPrimary} />;
const color = isPrimary ? "text-primary-11" : "text-neutral-11";
return <Spinner className={color} />;
};

const meta = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const PreviewToggleButton = (properties: {
isDisabled: boolean;
isReady: boolean;
isVanishing: boolean;
style: "Ghost";
variant: "ghost";
}) => {
const { isAlternating, isDisabled, isReady, isVanishing } = properties;
const intl = useIntl();
Expand Down Expand Up @@ -107,7 +107,7 @@ const PreviewToggleButton = (properties: {

const meta = {
argTypes: {
style: { control: { type: "select" }, options: ["Ghost"] },
variant: { control: { type: "select" }, options: ["ghost"] },
},
component: PreviewToggleButton,
} satisfies Meta<typeof PreviewToggleButton>;
Expand All @@ -120,7 +120,7 @@ export const Activating: Story = {
isDisabled: false,
isReady: true,
isVanishing: false,
style: "Ghost",
variant: "ghost",
},
parameters: {
controls: {
Expand All @@ -137,7 +137,7 @@ export const Alternating: Story = {
isDisabled: false,
isReady: true,
isVanishing: false,
style: "Ghost",
variant: "ghost",
},
parameters: {
controls: {
Expand All @@ -154,7 +154,7 @@ export const Vanishing: Story = {
isDisabled: false,
isReady: true,
isVanishing: false,
style: "Ghost",
variant: "ghost",
},
parameters: {
controls: {
Expand Down
49 changes: 30 additions & 19 deletions packages/ui/src/components/dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Cross1Icon } from "@radix-ui/react-icons";
import { Slot } from "@radix-ui/react-slot";
import { ComponentPropsWithoutRef, ElementRef, forwardRef, SVGAttributes, useContext } from "react";
import { ComponentPropsWithoutRef, ElementRef, forwardRef, useContext } from "react";
import {
Button as ButtonPrimitive,
Dialog as DialogPrimitive,
Expand All @@ -9,6 +9,7 @@ import {
ModalOverlay as ModalOverlayPrimitive,
OverlayTriggerStateContext,
} from "react-aria-components";
import { IconButton, IconButtonIcon } from "@/components/icon-button";
import { cx } from "@/helpers/cx";

export const DialogModalOverlay = forwardRef<
Expand Down Expand Up @@ -49,35 +50,45 @@ Dialog.displayName = "Dialog";

export const DialogCloseButtonPrimitive = forwardRef<
ElementRef<typeof ButtonPrimitive>,
ComponentPropsWithoutRef<typeof ButtonPrimitive> & { className?: string | undefined }
>((properties, reference) => {
ComponentPropsWithoutRef<typeof ButtonPrimitive> & { asChild?: boolean | undefined }
>(({ asChild = false, ...properties }, reference) => {
const Component = asChild ? Slot : ButtonPrimitive;
// eslint-disable-next-line @typescript-eslint/unbound-method
const { close } = useContext(OverlayTriggerStateContext);
return <ButtonPrimitive onPress={close} {...properties} ref={reference} />;
// @ts-expect-error the Slot component’s type definition is missing
return <Component onPress={close} {...properties} ref={reference} />;
});

DialogCloseButtonPrimitive.displayName = "DialogCloseButtonPrimitive";

export const DialogCloseIconButton = forwardRef<
ElementRef<typeof DialogCloseButtonPrimitive>,
ComponentPropsWithoutRef<typeof DialogCloseButtonPrimitive> & { className?: string | undefined }
>(({ className, ...properties }, reference) => {
const mergedClassName = cx(
"text-neutral-a-11 hover:bg-neutral-a-4 focus-visible:bg-neutral-a-5 absolute hidden size-7 cursor-default rounded-md p-1.5 sm:block",
className,
ElementRef<typeof IconButton>,
ComponentPropsWithoutRef<typeof IconButton>
>(({ className, variant = "ghost", ...properties }, reference) => {
const mergedClassName = cx("absolute end-3 top-3 hidden size-7 sm:block", className);
// eslint-disable-next-line @typescript-eslint/unbound-method
const { close } = useContext(OverlayTriggerStateContext);
return (
<IconButton
className={mergedClassName}
onPress={close}
variant={variant}
{...properties}
ref={reference}
/>
);
return <DialogCloseButtonPrimitive className={mergedClassName} {...properties} ref={reference} />;
});

DialogCloseIconButton.displayName = "DialogCloseIconButton";

export const DialogCloseIconButtonIcon = forwardRef<
SVGSVGElement,
SVGAttributes<SVGElement> & { asChild?: boolean | undefined }
>(({ asChild = false, className, ...properties }, reference) => {
const Component = asChild ? Slot : Cross1Icon;
const mergedClassName = cx("h-full w-full", className);
// @ts-expect-error the Slot component’s type definition doesn’t play nice with SVGs
return <Component aria-hidden className={mergedClassName} ref={reference} {...properties} />;
});
ElementRef<typeof IconButtonIcon>,
ComponentPropsWithoutRef<typeof IconButtonIcon>
>((properties, reference) => (
<IconButtonIcon aria-hidden asChild ref={reference} {...properties}>
<Cross1Icon />
</IconButtonIcon>
));

DialogCloseIconButtonIcon.displayName = "DialogCloseIconButtonIcon";

Expand Down
97 changes: 97 additions & 0 deletions packages/ui/src/components/icon-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Slot } from "@radix-ui/react-slot";
import { ComponentPropsWithoutRef, ElementRef, forwardRef, SVGAttributes } from "react";
import { Button as ButtonPrimitive, Link as LinkPrimitive } from "react-aria-components";
import { Spinner } from "@/components/spinner";
import { cx } from "@/helpers/cx";

export const IconButton = forwardRef<
ElementRef<typeof ButtonPrimitive>,
ComponentPropsWithoutRef<typeof ButtonPrimitive> & {
className?: string | undefined;
color?: "negative" | "neutral" | "positive" | "primary" | "x-negative" | "x-positive" | undefined;
isLoading?: boolean | undefined;
isPrimary?: boolean | undefined;
isSkeleton?: boolean | undefined;
variant?: "ghost" | "outline" | "soft" | "solid";
}
>(
(
{ className, color = "neutral", isLoading = false, isSkeleton = false, variant = "soft", ...properties },
reference,
) => {
const mergedClassName = cx(
"text-neutral-a-11 data-[variant='solid']:bg-neutral-9 data-[variant='solid']:data-[color='primary']:bg-primary-9 data-[variant='solid']:text-neutral-contrast data-[variant='solid']:data-[color='primary']:text-primary-contrast data-[color='primary']:text-primary-a-11 data-[variant='soft']:bg-neutral-a-3 data-[variant='soft']:data-[color='primary']:bg-primary-3 hover:bg-neutral-a-4 hover:data-[variant='solid']:bg-neutral-10 data-[color='primary']:hover:bg-primary-a-4 hover:data-[variant='solid']:data-[color='primary']:bg-primary-10 pressed:data-[color='primary']:bg-primary-a-5 pressed:bg-neutral-a-5 data-[variant='outline']:outline-neutral-a-7 data-[variant='outline']:focus-visible:outline-primary-a-7 data-[variant='outline']:data-[color='primary']:outline-primary-a-7 data-[color='negative']:hover:bg-negative-a-4 data-[color='negative']:text-negative-a-11 data-[variant='outline']:data-[color='negative']:outline-negative-a-7 data-[variant='soft']:data-[color='negative']:bg-negative-a-3 data-[variant='solid']:data-[color='negative']:text-negative-contrast data-[variant='solid']:data-[color='negative']:bg-negative-9 hover:data-[variant='solid']:data-[color='negative']:bg-negative-10 pressed:data-[color='negative']:bg-negative-a-5 data-[color='x-negative']:hover:bg-x-negative-a-4 data-[color='x-negative']:text-x-negative-a-11 data-[variant='outline']:data-[color='x-negative']:outline-x-negative-a-7 data-[variant='soft']:data-[color='x-negative']:bg-x-negative-a-3 data-[variant='solid']:data-[color='x-negative']:text-x-negative-contrast data-[variant='solid']:data-[color='x-negative']:bg-x-negative-9 hover:data-[variant='solid']:data-[color='x-negative']:bg-x-negative-10 pressed:data-[color='x-negative']:bg-x-negative-a-5 data-[color='positive']:hover:bg-positive-a-4 data-[color='positive']:text-positive-a-11 data-[variant='outline']:data-[color='positive']:outline-positive-a-7 data-[variant='soft']:data-[color='positive']:bg-positive-a-3 data-[variant='solid']:data-[color='positive']:text-positive-contrast data-[variant='solid']:data-[color='positive']:bg-positive-9 hover:data-[variant='solid']:data-[color='positive']:bg-positive-10 pressed:data-[color='positive']:bg-positive-a-5 data-[color='x-positive']:hover:bg-x-positive-a-4 data-[color='x-positive']:text-x-positive-a-11 data-[variant='outline']:data-[color='x-positive']:outline-x-positive-a-7 data-[variant='soft']:data-[color='x-positive']:bg-x-positive-a-3 data-[variant='solid']:data-[color='x-positive']:text-x-positive-contrast data-[variant='solid']:data-[color='x-positive']:bg-x-positive-9 hover:data-[variant='solid']:data-[color='x-positive']:bg-x-positive-10 pressed:data-[color='x-positive']:bg-x-positive-a-5 disabled:text-neutral-a-8 data-[variant='outline']:disabled:outline-neutral-a-6 data-[variant='solid']:disabled:bg-neutral-a-3 size-8 cursor-default rounded-md p-1.5 shadow-sm data-[is-skeleton=true]:pointer-events-none data-[is-skeleton=true]:animate-pulse data-[variant='outline']:outline data-[variant='outline']:outline-offset-0",
className,
);
return (
<ButtonPrimitive
className={mergedClassName}
data-color={color}
data-is-loading={isLoading}
data-is-skeleton={isSkeleton}
data-variant={variant}
{...properties}
ref={reference}
/>
);
},
);

IconButton.displayName = "IconButton";

export const LinkIconButton = forwardRef<
ElementRef<typeof LinkPrimitive>,
ComponentPropsWithoutRef<typeof LinkPrimitive> & {
className?: string | undefined;
color?: "negative" | "neutral" | "positive" | "primary" | "x-negative" | "x-positive" | undefined;
isLoading?: boolean | undefined;
isPrimary?: boolean | undefined;
isSkeleton?: boolean | undefined;
variant?: "ghost" | "outline" | "soft" | "solid";
}
>(
(
{ className, color = "neutral", isLoading = false, isSkeleton = false, variant = "soft", ...properties },
reference,
) => {
const mergedClassName = cx(
"text-neutral-a-11 data-[variant='solid']:bg-neutral-9 data-[variant='solid']:data-[color='primary']:bg-primary-9 data-[variant='solid']:text-neutral-contrast data-[variant='solid']:data-[color='primary']:text-primary-contrast data-[color='primary']:text-primary-a-11 data-[variant='soft']:bg-neutral-a-3 data-[variant='soft']:data-[color='primary']:bg-primary-3 hover:bg-neutral-a-4 hover:data-[variant='solid']:bg-neutral-10 data-[color='primary']:hover:bg-primary-a-4 hover:data-[variant='solid']:data-[color='primary']:bg-primary-10 pressed:data-[color='primary']:bg-primary-a-5 pressed:bg-neutral-a-5 data-[variant='outline']:outline-neutral-a-7 data-[variant='outline']:focus-visible:outline-primary-a-7 data-[variant='outline']:data-[color='primary']:outline-primary-a-7 data-[color='negative']:hover:bg-negative-a-4 data-[color='negative']:text-negative-a-11 data-[variant='outline']:data-[color='negative']:outline-negative-a-7 data-[variant='soft']:data-[color='negative']:bg-negative-a-3 data-[variant='solid']:data-[color='negative']:text-negative-contrast data-[variant='solid']:data-[color='negative']:bg-negative-9 hover:data-[variant='solid']:data-[color='negative']:bg-negative-10 pressed:data-[color='negative']:bg-negative-a-5 data-[color='x-negative']:hover:bg-x-negative-a-4 data-[color='x-negative']:text-x-negative-a-11 data-[variant='outline']:data-[color='x-negative']:outline-x-negative-a-7 data-[variant='soft']:data-[color='x-negative']:bg-x-negative-a-3 data-[variant='solid']:data-[color='x-negative']:text-x-negative-contrast data-[variant='solid']:data-[color='x-negative']:bg-x-negative-9 hover:data-[variant='solid']:data-[color='x-negative']:bg-x-negative-10 pressed:data-[color='x-negative']:bg-x-negative-a-5 data-[color='positive']:hover:bg-positive-a-4 data-[color='positive']:text-positive-a-11 data-[variant='outline']:data-[color='positive']:outline-positive-a-7 data-[variant='soft']:data-[color='positive']:bg-positive-a-3 data-[variant='solid']:data-[color='positive']:text-positive-contrast data-[variant='solid']:data-[color='positive']:bg-positive-9 hover:data-[variant='solid']:data-[color='positive']:bg-positive-10 pressed:data-[color='positive']:bg-positive-a-5 data-[color='x-positive']:hover:bg-x-positive-a-4 data-[color='x-positive']:text-x-positive-a-11 data-[variant='outline']:data-[color='x-positive']:outline-x-positive-a-7 data-[variant='soft']:data-[color='x-positive']:bg-x-positive-a-3 data-[variant='solid']:data-[color='x-positive']:text-x-positive-contrast data-[variant='solid']:data-[color='x-positive']:bg-x-positive-9 hover:data-[variant='solid']:data-[color='x-positive']:bg-x-positive-10 pressed:data-[color='x-positive']:bg-x-positive-a-5 disabled:text-neutral-a-8 data-[variant='outline']:disabled:outline-neutral-a-6 data-[variant='solid']:disabled:bg-neutral-a-3 size-8 cursor-default rounded-md p-1.5 shadow-sm data-[is-skeleton=true]:pointer-events-none data-[is-skeleton=true]:animate-pulse data-[variant='outline']:outline data-[variant='outline']:outline-offset-0",
className,
);
return (
<LinkPrimitive
className={mergedClassName}
data-color={color}
data-is-loading={isLoading}
data-is-skeleton={isSkeleton}
data-variant={variant}
{...properties}
ref={reference}
/>
);
},
);

LinkIconButton.displayName = "LinkIconButton";

export const IconButtonIcon = forwardRef<
SVGSVGElement,
SVGAttributes<SVGElement> & { asChild?: boolean | undefined }
>(({ asChild = false, className, ...properties }, reference) => {
const Component = asChild ? Slot : "svg";
const mergedClassName = cx("h-full w-full", className);
// @ts-expect-error the Slot component’s type definition doesn’t play nice with SVGs
return <Component aria-hidden className={mergedClassName} ref={reference} {...properties} />;
});

IconButtonIcon.displayName = "IconButtonIcon";

export const IconButtonSpinner = forwardRef<
ElementRef<typeof Spinner>,
ComponentPropsWithoutRef<typeof Spinner>
>(({ className, ...properties }, reference) => {
const mergedClassName = cx("size-full", className);
return <Spinner className={mergedClassName} {...properties} ref={reference} />;
});

IconButtonSpinner.displayName = "IconButtonSpinner";
5 changes: 1 addition & 4 deletions packages/ui/src/components/spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ export const Spinner = forwardRef<
SVGSVGElement,
SVGAttributes<SVGElement> & { isPrimary?: boolean | undefined }
>(({ className, isPrimary = false, ...properties }, reference) => {
const mergedClassName = cx(
"text-neutral-9 data-[is-primary=true]:text-primary-9 group size-5 animate-spin",
className,
);
const mergedClassName = cx("group size-5 animate-spin text-inherit", className);

return (
<svg
Expand Down
Loading

0 comments on commit 83c1a54

Please sign in to comment.