diff --git a/packages/coderskit/src/components/Steps/Steps.tsx b/packages/coderskit/src/components/Steps/Steps.tsx new file mode 100644 index 0000000..8adfc0a --- /dev/null +++ b/packages/coderskit/src/components/Steps/Steps.tsx @@ -0,0 +1,261 @@ +import React, { HTMLAttributes, ReactNode, ReactElement, LabelHTMLAttributes } from 'react'; +import classnames from 'classnames'; +import styled from '@emotion/styled'; +import { ThemeColorsKeys } from '../..'; + +// SINGLE STEP + +export type StepState = 'success' | 'error' | 'active' | 'pending'; + +export interface StepProps extends HTMLAttributes { + state?: StepState; + labelLayout?: LabelLayout; + children?: ReactNode; +} + +interface StepContentProps extends StepProps { + size?: number; + color?: ThemeColorsKeys; + fontColor?: ThemeColorsKeys; + children?: ReactNode; +} + +export const StepContentBase = styled.div(props => { + const { colors } = props.theme; + const color = colors[props.color!]; + const fontColor = colors[props.fontColor!]; + + return { + display: 'flex', + borderRadius: '100%', + alignItems: 'center', + justifyContent: 'center', + border: `1px solid ${color}`, + backgroundColor: color, + color: fontColor, + width: props.size, + height: props.size, + }; +}); + +const StepContentSuccess = styled(StepContentBase)(props => { + const { colors } = props.theme; + + return { + backgroundColor: colors.primary, + borderColor: colors.primary, + color: colors.white, + }; +}); + +const StepContentError = styled(StepContentBase)(props => { + const { colors } = props.theme; + + return { + backgroundColor: colors.error, + borderColor: colors.error, + color: colors.white, + }; +}); + +const StepContentActive = styled(StepContentBase)(props => { + const { colors } = props.theme; + + return { + backgroundColor: colors.white, + borderColor: colors.primary, + color: colors.primary, + }; +}); + +const StepContentPending = styled(StepContentBase)(props => { + const { colors } = props.theme; + + return { + backgroundColor: colors.white, + borderColor: colors.fontDisabled, + color: colors.fontDisabled, + }; +}); + +const StepVertical = styled.div(() => { + return { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + margin: '10px', + }; +}); + +const StepHorizontal = styled.div(() => { + return { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + margin: '10px', + + '.ck-step-content': { + marginRight: 8, + }, + }; +}); + +export const StepContent = (props: StepContentProps) => { + const StepContent = + props.state === 'success' + ? StepContentSuccess + : props.state === 'error' + ? StepContentError + : props.state === 'active' + ? StepContentActive + : props.state === 'pending' + ? StepContentPending + : StepContentBase; + + return ( + + {props.children} + + ); +}; + +export const Step = (props: StepProps) => { + const StepWrapper = props.labelLayout === 'vertical' ? StepVertical : StepHorizontal; + + return {props.children}; +}; + +// STEP LABEL + +export type LabelLayout = 'vertical' | 'horizontal'; + +export interface StepLabelProps extends LabelHTMLAttributes { + state?: StepState; + children?: ReactNode; +} + +const StepLabelWrapper = styled.label(props => { + const { theme, state } = props; + const { fontSizes, fontWeights, lineHeights, colors } = theme; + + return { + width: 'fit-content', + whiteSpace: 'nowrap', + fontSize: fontSizes.body2, + lineHeight: lineHeights.body2, + fontWeight: fontWeights.regular, + color: colors[state === 'active' ? 'primary' : 'fontDisabled'], + }; +}); + +export const StepLabel = ({ children, ...props }: StepLabelProps) => { + return ( + + {children} + + ); +}; + +// SEPARATOR + +export interface SeparatorProps extends StepContentProps { + topOffset?: string; +} + +const Separator = styled.div(props => { + const { colors } = props.theme; + + return { + height: '1px', + width: '100%', + marginTop: props.topOffset, + backgroundColor: colors.fontDisabled, + }; +}); + +// STEP GROUP + +export interface StepsProps extends HTMLAttributes { + children?: ReactNode; + activeStep?: number; + labelLayout?: LabelLayout; +} + +const StepsWrapper = styled.div(() => { + return { + display: 'flex', + justifyContent: 'space-between', + }; +}); + +const calculateSeparatorTopOffset = (StepWrapper: ReactElement) => { + const stepContent = StepWrapper!.props.children[0]; + return `${stepContent.props.size / 2 + 10}px`; +}; + +export const Steps = ({ children, ...props }: StepsProps) => { + const className = classnames(props.className, 'ck-steps'); + + // Determine child's state and pass number as a child (only if it's active or pending) + const childrenWithState = React.Children.map(children as ReactElement[], (child, index) => { + const state = index === props.activeStep ? 'active' : index > props.activeStep! ? 'pending' : undefined; + + if (index >= props.activeStep!) { + let number = index + 1; + let updatedChildren = React.Children.map(child.props.children as ReactElement[], (innerChild, index) => { + if (index === 0) { + return React.cloneElement(innerChild as ReactElement, { state: state, children: number }); + } else { + return React.cloneElement(innerChild as ReactElement, { state: state }); + } + }); + return React.cloneElement(child, {}, updatedChildren); + } + + return child; + }); + + // Pass label layout to children + const calcChildren = childrenWithState.length; + const childrenWithLabels: ReactNode[] = []; + const labelLayout = props.labelLayout; + + childrenWithState.forEach((element: ReactNode) => { + childrenWithLabels.push(React.cloneElement(element as ReactElement, { labelLayout: labelLayout })); + }); + + // Add separators between steps + const topOffset = calculateSeparatorTopOffset(children![0]); + + const childrenWithSeparators: ReactNode[] = []; + childrenWithLabels.forEach((element: ReactNode, index) => { + childrenWithSeparators.push(element); + if (index < calcChildren - 1) { + childrenWithSeparators.push(); + } + }); + + return ( + + {childrenWithSeparators} + + ); +}; + +StepContent.defaultProps = { + fontColor: 'white', + color: 'primary', + size: 32, + children: '', +}; + +Step.defaultProps = { + labelLeyout: 'vertical', +}; + +Steps.defaultProps = { + activeStep: 2, +}; + +Step.Label = StepLabel; +Step.Content = StepContent; diff --git a/packages/coderskit/src/components/Steps/index.ts b/packages/coderskit/src/components/Steps/index.ts new file mode 100644 index 0000000..b628698 --- /dev/null +++ b/packages/coderskit/src/components/Steps/index.ts @@ -0,0 +1 @@ +export * from './Steps'; diff --git a/packages/coderskit/src/components/index.ts b/packages/coderskit/src/components/index.ts index 71465fa..41cc2fb 100644 --- a/packages/coderskit/src/components/index.ts +++ b/packages/coderskit/src/components/index.ts @@ -21,3 +21,4 @@ export * from './TextArea'; export * from './Tooltip'; export * from './Typography'; export * from './Upload'; +export * from './Steps'; diff --git a/packages/coderskit/src/icons/TimesSolid.tsx b/packages/coderskit/src/icons/TimesSolid.tsx new file mode 100644 index 0000000..32fc17f --- /dev/null +++ b/packages/coderskit/src/icons/TimesSolid.tsx @@ -0,0 +1,19 @@ +import React, { SVGProps } from 'react'; + +const SvgTimesSolid = (props: SVGProps) => ( + +); + +export default SvgTimesSolid; diff --git a/packages/docs/public/times-solid.svg b/packages/docs/public/times-solid.svg new file mode 100644 index 0000000..8144622 --- /dev/null +++ b/packages/docs/public/times-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/src/components/Steps/Steps.stories.tsx b/packages/docs/src/components/Steps/Steps.stories.tsx new file mode 100644 index 0000000..c79d176 --- /dev/null +++ b/packages/docs/src/components/Steps/Steps.stories.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { text, select, number } from '@storybook/addon-knobs'; +import { Steps, Step } from 'coderskit'; +import { Icon, colors as themeColors } from 'coderskit'; + +const labelLayouts = { + horizontal: 'horizontal', + vertical: 'vertical', +}; + +const stepStates = { + success: 'success', + error: 'error', + active: 'active', + pending: 'pending', + unset: 'unset', +}; + +const colors = { unset: 'unset', ...Object.keys(themeColors).reduce((a, key) => ({ ...a, [key]: key }), {}) }; + +const getStepProps = () => ({ + state: select('state', stepStates, 'unset', 'Step') as keyof typeof stepStates, + size: number('size', 32, undefined, 'Step'), + color: select('color', colors, 'unset', 'Step') as keyof typeof colors, + fontColor: select('fontColor', colors, 'unset', 'Step') as keyof typeof colors, + label: text('label', 'Step label', 'Label'), + labelLayout: select('labelLayout', labelLayouts, 'vertical', 'Label') as keyof typeof labelLayouts, +}); + +const setValues = (props = getStepProps()) => { + const color = props.color === 'unset' ? undefined : props.color; + const fontColor = props.fontColor === 'unset' ? undefined : props.fontColor; + const state = props.state === 'unset' ? undefined : props.state; + return { color, fontColor, state }; +}; + +storiesOf('Steps', module) + .add('Step with number', () => { + const inheritedProps = getStepProps(); + + const props = { + ...inheritedProps, + children: number('children', 1, undefined, 'Step'), + }; + + const { color, fontColor, state } = setValues(); + const { label, children, labelLayout, ...rest } = props; + + return ( + + + {children} + + {label} + + ); + }) + .add('Step with icon', () => { + const inheritedProps = getStepProps(); + + const props = { + ...inheritedProps, + children: text('children', 'check-solid.svg', 'Step'), + }; + + const { color, fontColor, state } = setValues(); + const { label, children, labelLayout, ...rest } = props; + + return ( + + + + + {label} + + ); + }) + .add('StepGroup', () => { + const props = { + labelLayout: select('labelLayout', labelLayouts, 'vertical') as keyof typeof labelLayouts, + }; + + const label = 'Step label'; + const { labelLayout } = props; + + return ( + + + + + + {label} + + + + + + {label} + + + + {label} + + + + {label} + + + ); + });