diff --git a/web/components/stepper.css b/web/components/stepper.css new file mode 100644 --- /dev/null +++ b/web/components/stepper.css @@ -0,0 +1,46 @@ +.stepperContainer { + overflow-y: auto; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.stepperItem { + overflow-y: auto; + height: 100%; +} + +.stepperFooter { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.button { + position: relative; +} + +.buttonLoadingIndicator { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.hide { + opacity: 0; +} + +.errorMessage { + color: var(--error); + font-style: italic; + height: 32px; + + display: flex; + align-items: center; + justify-content: flex-end; +} diff --git a/web/components/stepper.react.js b/web/components/stepper.react.js new file mode 100644 --- /dev/null +++ b/web/components/stepper.react.js @@ -0,0 +1,115 @@ +// @flow +import classnames from 'classnames'; +import * as React from 'react'; +import { useCallback } from 'react'; + +import LoadingIndicator from '../loading-indicator.react'; +import Button from './button.react'; +import css from './stepper.css'; + +export type ButtonProps = { + +content: string, + +disabled?: boolean, + +loading?: boolean, + +onClick: () => void, +}; + +type ButtonType = 'prev' | 'next'; + +type ItemProps = { + +content: React.Node, + +errorMessage?: string, + +prevProps?: ButtonProps, + +nextProps?: ButtonProps, +}; + +function StepperItem(props: ItemProps): React.Node { + const { content, errorMessage, prevProps, nextProps } = props; + + const prepareButton = useCallback( + (buttonProps: ButtonProps, type: ButtonType) => { + const { content: name, loading, disabled, onClick } = buttonProps; + const contentClass = classnames({ + [css.hide]: loading, + }); + + const indicatorClass = classnames({ + [css.buttonLoadingIndicator]: true, + [css.hide]: !loading, + }); + + return ( + + ); + }, + [], + ); + + const prevButton = React.useMemo( + () => (prevProps ? prepareButton(prevProps, 'prev') : null), + [prevProps, prepareButton], + ); + + const nextButton = React.useMemo( + () => (nextProps ? prepareButton(nextProps, 'next') : null), + [nextProps, prepareButton], + ); + + return ( + <> +
{content}
+
{errorMessage}
+
+ {prevButton} + {nextButton} +
+ + ); +} + +type ContainerProps = { + +activeStep: string, + +className?: string, + +children: React.ChildrenArray>, +}; + +function StepperContainer(props: ContainerProps): React.Node { + const { children, activeStep, className } = props; + + const index = React.useMemo( + () => + new Map( + React.Children.toArray(children).map((child, i) => [ + child.key?.toString().substring(2), + i, + ]), + ), + [children], + ); + + const activeComponent = children[index.get(activeStep) ?? 0]; + + const styles = classnames({ + [css.stepperContainer]: true, + [className || '']: !!className, + }); + + return
{activeComponent}
; +} + +const Stepper = { + Container: StepperContainer, + Item: StepperItem, +}; + +export default Stepper;