From c8a6c4411636021f49da85c8eeb3e40528998f14 Mon Sep 17 00:00:00 2001 From: Dennis Konieczek Date: Thu, 5 Oct 2023 12:31:38 -0400 Subject: [PATCH] feat: icon component path element array, children support --- .../components/Atoms/Icon/Icon.stories.tsx | 83 ++++++++- .../src/components/Atoms/Icon/Icon.test.tsx | 174 ++++++++++++++++++ .../src/components/Atoms/Icon/Icon.tsx | 37 +++- .../src/components/Atoms/Icon/readme.md | 67 +++++++ packages/snap-preact-components/src/types.ts | 2 +- 5 files changed, 349 insertions(+), 14 deletions(-) diff --git a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.stories.tsx b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.stories.tsx index fe2267c3c..5c64b5285 100644 --- a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.stories.tsx +++ b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.stories.tsx @@ -40,11 +40,19 @@ export default { description: 'SVG path', table: { type: { - summary: 'string', + summary: 'string | SVGPathElement[]', }, }, control: { type: 'text' }, }, + children: { + description: 'SVG elements to be contained within (using children)', + table: { + type: { + summary: 'string, JSX', + }, + }, + }, color: { description: 'Icon color', table: { @@ -70,7 +78,7 @@ export default { description: 'Icon height. Overwrites size.', table: { type: { - summary: 'string', + summary: 'string | number', }, }, control: { type: 'text' }, @@ -79,7 +87,7 @@ export default { description: 'Icon width. Overwrites size.', table: { type: { - summary: 'string', + summary: 'string | number', }, }, control: { type: 'text' }, @@ -118,6 +126,75 @@ CustomPath.args = { viewBox: '0 0 70 70', }; +export const Children = (props: IconProps): JSX.Element => ( + + + + + + +); +Children.args = { + size: '70px', + viewBox: '0 0 70 70', +}; + +export const ArrayPath = (props: IconProps): JSX.Element => ; +ArrayPath.args = { + path: [ + { + type: 'line', + attributes: { + x1: '1', + y1: '10', + x2: '69', + y2: '10', + stroke: '#000000', + 'stroke-width': '3', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }, + }, + { + type: 'line', + attributes: { + x1: '1', + y1: '30', + x2: '69', + y2: '30', + stroke: '#000000', + 'stroke-width': '3', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }, + }, + { + type: 'circle', + attributes: { + cx: '15', + cy: '10', + r: '6', + fill: '#000000', + stroke: '#000000', + 'stroke-width': '3', + }, + }, + { + type: 'circle', + attributes: { + cx: '55', + cy: '30', + r: '6', + fill: '#000000', + stroke: '#000000', + 'stroke-width': '3', + }, + }, + ], + size: '70', + viewBox: '0 0 70 70', +}; + export const Gallery = (): JSX.Element => { return (
diff --git a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.test.tsx b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.test.tsx index 2b812d004..e32a1f919 100644 --- a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.test.tsx +++ b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.test.tsx @@ -166,6 +166,180 @@ describe('Icon Component', () => { expect(path).toHaveAttribute('d', svgPath); }); + it('renders path array of elements', () => { + const line1 = { + type: 'line', + attributes: { + x1: '1', + y1: '6', + x2: '19', + y2: '6', + stroke: '#000000', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }, + }; + const line2 = { + type: 'line', + attributes: { + x1: '1', + y1: '14', + x2: '19', + y2: '14', + stroke: '#000000', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }, + }; + const circle1 = { + type: 'circle', + attributes: { + cx: '7', + cy: '6', + r: '3', + fill: 'none', + stroke: 'currentColor', + }, + }; + const circle2 = { + type: 'circle', + attributes: { + cx: '13', + cy: '14', + r: '3', + fill: 'none', + stroke: 'currentColor', + }, + }; + const path = [{ ...line1 }, { ...line2 }, { ...circle1 }, { ...circle2 }]; + + const otherProps = { + 'stroke-width': '1.25', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }; + const props = { + color: '#ff0000', + className: 'active', + width: '23', + height: '19', + viewBox: '0 0 20 20', + style: { + transition: `transform .4s cubic-bezier(.11,.44,.03,1)`, + '&.active': { + '& circle:nth-child(3)': { + transform: `translate(6px)`, + }, + '& circle:nth-child(4)': { + transform: `translate(-6px)`, + }, + }, + }, + ...otherProps, + }; + + const rendered = render(); + + expect.assertions( + Object.keys(line1.attributes).length + + Object.keys(line2.attributes).length + + Object.keys(circle1.attributes).length + + Object.keys(circle2.attributes).length + + Object.keys(otherProps).length + + 13 + ); + + const svgLine1 = rendered.container.querySelector(`svg ${line1.type}:nth-of-type(1)`)!; + expect(svgLine1).toBeInTheDocument(); + Object.keys(line1.attributes).forEach((key) => { + expect(svgLine1).toHaveAttribute(key, line1.attributes[key as keyof typeof line1.attributes]); + }); + + const svgLine2 = rendered.container.querySelector(`svg ${line2.type}:nth-of-type(2)`)!; + expect(svgLine2).toBeInTheDocument(); + Object.keys(line2.attributes).forEach((key) => { + expect(svgLine2).toHaveAttribute(key, line2.attributes[key as keyof typeof line2.attributes]); + }); + + const svgCircle1 = rendered.container.querySelector(`svg ${circle1.type}:nth-of-type(1)`)!; + expect(svgCircle1).toBeInTheDocument(); + Object.keys(circle1.attributes).forEach((key) => { + expect(svgCircle1).toHaveAttribute(key, circle1.attributes[key as keyof typeof circle1.attributes]); + }); + + const svgCircle2 = rendered.container.querySelector(`svg ${circle2.type}:nth-of-type(2)`)!; + expect(svgCircle2).toBeInTheDocument(); + Object.keys(circle2.attributes).forEach((key) => { + expect(svgCircle2).toHaveAttribute(key, circle2.attributes[key as keyof typeof circle2.attributes]); + }); + + const svg = rendered.container.querySelector(`svg`)!; + expect(svg).toBeInTheDocument(); + + // svg attributes that aren't props to be added on the root element + Object.keys(otherProps).forEach((key) => { + expect(svg).toHaveAttribute(key, otherProps[key as keyof typeof otherProps]); + }); + + // props + expect(svg).toHaveAttribute('viewBox', props.viewBox); + const styles = getComputedStyle(svg); + expect(styles.width).toBe(`${props.width}px`); + expect(styles.height).toBe(`${props.height}px`); + expect(styles.fill).toBe(props.color); + + // custom style script + expect(styles.transition).toBe(props.style.transition); + expect(svg).toHaveClass(props.className); + const circle1Styles = getComputedStyle(svgCircle1); + expect(circle1Styles.transform).toBe(props.style['&.active']['& circle:nth-child(3)'].transform); + const circle2Styles = getComputedStyle(svgCircle2); + expect(circle2Styles.transform).toBe(props.style['&.active']['& circle:nth-child(4)'].transform); + }); + + it('renders children elements', () => { + const lineAttributes = { + x1: '1', + y1: '6', + x2: '19', + y2: '6', + stroke: '#000000', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }; + + const cicleAttributes = { + cx: '7', + cy: '6', + r: '3', + fill: 'none', + stroke: 'currentColor', + }; + + expect.assertions(Object.keys(lineAttributes).length + Object.keys(cicleAttributes).length + 2); + + const rendered = render( + + + + + ); + + const svgLine = rendered.container.querySelector('svg line')!; + expect(svgLine).toBeInTheDocument(); + + Object.keys(lineAttributes).forEach((key) => { + expect(svgLine).toHaveAttribute(key, lineAttributes[key as keyof typeof lineAttributes]); + }); + + const svgCicle = rendered.container.querySelector('svg circle')!; + expect(svgCicle).toBeInTheDocument(); + + Object.keys(cicleAttributes).forEach((key) => { + expect(svgCicle).toHaveAttribute(key, cicleAttributes[key as keyof typeof cicleAttributes]); + }); + }); + it('can disable styles', () => { const icon = 'cog'; const color = '#3a23ad'; diff --git a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx index d82a7f6b7..106d4b88e 100644 --- a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx +++ b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx @@ -1,5 +1,5 @@ /** @jsx jsx */ -import { Fragment, h } from 'preact'; +import { Fragment, h, ComponentChildren } from 'preact'; import { jsx, css } from '@emotion/react'; import classnames from 'classnames'; @@ -12,8 +12,8 @@ const CSS = { icon: ({ color, height, width, size, theme }: IconProps) => css({ fill: color || theme?.colors?.primary, - width: width || size, - height: height || size, + width: isNaN(Number(width || size)) ? width || size : `${width || size}px`, + height: isNaN(Number(height || size)) ? height || size : `${height || size}px`, position: 'relative', }), }; @@ -32,10 +32,10 @@ export function Icon(properties: IconProps): JSX.Element { ...properties, ...properties.theme?.components?.icon, }; - const { color, icon, path, size, width, height, viewBox, disableStyles, className, style } = props; + const { color, icon, path, children, size, width, height, viewBox, disableStyles, className, style, ...otherProps } = props; const iconPath = iconPaths[icon as keyof typeof iconPaths] || path; - + const pathType = typeof iconPath; const styling: { css?: StylingCSS } = {}; if (!disableStyles) { styling.css = [CSS.icon({ color, width, height, size, theme }), style]; @@ -43,7 +43,7 @@ export function Icon(properties: IconProps): JSX.Element { styling.css = [style]; } - return iconPath ? ( + return children || (iconPath && pathType === 'string') || (pathType === 'object' && Array.isArray(path)) ? ( - + {(() => { + if (children) { + return children; + } else if (pathType === 'string') { + return ; + } else if (pathType === 'object' && Array.isArray(path)) { + return path.map((p: SVGPathElement, i) => ); + } + })()} ) : ( @@ -61,12 +70,20 @@ export function Icon(properties: IconProps): JSX.Element { ); } +type SVGPathElement = { + type: string; + attributes: { + [attribute: string]: string; + }; +}; + export interface IconProps extends ComponentProps { color?: string; icon?: IconType | string; - path?: string; + path?: string | SVGPathElement[]; + children?: ComponentChildren; size?: string; - width?: string; - height?: string; + width?: string | number; + height?: string | number; viewBox?: string; } diff --git a/packages/snap-preact-components/src/components/Atoms/Icon/readme.md b/packages/snap-preact-components/src/components/Atoms/Icon/readme.md index 34f5464e8..f0e4ed9f9 100644 --- a/packages/snap-preact-components/src/components/Atoms/Icon/readme.md +++ b/packages/snap-preact-components/src/components/Atoms/Icon/readme.md @@ -19,6 +19,73 @@ The `path` prop specifies the SVG path value for custom icons. ``` +The `path` prop can also contain an array of children svg elements to render. + +```jsx + +``` + +### children +Component children can be provided and will be rendered within the wrapping `svg` element. + +```jsx + + + + + + +``` + ### color The `color` prop specifies the icon color. diff --git a/packages/snap-preact-components/src/types.ts b/packages/snap-preact-components/src/types.ts index f993e8462..575099dea 100644 --- a/packages/snap-preact-components/src/types.ts +++ b/packages/snap-preact-components/src/types.ts @@ -5,7 +5,7 @@ import { Theme } from './providers/theme'; export interface ComponentProps extends RenderableProps { className?: string; disableStyles?: boolean; - style?: string | Record; + style?: string | Record; theme?: Theme; }