From 82b5db83881d1fc77d8491cea6439c6fc9c58fb1 Mon Sep 17 00:00:00 2001 From: Byron Wall <87667330+ByronDWall@users.noreply.github.com> Date: Tue, 14 May 2024 09:49:25 -0400 Subject: [PATCH] FCT-808: Create `CustomIcon` component (#2803) * 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> --- .changeset/weak-ligers-perform.md | 5 ++ packages/components/icons/README.md | 29 +++++++++++ .../components/icons/custom-icon/package.json | 4 ++ packages/components/icons/package.json | 3 +- .../src/custom-icon/custom-icon.spec.tsx | 40 +++++++++++++++ .../src/custom-icon/custom-icon.styles.ts | 30 ++++++++++++ .../icons/src/custom-icon/custom-icon.tsx | 47 ++++++++++++++++++ .../icons/src/custom-icon/export-types.ts | 1 + .../components/icons/src/custom-icon/index.ts | 3 ++ .../icons/src/fixtures/CustomIconReact.tsx | 30 ++++++++++++ packages/components/icons/src/icon.story.js | 15 ++++++ .../icons/src/icons.visualroute.jsx | 49 +++++++++++++++++++ .../components/icons/src/icons.visualspec.js | 5 ++ 13 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 .changeset/weak-ligers-perform.md create mode 100644 packages/components/icons/custom-icon/package.json create mode 100644 packages/components/icons/src/custom-icon/custom-icon.spec.tsx create mode 100644 packages/components/icons/src/custom-icon/custom-icon.styles.ts create mode 100644 packages/components/icons/src/custom-icon/custom-icon.tsx create mode 100644 packages/components/icons/src/custom-icon/export-types.ts create mode 100644 packages/components/icons/src/custom-icon/index.ts create mode 100644 packages/components/icons/src/fixtures/CustomIconReact.tsx diff --git a/.changeset/weak-ligers-perform.md b/.changeset/weak-ligers-perform.md new file mode 100644 index 0000000000..90de2745b8 --- /dev/null +++ b/.changeset/weak-ligers-perform.md @@ -0,0 +1,5 @@ +--- +'@commercetools-uikit/icons': minor +--- + +Adds new CustomIcon component for displaying non-ui-kit-svgs. diff --git a/packages/components/icons/README.md b/packages/components/icons/README.md index 2ae26741fe..a62dee40de 100644 --- a/packages/components/icons/README.md +++ b/packages/components/icons/README.md @@ -105,3 +105,32 @@ const app = () => } />; ### 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 = () => } />; +``` + +### Properties + +| Props | Type | Required | Values | Default | Description | +| ----------- | --------------------------------------------------------- | :------: | --------------------------------------------------- | ------- | ----------------------------------------------- | +| `size` | `string` | | '10', '20', '30', '40' | '20' | Specifies the icon size | +| `icon` | `union`
Possible values:
`, 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 | diff --git a/packages/components/icons/custom-icon/package.json b/packages/components/icons/custom-icon/package.json new file mode 100644 index 0000000000..80d327bf0d --- /dev/null +++ b/packages/components/icons/custom-icon/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/commercetools-uikit-icons-custom-icon.cjs.js", + "module": "dist/commercetools-uikit-icons-custom-icon.esm.js" +} diff --git a/packages/components/icons/package.json b/packages/components/icons/package.json index b5b0292fdb..d957cdd9f3 100644 --- a/packages/components/icons/package.json +++ b/packages/components/icons/package.json @@ -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" }, diff --git a/packages/components/icons/src/custom-icon/custom-icon.spec.tsx b/packages/components/icons/src/custom-icon/custom-icon.spec.tsx new file mode 100644 index 0000000000..e8c3f761c6 --- /dev/null +++ b/packages/components/icons/src/custom-icon/custom-icon.spec.tsx @@ -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: , + '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(); + await screen.findByRole('img', { name: 'custom-icon-test' }); + }); + it('should pass data attributes', async () => { + render(); + await screen.findByTestId('test-testid'); + }); + it('should render a custom svg when svg prop is passed', async () => { + render(); + await screen.findByLabelText('custom clock svg'); + }); +}); diff --git a/packages/components/icons/src/custom-icon/custom-icon.styles.ts b/packages/components/icons/src/custom-icon/custom-icon.styles.ts new file mode 100644 index 0000000000..5a3425bfef --- /dev/null +++ b/packages/components/icons/src/custom-icon/custom-icon.styles.ts @@ -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'}; + `; +}; diff --git a/packages/components/icons/src/custom-icon/custom-icon.tsx b/packages/components/icons/src/custom-icon/custom-icon.tsx new file mode 100644 index 0000000000..22772ab9e9 --- /dev/null +++ b/packages/components/icons/src/custom-icon/custom-icon.tsx @@ -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 component, must pass either an icon prop or an svg prop + */ + icon: ReactElement | string; +}; + +const defaultProps: Required> = { + size: '20', + hasBorder: true, +}; + +const CustomIcon = (props: TCustomIconProps) => ( +
+ {typeof props.icon === 'string' ? ( + + ) : ( + cloneElement(props.icon) + )} +
+); + +CustomIcon.displayName = 'CustomIcon'; +CustomIcon.defaultProps = defaultProps; + +export default CustomIcon; diff --git a/packages/components/icons/src/custom-icon/export-types.ts b/packages/components/icons/src/custom-icon/export-types.ts new file mode 100644 index 0000000000..d2456cb219 --- /dev/null +++ b/packages/components/icons/src/custom-icon/export-types.ts @@ -0,0 +1 @@ +export type { TCustomIconProps } from './custom-icon'; diff --git a/packages/components/icons/src/custom-icon/index.ts b/packages/components/icons/src/custom-icon/index.ts new file mode 100644 index 0000000000..ec7997b088 --- /dev/null +++ b/packages/components/icons/src/custom-icon/index.ts @@ -0,0 +1,3 @@ +export { default } from './custom-icon'; + +export * from './export-types'; diff --git a/packages/components/icons/src/fixtures/CustomIconReact.tsx b/packages/components/icons/src/fixtures/CustomIconReact.tsx new file mode 100644 index 0000000000..39bd7661b5 --- /dev/null +++ b/packages/components/icons/src/fixtures/CustomIconReact.tsx @@ -0,0 +1,30 @@ +const SvgCustomIcon = () => ( + + + + + + + + + + + + + +); +SvgCustomIcon.displayName = 'SvgCustomIcon'; + +export default SvgCustomIcon; diff --git a/packages/components/icons/src/icon.story.js b/packages/components/icons/src/icon.story.js index 1db60e323a..63e575d1fe 100644 --- a/packages/components/icons/src/icon.story.js +++ b/packages/components/icons/src/icon.story.js @@ -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'; @@ -294,4 +296,17 @@ storiesOf('Components|Icons', module) /> ); + }) + .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 ( +
+ } + /> +
+ ); }); diff --git a/packages/components/icons/src/icons.visualroute.jsx b/packages/components/icons/src/icons.visualroute.jsx index 2d9bcac47a..46977002ae 100644 --- a/packages/components/icons/src/icons.visualroute.jsx +++ b/packages/components/icons/src/icons.visualroute.jsx @@ -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'; @@ -100,6 +102,9 @@ export const component = () => ( href={`${routePath}/leading-icon`} >{`${routePath}/leading-icon`} +
  • + {`${routePath}/custom-icon`} +
  • {colors.map((color) => ( @@ -198,5 +203,49 @@ export const component = () => ( + + + + + {leadingIconSizes.map((size) => ( + + + } /> + {`size ${size}`} + + + ))} + + + + + {leadingIconSizes.map((size) => ( + + + + {` size ${size}`} + + + ))} + + + + + {leadingIconSizes.map((size) => ( + + + } + hasBorder={false} + /> + {`size ${size}`} + + + ))} + + + + ); diff --git a/packages/components/icons/src/icons.visualspec.js b/packages/components/icons/src/icons.visualspec.js index 3530f91de9..2d23c1b5b5 100644 --- a/packages/components/icons/src/icons.visualspec.js +++ b/packages/components/icons/src/icons.visualspec.js @@ -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'); + }); });