Skip to content

FCT-1517: Nimbus - Icon component #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 13, 2025
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
86 changes: 86 additions & 0 deletions packages/nimbus/src/components/icon/icon.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
id: Components-Icon
title: Icon
description: displays icon components
documentState: InitialDraft
order: 999
menu:
- Components
- Media
- Icon
tags:
- component
---

# Icon

displays icon components

## Examples

Below are examples showcasing the different ways to use the `Icon` component.

### Basic Usage

The most basic way to use the `Icon` component is by passing the desired icon as a child. The icon will inherit the current font-size and -color by default.

```jsx-live
const App = () => (
<Icon>
<Icons.Bathtub />
</Icon>
);
```

### Using the `as` prop

As a shorthand, you can pass the icon component directly to the `as` prop.

```jsx-live
const App = () => <Icon as={Icons.Bathtub} />;
```

### Predefined Sizes

The `Icon` component comes with a set of predefined sizes that can be applied using the `size` prop.

```jsx-live
const App = () => (
<Stack direction="row" gap="400" alignItems="center">
<Icon as={Icons.Bathtub} size="2xs" />
<Icon as={Icons.Bathtub} size="xs" />
<Icon as={Icons.Bathtub} size="sm" />
<Icon as={Icons.Bathtub} size="md" />
<Icon as={Icons.Bathtub} size="lg" />
<Icon as={Icons.Bathtub} size="xl" />
</Stack>
);
```

### Custom Size

You can apply a custom size to the `Icon` using the `boxSize` style prop.

```jsx-live
const App = () => <Icon as={Icons.Bathtub} boxSize="3200" />;
```

### Custom Color

A custom color can be applied using the `color` style prop.

```jsx-live
const App = () => (
<Stack direction="row">
<Icon as={Icons.Bathtub} size="xl" color="primary.11" />
<Icon as={Icons.Bathtub} size="xl" color="info.11" />
<Icon as={Icons.Bathtub} size="xl" color="positive.11" />
<Icon as={Icons.Bathtub} size="xl" color="warning.11" />
<Icon as={Icons.Bathtub} size="xl" color="critical.11" />
</Stack>
);
```

## Props

<PropTable id="Icon" />
26 changes: 26 additions & 0 deletions packages/nimbus/src/components/icon/icon.recipe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineRecipe } from "@chakra-ui/react";

/**
* Recipe configuration for the Icon component.
* Defines the styling variants and base styles using Chakra UI's recipe system.
*/
export const iconRecipe = defineRecipe({
className: "nimbus-icon",
base: {
display: "inline-block",
},

variants: {
/**
* allows applying a predefined size to the icon
*/
size: {
"2xs": { boxSize: "600" },
xs: { boxSize: "800" },
sm: { boxSize: "900" },
md: { boxSize: "1000" },
lg: { boxSize: "1200" },
xl: { boxSize: "1400" },
},
},
});
32 changes: 32 additions & 0 deletions packages/nimbus/src/components/icon/icon.slots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
type HTMLChakraProps,
type RecipeProps,
type UnstyledProp,
createRecipeContext,
} from "@chakra-ui/react";

import { iconRecipe } from "./icon.recipe";

/**
* Base recipe props interface that combines Chakra UI's recipe props
* with the unstyled prop option for the svg element.
*/
interface IconRecipeProps extends RecipeProps<"svg">, UnstyledProp {}

/**
* Root props interface that extends Chakra's HTML props with our recipe props.
* This creates a complete set of props for the root element, combining
* HTML attributes, Chakra's styling system, and our custom recipe props.
*/
export interface IconRootSlotProps
extends HTMLChakraProps<"svg", IconRecipeProps> {}

const { withContext } = createRecipeContext({ recipe: iconRecipe });

/**
* Root component that provides the styling context for the Icon component.
* Uses Chakra UI's recipe context system for consistent styling across instances.
*/
export const IconRootSlot = withContext<SVGSVGElement, IconRootSlotProps>(
"svg"
);
170 changes: 170 additions & 0 deletions packages/nimbus/src/components/icon/icon.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Icon } from "./icon";
import { Stack } from "./../stack";
import { Bathtub } from "@commercetools/nimbus-icons";
import type { IconProps } from "./icon.types";
import { within, expect } from "@storybook/test";

/**
* Storybook metadata configuration
* - title: determines the location in the sidebar
* - component: references the component being documented
*/
const meta: Meta<typeof Icon> = {
title: "components/Icon",
component: Icon,
};

export default meta;

const sizes: IconProps["size"][] = ["2xs", "xs", "sm", "md", "lg", "xl"];

/**
* Story type for TypeScript support
* StoryObj provides type checking for our story configurations
*/
type Story = StoryObj<typeof Icon>;

/**
* Icon is the wrapper component, the actual icon is passed as a child. Default size is the currently inherited font-size, same applies to the color.
*/
export const Base: Story = {
args: {
children: <Bathtub />,
// @ts-expect-error - data-testid is not a standard prop on IconProps but is used for testing
"data-testid": "icon-base",
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const icon = canvas.getByTestId("icon-base");

await step("Icon is rendered", async () => {
await expect(icon).toBeInTheDocument();
});

await step("Icon is an svg element", async () => {
await expect(icon.tagName).toBe("svg");
});
},
};

/**
* As shorthand, the icon can be passed as a prop via the `as` property.
*/
export const ViaAsProperty: Story = {
args: {
as: Bathtub,
// @ts-expect-error - data-testid is not a standard prop on IconProps but is used for testing
"data-testid": "icon-as-prop",
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const icon = canvas.getByTestId("icon-as-prop");

await step("Icon is rendered when using as prop", async () => {
await expect(icon).toBeInTheDocument();
});

await step("Icon is an svg element", async () => {
await expect(icon.tagName).toBe("svg");
});
},
};

/**
* predefined sizes from the recipe, configurable via the `size` property
*/
export const Sizes: Story = {
args: {
as: Bathtub,
// @ts-expect-error - data-testid is not a standard prop on IconProps but is used for testing
"data-testid": "icon-sizes",
},
render: (args) => {
return (
<Stack direction="row" gap="400" alignItems="center">
{sizes.map((size) => (
<Icon key={JSON.stringify(size)} {...args} size={size} />
))}
</Stack>
);
},
};

/**
* A custom size can be set via the style-property `boxSize` and a size-token.
*/
export const CustomSize: Story = {
args: {
as: Bathtub,
boxSize: "240px",
// @ts-expect-error - data-testid is not a standard prop on IconProps but is used for testing
"data-testid": "icon-custom-size",
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const icon = canvas.getByTestId("icon-custom-size");

await step("Icon with custom size is rendered", async () => {
await expect(icon).toBeInTheDocument();
});

await step("Icon has the correct custom size", async () => {
await expect(icon).toHaveStyle({ width: "240px", height: "240px" });
});
},
};

/**
* A custom color can be set via the style-property `color` and a color-token.
*/
export const CustomColor: Story = {
args: {
as: Bathtub,
size: "xl",
// @ts-expect-error - data-testid is not a standard prop on IconProps but is used for testing
"data-testid": "icon-custom-color",
},
render: (args) => {
return (
<Stack direction="row">
{[
"primary.11",
"info.11",
"positive.11",
"warning.11",
"critical.11",
"deeppink",
].map((colorToken) => (
<Icon
key={colorToken}
color={colorToken}
{...args}
data-testid={`icon-custom-color-${colorToken}`}
/>
))}
</Stack>
);
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const colorTokens = [
"primary.11",
"info.11",
"positive.11",
"warning.11",
"critical.11",
"deeppink",
];

for (const colorToken of colorTokens) {
await step(`Icon with color ${colorToken} is rendered`, async () => {
const icon = canvas.getByTestId(`icon-custom-color-${colorToken}`);
await expect(icon).toBeInTheDocument();
if (colorToken === "deeppink") {
await expect(icon).toHaveStyle({ color: "rgb(255, 20, 147)" });
}
});
}
},
};
15 changes: 15 additions & 0 deletions packages/nimbus/src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { forwardRef } from "react";
import { IconRootSlot } from "./icon.slots";
import type { IconProps } from "./icon.types";

/**
* Icon
* displays icon components
*/
export const Icon = forwardRef<SVGSVGElement, IconProps>(
({ ...props }, ref) => {
return <IconRootSlot ref={ref} asChild={!props.as} {...props} />;
}
);

Icon.displayName = "Icon";
40 changes: 40 additions & 0 deletions packages/nimbus/src/components/icon/icon.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { IconRootSlotProps } from "./icon.slots";
import type { RecipeVariantProps } from "@chakra-ui/react";
import { iconRecipe } from "./icon.recipe";
import type { BoxProps } from "../box";

/**
* Combines the root props with Chakra UI's recipe variant props.
* This allows the component to accept both structural props from Root
* and styling variants from the recipe.
*/
type IconVariantProps = Omit<
IconRootSlotProps,
| keyof React.SVGProps<SVGSVGElement> // excludes the 3 bazillion possible svg-props from the props-table
| "css"
| "unstyled"
| "asChild"
| "recipe"
> &
RecipeVariantProps<typeof iconRecipe>;

/**
* Main props interface for the Icon component.
* Extends IconVariantProps to include both root props and variant props,
* while adding support for React children.
*/
export interface IconProps extends IconVariantProps {
/**
* Accepts only a single child - an icon-component or SVG html-element.
* Alternatively, as shorthand, use the `as` property.
*/
children?: React.ReactNode;
/**
* Colors the icon, accepts a color token from the theme or a custom value
*/
color?: BoxProps["color"];
/**
* Accepts a React component to be rendered as the icon .
*/
as?: BoxProps["as"];
}
2 changes: 2 additions & 0 deletions packages/nimbus/src/components/icon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./icon";
export * from "./icon.types";
2 changes: 2 additions & 0 deletions packages/nimbus/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@ export * from "./badge";
export * from "./alert";
export * from "./toggle-button-group";
export * from "./form-field";
export * from "./icon";
export * from "./tag-group";