Skip to content
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

FCT-808: Create CustomIcon component #2803

Merged
merged 9 commits into from
May 14, 2024
5 changes: 5 additions & 0 deletions .changeset/weak-ligers-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@commercetools-uikit/icons': minor
---

Adds new CustomIcon component for displaying non-ui-kit-svgs.
29 changes: 29 additions & 0 deletions packages/components/icons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,32 @@ const app = () => <LeadingIcon icon={<ExportIcon />} />;
### Where to use

This component can be used wherever it is necessary to display a themed icon.

## Custom Icon

This component is meant to be used whenever consumers need to render an icon which is not part of the [ui-kit icon set](https://uikit.commercetools.com/?path=/story/components-icons--all-icons).

In order to keep visual consistency, we want to keep the available sizes of all icons equal. Bear in mind we would expect custom SVG icons to not contain size attributes so it can be controlled based on the components size attribute.

The component is exported as a separate entry point:

```js
import CustomIcon from '@commercetools-uikit/icons/custom-icon';
```

### Usage

```js
import CustomIcon from '@commercetools-uikit/icons/custom-icon';
import { YourCustomIcon } from './your-custom-icon-directory';

const app = () => <Icon icon={<YourCustomIcon />} />;
```

### Properties

| Props | Type | Required | Values | Default | Description |
| ----------- | --------------------------------------------------------- | :------: | --------------------------------------------------- | ------- | ----------------------------------------------- |
| `size` | `string` | | '10', '20', '30', '40' | '20' | Specifies the icon size |
| `icon` | `union`<br/>Possible values:<br/>`, ReactElement, string` | - | A `ReactNode` or `string` that display a custom SVG | | Icon displayed as a child of this component |
| `hasBorder` | `boolean` | | `true`, `false` | `false` | Specifies whether the element displays a border |
4 changes: 4 additions & 0 deletions packages/components/icons/custom-icon/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "dist/commercetools-uikit-icons-custom-icon.cjs.js",
"module": "dist/commercetools-uikit-icons-custom-icon.esm.js"
}
3 changes: 2 additions & 1 deletion packages/components/icons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
"preconstruct": {
"entrypoints": [
"./index.ts",
"./custom-icon/index.ts",
"./inline-svg/index.ts",
"./leading-icon/index.ts"
]
},
"files": ["dist", "inline-svg", "leading-icon"],
"files": ["dist", "custom-icon", "inline-svg", "leading-icon"],
"scripts": {
"generate-icons": "svgr -d src/generated -- src/svg"
},
Expand Down
40 changes: 40 additions & 0 deletions packages/components/icons/src/custom-icon/custom-icon.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ArrowLeftIcon } from '../generated';
import { screen, render } from '../../../../../test/test-utils';
import rawSvg from '../fixtures/raw-svg';
import CustomIcon, { type TCustomIconProps } from './custom-icon';

type TCustomIconTestProps = Pick<
TCustomIconProps,
'size' | 'icon' | 'hasBorder'
> & {
'data-testid'?: string;
'aria-label': string;
};

const createTestProps = (
custom?: TCustomIconTestProps
): TCustomIconTestProps => ({
size: '20',
icon: <ArrowLeftIcon aria-label="arrowLeft" />,
'aria-label': 'custom-icon-test',
...custom,
});

describe('CustomIcon', () => {
let props: TCustomIconTestProps;
beforeEach(() => {
props = createTestProps();
});
it('should render a react component and pass aria attributes', async () => {
render(<CustomIcon {...props} />);
await screen.findByRole('img', { name: 'custom-icon-test' });
});
it('should pass data attributes', async () => {
render(<CustomIcon {...props} data-testid="test-testid" />);
await screen.findByTestId('test-testid');
});
it('should render a custom svg when svg prop is passed', async () => {
render(<CustomIcon icon={rawSvg.clock} />);
await screen.findByLabelText('custom clock svg');
});
});
30 changes: 30 additions & 0 deletions packages/components/icons/src/custom-icon/custom-icon.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { designTokens } from '@commercetools-uikit/design-system';
import { css } from '@emotion/react';
import { type TCustomIconProps } from './custom-icon';

const sizeMap = {
10: designTokens.spacing50,
20: `calc(${designTokens.spacing50} + ${designTokens.spacing20})`,
30: designTokens.spacing60,
40: designTokens.spacing70,
};

export const getCustomIconStyles = (props: TCustomIconProps) => {
const sizeStyles = {
height: sizeMap[props.size!],
width: sizeMap[props.size!],
};

return css`
display: inline-block;
height: ${sizeStyles.height};
width: ${sizeStyles.width};
border-radius: ${designTokens.borderRadius4};
background-color: ${designTokens.colorTransparent};
box-sizing: border-box;
overflow: hidden;
border: ${props.hasBorder
? `solid ${designTokens.borderWidth1} ${designTokens.colorNeutral90}`
: 'none'};
`;
};
47 changes: 47 additions & 0 deletions packages/components/icons/src/custom-icon/custom-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type ReactElement, cloneElement } from 'react';
import {
filterAriaAttributes,
filterDataAttributes,
} from '@commercetools-uikit/utils';
import InlineSvg from '../inline-svg/inline-svg';
import { getCustomIconStyles } from './custom-icon.styles';

export type TCustomIconProps = {
/**
* Indicates the size of the component
*/
size?: '10' | '20' | '30' | '40';
/**
* Indicates whether the component should display a border
*/
hasBorder?: boolean;
/**
* An <Icon /> component, must pass either an icon prop or an svg prop
*/
icon: ReactElement | string;
};

const defaultProps: Required<Pick<TCustomIconProps, 'size' | 'hasBorder'>> = {
size: '20',
hasBorder: true,
};

const CustomIcon = (props: TCustomIconProps) => (
<div
role="img"
css={getCustomIconStyles(props)}
{...filterDataAttributes(props)}
{...filterAriaAttributes(props)}
>
{typeof props.icon === 'string' ? (
<InlineSvg data={props.icon} size={'scale'} />
) : (
cloneElement(props.icon)
)}
</div>
);

CustomIcon.displayName = 'CustomIcon';
CustomIcon.defaultProps = defaultProps;

export default CustomIcon;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { TCustomIconProps } from './custom-icon';
3 changes: 3 additions & 0 deletions packages/components/icons/src/custom-icon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default } from './custom-icon';

export * from './export-types';
30 changes: 30 additions & 0 deletions packages/components/icons/src/fixtures/CustomIconReact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const SvgCustomIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 35" role="img">
<g clipPath="url(#custom-icon_react_svg__a)">
<path fill="#fff" d="M0 0h35v35H0z" />
<path
fill="#6359FF"
fillRule="evenodd"
d="M16.059 20q-.45 0-.755-.305a1.02 1.02 0 0 1-.304-.754V3.06q0-.45.304-.755.305-.304.755-.304h7.597q.37 0 .662.238.29.239.37.61l.265 1.27h4.87q.45-.001.754.304.305.304.305.754v8.471q0 .45-.305.754a1.02 1.02 0 0 1-.754.305h-5.479q-.37 0-.662-.238a1.05 1.05 0 0 1-.37-.61l-.265-1.27h-5.93v6.353q0 .45-.303.754a1.02 1.02 0 0 1-.755.305"
clipRule="evenodd"
/>
<path
fill="#000"
d="M9.554 22.481-1 37.5h37L22.45 21.833a8.19 8.19 0 0 0-12.896.648"
/>
<path
stroke="#fff"
strokeWidth={1.5}
d="m5 36.5 13.66-4.673c1.782-.61 1.81-3.121.04-3.77l-5.252-1.926c-1.66-.61-1.772-2.916-.177-3.681L21.5 18.5"
/>
</g>
<defs>
<clipPath id="custom-icon_react_svg__a">
<path fill="#fff" d="M0 0h35v35H0z" />
</clipPath>
</defs>
</svg>
);
SvgCustomIcon.displayName = 'SvgCustomIcon';

export default SvgCustomIcon;
15 changes: 15 additions & 0 deletions packages/components/icons/src/icon.story.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import Section from '../../../../docs/.storybook/decorators/section';
import Text from '../../text';
import Readme from '../README.md';
import CustomIcon from './custom-icon';
import CustomReactSvg from './fixtures/CustomIconReact';
import xssFixtures from './fixtures/xss';
import InlineSvg from './inline-svg';
import LeadingIcon from './leading-icon';
Expand Down Expand Up @@ -294,4 +296,17 @@ storiesOf('Components|Icons', module)
/>
</Section>
);
})
.add('CustomIcon', () => {
// storybook knobs escape input data to html, so we cannot use them to send unescaped svg, so setting it here using a boolean
const useString = boolean('use stringified svg for icon', false);
return (
<Section>
<CustomIcon
size={select('size', ['10', '20', '30', '40'], '20')}
hasBorder={boolean('hasBorder', true)}
icon={useString ? svgFixtures.cleanSvg : <CustomReactSvg />}
/>
</Section>
);
});
49 changes: 49 additions & 0 deletions packages/components/icons/src/icons.visualroute.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import styled from '@emotion/styled';
import { Switch, Route } from 'react-router-dom';
import { designTokens } from '@commercetools-uikit/design-system';
import * as icons from '@commercetools-uikit/icons';
import CustomIcon from '@commercetools-uikit/icons/custom-icon';
import InlineSvg from '@commercetools-uikit/icons/inline-svg';
import LeadingIcon from '@commercetools-uikit/icons/leading-icon';
import Text from '@commercetools-uikit/text';
import Spacings from '@commercetools-uikit/spacings';
import CustomReactSvg from './fixtures/CustomIconReact';
import rawSvg from './fixtures/raw-svg';
import { Suite, Spec } from '../../../../test/percy';

Expand Down Expand Up @@ -100,6 +102,9 @@ export const component = () => (
href={`${routePath}/leading-icon`}
>{`${routePath}/leading-icon`}</a>
</li>
<li>
<a href={`${routePath}/custom-icon`}>{`${routePath}/custom-icon`}</a>
</li>
</ul>
</Route>
{colors.map((color) => (
Expand Down Expand Up @@ -198,5 +203,49 @@ export const component = () => (
</Spec>
</Suite>
</Route>
<Route exact path={`${routePath}/custom-icon`}>
<Suite>
<Spec label={`Custom Icon - React Element`} omitPropsList>
<LeadingIconList label={`Custom Icon - React Element`}>
{leadingIconSizes.map((size) => (
<LeadingIconItem key={size}>
<Spacings.Stack alignItems="center">
<CustomIcon size={size} icon={<CustomReactSvg />} />
<Text.Detail>{`size ${size}`}</Text.Detail>
</Spacings.Stack>
</LeadingIconItem>
))}
</LeadingIconList>
</Spec>
<Spec label={`Custom Icon - SVG String`} omitPropsList>
<LeadingIconList label={`Custom Icon - SVG String`}>
{leadingIconSizes.map((size) => (
<LeadingIconItem key={size}>
<Spacings.Stack alignItems="center">
<CustomIcon size={size} icon={rawSvg.clock} />
<Text.Detail>{` size ${size}`}</Text.Detail>
</Spacings.Stack>
</LeadingIconItem>
))}
</LeadingIconList>
</Spec>
<Spec label={`Custom Icon - No Border`} omitPropsList>
<LeadingIconList label={`Custom Icon - No Border`}>
{leadingIconSizes.map((size) => (
<LeadingIconItem key={size}>
<Spacings.Stack alignItems="center">
<CustomIcon
size={size}
icon={<CustomReactSvg />}
hasBorder={false}
/>
<Text.Detail>{`size ${size}`}</Text.Detail>
</Spacings.Stack>
</LeadingIconItem>
))}
</LeadingIconList>
</Spec>
</Suite>
</Route>
</Switch>
);
5 changes: 5 additions & 0 deletions packages/components/icons/src/icons.visualspec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,9 @@ describe('Icons', () => {
await page.waitForSelector('text/Leading Icon');
await percySnapshot(page, `Icons - Leading Icon`);
});
it('Custom Icon', async () => {
await page.goto(`${globalThis.HOST}/icons/custom-icon`);
await page.waitForSelector('text/Custom Icon');
await percySnapshot(page, 'Icons - Custom Icon');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type TColorThemeConfiguration = {
border?: string;
};

const sizeMap = {
export const sizeMap = {
CarlosCortizasCT marked this conversation as resolved.
Show resolved Hide resolved
10: designTokens.spacing50,
20: `calc(${designTokens.spacing50} + ${designTokens.spacing20})`,
30: designTokens.spacing60,
Expand Down