Skip to content

Commit

Permalink
FCT-808: Create CustomIcon component (#2803)
Browse files Browse the repository at this point in the history
* feat(CustomIcon): implement initial CustomIcon component with tests and documentation

* feat(CustomIcon): update README, remove height/width from fixture, copy sizing values into style file directly, remove warning

* feat(customIcon): add overflow:hidden to styles so that svg does not get clipped by border radius

* feat(customIcon): update readme based on feedback

* feat(customIcon): update flex properties to address resize concerns

* feat(customIcon): use inline-block instead of flex due to safari computing svg width as 0px in flex containers where there is no w/h specified for the svg

* fix(leadingIcon styles): remove export

---------

Co-authored-by: Carlos Cortizas <97907068+CarlosCortizasCT@users.noreply.github.com>
  • Loading branch information
ByronDWall and CarlosCortizasCT committed May 14, 2024
1 parent adaa8dc commit 82b5db8
Show file tree
Hide file tree
Showing 13 changed files with 260 additions and 1 deletion.
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;
1 change: 1 addition & 0 deletions packages/components/icons/src/custom-icon/export-types.ts
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');
});
});

0 comments on commit 82b5db8

Please sign in to comment.