diff --git a/README.md b/README.md index 030a3b95f7..111b375d89 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ import Button from '@leafygreen-ui/button'; | [@leafygreen-ui/toolbar](./packages/toolbar) | [![version](https://img.shields.io/npm/v/@leafygreen-ui/toolbar)](https://www.npmjs.com/package/@leafygreen-ui/toolbar) | ![downloads](https://img.shields.io/npm/dm/@leafygreen-ui/toolbar?color=white) | [Live Example](http://mongodb.design/component/toolbar/live-example) | | [@leafygreen-ui/tooltip](./packages/tooltip) | [![version](https://img.shields.io/npm/v/@leafygreen-ui/tooltip)](https://www.npmjs.com/package/@leafygreen-ui/tooltip) | ![downloads](https://img.shields.io/npm/dm/@leafygreen-ui/tooltip?color=white) | [Live Example](http://mongodb.design/component/tooltip/live-example) | | [@leafygreen-ui/typography](./packages/typography) | [![version](https://img.shields.io/npm/v/@leafygreen-ui/typography)](https://www.npmjs.com/package/@leafygreen-ui/typography) | ![downloads](https://img.shields.io/npm/dm/@leafygreen-ui/typography?color=white) | [Live Example](http://mongodb.design/component/typography/live-example) | +| [@leafygreen-ui/vertical-stepper](./packages/vertical-stepper) | [![version](https://img.shields.io/npm/v/@leafygreen-ui/vertical-stepper)](https://www.npmjs.com/package/@leafygreen-ui/vertical-stepper) | ![downloads](https://img.shields.io/npm/dm/@leafygreen-ui/vertical-stepper?color=white) | [Live Example](http://mongodb.design/component/vertical-stepper/live-example) | | [@lg-charts/chart-card](./charts/chart-card) | [![version](https://img.shields.io/npm/v/@lg-charts/chart-card)](https://www.npmjs.com/package/@lg-charts/chart-card) | ![downloads](https://img.shields.io/npm/dm/@lg-charts/chart-card?color=white) | [Live Example](http://mongodb.design/component/chart-card/live-example) | | [@lg-charts/colors](./charts/colors) | [![version](https://img.shields.io/npm/v/@lg-charts/colors)](https://www.npmjs.com/package/@lg-charts/colors) | ![downloads](https://img.shields.io/npm/dm/@lg-charts/colors?color=white) | [Live Example](http://mongodb.design/component/colors/live-example) | | [@lg-charts/core](./charts/core) | [![version](https://img.shields.io/npm/v/@lg-charts/core)](https://www.npmjs.com/package/@lg-charts/core) | ![downloads](https://img.shields.io/npm/dm/@lg-charts/core?color=white) | [Live Example](http://mongodb.design/component/core/live-example) | diff --git a/packages/vertical-stepper/CHANGELOG.md b/packages/vertical-stepper/CHANGELOG.md new file mode 100644 index 0000000000..384d4b2c88 --- /dev/null +++ b/packages/vertical-stepper/CHANGELOG.md @@ -0,0 +1,45 @@ +# @leafygreen-ui/vertical-stepper + +## 3.0.0 + +### Major Changes + +- **Package has been renamed and moved!** This package is now published under the `@leafygreen-ui` scope. All dependencies and import paths must be updated from `@lg-private/vertical-stepper` to `@leafygreen-ui/vertical-stepper`. + +### Major Changes + +- c9203f7: Removes `prop-types`. Updates LG core packages to latest + +## 2.1.1 + +### Patch Changes + +- 066c4ce: [LG-4670](https://jira.mongodb.org/browse/LG-4670): vertically stack buttons in `VerticalStepActions` on smaller breakpoints + +## 2.1.0 + +### Minor Changes + +- dcab77e: [LG-4413](https://jira.mongodb.org/browse/LG-4413): VerticalStepper steps always render description and media regardless of current step + +## 2.0.0 + +### Major Changes + +- a3f1aa9: Replaces `primaryButtonProps` and `secondaryButtonProps` with `actions` prop to enable more flexibility for CTAs in each step + +## 1.1.0 + +### Minor Changes + +- f78419b: [LG-4395](https://jira.mongodb.org/browse/LG-4395): Fixes buttons sizes from default to small + +### Patch Changes + +- 8f076fb: Fixes naming of lg private packages from `@leafygreen-ui/*` to `@lg-private/*` + +## 1.0.0 + +### Major Changes + +- d50a214: First major release of `VerticalStepper`. [LG-4333](https://jira.mongodb.org/browse/LG-4333) diff --git a/packages/vertical-stepper/README.md b/packages/vertical-stepper/README.md new file mode 100644 index 0000000000..827c037b34 --- /dev/null +++ b/packages/vertical-stepper/README.md @@ -0,0 +1,113 @@ +# Vertical Stepper + +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/vertical-stepper.svg) + +#### [View on MongoDB.design](https://www.mongodb.design/component/vertical-stepper/example/) + +## Installation + +### Yarn + +```shell +yarn add @leafygreen-ui/vertical-stepper +``` + +### NPM + +```shell +npm install @leafygreen-ui/vertical-stepper +``` + +## Example + +```js +import Button, { Size, Variant } from `@leafygreen-ui/button`; +import { VerticalStepper, VerticalStep } from `@leafygreen-ui/vertical-stepper`; + +const [currentStep, setCurrentStep] = useState(0); + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam + efficitur nunc mattis magna pretium, id mattis metus vestibulum. Integer + cursus ex ante, ut molestie lorem vestibulum id.{' '} + Im a link + + } + actions={ + + } + /> + + + + + } + media={test} + /> + + + + + } + media={test} + /> +; +``` + +## Properties + +### `` + +| Prop | Type | Description | Default | +| ----------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| darkMode | `boolean` | Determines if the component renders in dark mode | `false` | +| currentStep | `number` | Zero-based. The index of the current step that will appear active. All steps will be marked as completed if the currentStep equals the number of steps. | `0` | +| children | `string` | Two or more `` components | | + +### `` + +| Prop | Type | Description | Default | +| ----------- | ----------------- | --------------------------------------------------------------- | ------- | +| title | `string` | The title of the step. | | +| description | `React.ReactNode` | The description of the step. This will render below the title. | | +| media | `React.ReactNode` | The image to the right of the text. E.g. `` or `` | | +| actions | `React.ReactNode` | Optional buttons that will render below the text. | | diff --git a/packages/vertical-stepper/package.json b/packages/vertical-stepper/package.json new file mode 100644 index 0000000000..3ff801e128 --- /dev/null +++ b/packages/vertical-stepper/package.json @@ -0,0 +1,49 @@ +{ + "name": "@leafygreen-ui/vertical-stepper", + "version": "3.0.0", + "description": "LeafyGreen UI Kit Vertical Stepper", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "require": "./dist/umd/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts" + } + }, + "license": "Apache-2.0", + "scripts": { + "build": "lg build-package", + "tsc": "lg build-ts", + "docs": "lg build-tsdoc" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@leafygreen-ui/button": "workspace:^", + "@leafygreen-ui/descendants": "workspace:^", + "@leafygreen-ui/emotion": "workspace:^", + "@leafygreen-ui/icon": "workspace:^", + "@leafygreen-ui/lib": "workspace:^", + "@leafygreen-ui/palette": "workspace:^", + "@leafygreen-ui/tokens": "workspace:^", + "@leafygreen-ui/typography": "workspace:^" + }, + "peerDependencies": { + "@leafygreen-ui/leafygreen-provider": "workspace:^" + }, + "devDependencies": { + "@lg-tools/build": "workspace:^", + "@lg-tools/storybook-utils": "workspace:^" + }, + "homepage": "https://github.com/10gen/leafygreen-ui/tree/main/packages/vertical-stepper", + "repository": { + "type": "git", + "url": "https://github.com/10gen/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/PD/summary" + } +} diff --git a/packages/vertical-stepper/src/StepIcon/StepIcon.styles.ts b/packages/vertical-stepper/src/StepIcon/StepIcon.styles.ts new file mode 100644 index 0000000000..10cde3af6f --- /dev/null +++ b/packages/vertical-stepper/src/StepIcon/StepIcon.styles.ts @@ -0,0 +1,82 @@ +import { css } from '@leafygreen-ui/emotion'; +import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { transitionDuration } from '@leafygreen-ui/tokens'; + +import { State } from '../VerticalStep/VerticalStep.types'; + +export const stepIconClassName = createUniqueClassName('step'); + +const STEP_SIZE = 20; + +export const getStepWrapperStyles = (isCompleted: boolean) => css` + position: relative; + + &::after { + background: ${isCompleted ? palette.green.dark1 : palette.gray.base}; + position: absolute; + width: 1px; + height: calc(100% - ${STEP_SIZE}px); + left: 50%; + transition: background ${transitionDuration.default}ms ease; + } +`; + +export const stepStyles = css` + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid; + transition: ${transitionDuration.default}ms ease; + width: ${STEP_SIZE}px; + height: ${STEP_SIZE}px; + position: relative; + font-size: 12px; + font-weight: 500; +`; + +export const themedStateColor = { + [Theme.Dark]: { + [State.Future]: palette.gray.light1, + [State.Completed]: palette.black, + [State.Current]: palette.green.base, + }, + [Theme.Light]: { + [State.Future]: palette.gray.dark1, + [State.Completed]: palette.white, + [State.Current]: palette.green.dark2, + }, +}; + +export const themedStateBgColor = { + [Theme.Dark]: { + [State.Future]: 'rgba(255, 255, 255, 0)', + [State.Completed]: palette.green.base, + [State.Current]: 'rgba(255, 255, 255, 0)', + }, + [Theme.Light]: { + [State.Future]: 'rgba(255, 255, 255, 0)', + [State.Completed]: palette.green.dark1, + [State.Current]: 'rgba(255, 255, 255, 0)', + }, +}; + +export const themedStateBorderColor = { + [Theme.Dark]: { + [State.Future]: palette.gray.light1, + [State.Completed]: palette.green.base, + [State.Current]: palette.green.base, + }, + [Theme.Light]: { + [State.Future]: palette.gray.base, + [State.Completed]: palette.green.dark1, + [State.Current]: palette.green.dark1, + }, +}; + +export const getThemedStateStyles = (theme: Theme, state: State) => css` + color: ${themedStateColor[theme][state]}; + background-color: ${themedStateBgColor[theme][state]}; + border-color: ${themedStateBorderColor[theme][state]}; +`; diff --git a/packages/vertical-stepper/src/StepIcon/StepIcon.tsx b/packages/vertical-stepper/src/StepIcon/StepIcon.tsx new file mode 100644 index 0000000000..563c8700c1 --- /dev/null +++ b/packages/vertical-stepper/src/StepIcon/StepIcon.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import CheckmarkIcon from '@leafygreen-ui/icon/dist/Checkmark'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; + +import { + getStepWrapperStyles, + getThemedStateStyles, + stepIconClassName, + stepStyles, +} from './StepIcon.styles'; +import { StepIconProps } from './StepIcon.types'; + +/** + * + * @internal + */ +export const StepIcon = ({ isCompleted, state, index }: StepIconProps) => { + const { theme } = useDarkMode(); + + return ( +
+
+ {isCompleted ? : index + 1} +
+
+ ); +}; + +StepIcon.displayName = 'StepIcon'; diff --git a/packages/vertical-stepper/src/StepIcon/StepIcon.types.ts b/packages/vertical-stepper/src/StepIcon/StepIcon.types.ts new file mode 100644 index 0000000000..29a8ceeff7 --- /dev/null +++ b/packages/vertical-stepper/src/StepIcon/StepIcon.types.ts @@ -0,0 +1,11 @@ +import { InternalVerticalStepProps } from '../VerticalStep'; + +export type StepIconProps = Pick< + InternalVerticalStepProps, + 'index' | 'state' +> & { + /** + * Whether the step is completed + */ + isCompleted: boolean; +}; diff --git a/packages/vertical-stepper/src/StepIcon/index.ts b/packages/vertical-stepper/src/StepIcon/index.ts new file mode 100644 index 0000000000..1d4011b03a --- /dev/null +++ b/packages/vertical-stepper/src/StepIcon/index.ts @@ -0,0 +1,2 @@ +export { StepIcon } from './StepIcon'; +export { stepIconClassName } from './StepIcon.styles'; diff --git a/packages/vertical-stepper/src/VerticalStep/InternalVerticalStep.tsx b/packages/vertical-stepper/src/VerticalStep/InternalVerticalStep.tsx new file mode 100644 index 0000000000..aa4ca72a3e --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStep/InternalVerticalStep.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { BaseFontSize } from '@leafygreen-ui/tokens'; +import { Body, Description } from '@leafygreen-ui/typography'; + +import { LGIDS_VERTICAL_STEPPER } from '../constants'; +import { StepIcon } from '../StepIcon'; +import { VerticalStepActions } from '../VerticalStepActions'; + +import { + baseStyles, + contentClassName, + getContentStyles, + getTitleStyles, + getWrapperStyles, + mediaStyles, +} from './VerticalStep.styles'; +import { InternalVerticalStepProps, State } from './VerticalStep.types'; + +/** + * @internal + */ + +export const InternalVerticalStep = React.forwardRef< + HTMLLIElement, + InternalVerticalStepProps +>( + ( + { + title, + description, + media, + actions, + state, + index, + className, + ...rest + }: InternalVerticalStepProps, + forwardRef, + ) => { + const { theme } = useDarkMode(); + + const isCompleted = state === State.Completed; + const isCurrent = state === State.Current; + const hasActions = actions !== undefined; + + return ( +
  • + +
    + + {title} + +
    + + {description} + + {media && ( +
    + {media} +
    + )} + {hasActions && ( + + )} +
    +
    +
  • + ); + }, +); + +InternalVerticalStep.displayName = 'InternalVerticalStep'; diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.spec.tsx b/packages/vertical-stepper/src/VerticalStep/VerticalStep.spec.tsx new file mode 100644 index 0000000000..ab4bfb30f3 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.spec.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import Button from '@leafygreen-ui/button'; + +import { LGIDS_VERTICAL_STEPPER } from '../constants'; + +import { State } from './VerticalStep.types'; +import { + InternalVerticalStep, + InternalVerticalStepProps, + VerticalStep, +} from '.'; + +const titleText = 'This is the title'; +const descriptionText = 'This is the description'; + +const renderVerticalStep = ({ + title = titleText, + description = descriptionText, + state = State.Current, + ...props +}: Partial) => { + const utils = render( + , + ); + + const verticalStep = utils.getByTestId(LGIDS_VERTICAL_STEPPER.step); + + return { + ...utils, + verticalStep, + }; +}; + +describe('packages/vertical-step', () => { + describe('step', () => { + describe('rendering', () => { + test('title renders', () => { + const { getByTestId } = renderVerticalStep({}); + const title = getByTestId(LGIDS_VERTICAL_STEPPER.stepTitle); + expect(title).toBeInTheDocument(); + expect(title).toHaveTextContent(titleText); + }); + test('description renders', () => { + const { getByTestId } = renderVerticalStep({}); + const description = getByTestId(LGIDS_VERTICAL_STEPPER.stepDescription); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent(descriptionText); + }); + + describe('media', () => { + test('renders', () => { + const { getByTestId } = renderVerticalStep({ + media: test, + }); + const media = getByTestId(LGIDS_VERTICAL_STEPPER.stepMedia); + expect(media).toBeInTheDocument(); + }); + test('does not render', () => { + const { queryByTestId } = renderVerticalStep({}); + const media = queryByTestId(LGIDS_VERTICAL_STEPPER.stepMedia); + expect(media).not.toBeInTheDocument(); + }); + }); + + describe('actions', () => { + test('render', () => { + const { getByTestId } = renderVerticalStep({ + actions: , + }); + const actions = getByTestId(LGIDS_VERTICAL_STEPPER.stepActions); + expect(actions).toBeInTheDocument(); + expect(actions).toHaveTextContent('primary button'); + }); + test('does not render', () => { + const { queryByTestId } = renderVerticalStep({}); + const actions = queryByTestId(LGIDS_VERTICAL_STEPPER.stepActions); + expect(actions).not.toBeInTheDocument(); + }); + }); + }); + + describe('current', () => { + test('aria-current is "step"', async () => { + const { verticalStep } = renderVerticalStep({}); + expect(verticalStep).toHaveAttribute('data-state', State.Current); + expect(verticalStep).toHaveAttribute('aria-current', 'step'); + }); + + test('buttons are tabbable', async () => { + const buttonTestId = 'test-button'; + const { getByTestId } = renderVerticalStep({ + actions: , + }); + const button = getByTestId(buttonTestId); + userEvent.tab(); + expect(button).toHaveFocus(); + }); + }); + + describe('completed', () => { + test('aria-current is false', async () => { + const { verticalStep } = renderVerticalStep({ + state: State.Completed, + }); + expect(verticalStep).toHaveAttribute('data-state', State.Completed); + expect(verticalStep).toHaveAttribute('aria-current', 'false'); + }); + + test('buttons are not tabbable', async () => { + const { getByTestId } = renderVerticalStep({ + state: State.Completed, + actions: , + }); + const actions = getByTestId(LGIDS_VERTICAL_STEPPER.stepActions); + // should test with .not.toHaveFocus() but jsdom does not currently support the `inert` attribute: https://github.com/jsdom/jsdom/issues/3605 + expect(actions).toHaveAttribute('inert'); + }); + }); + + describe('future', () => { + test('aria-current is false', async () => { + const { verticalStep } = renderVerticalStep({ + state: State.Future, + }); + expect(verticalStep).toHaveAttribute('data-state', State.Future); + expect(verticalStep).toHaveAttribute('aria-current', 'false'); + }); + + test('buttons are not tabbable', async () => { + const { getByTestId } = renderVerticalStep({ + state: State.Future, + actions: , + }); + const actions = getByTestId(LGIDS_VERTICAL_STEPPER.stepActions); + // should test with .not.toHaveFocus() but jsdom does not currently support the `inert` attribute: https://github.com/jsdom/jsdom/issues/3605 + expect(actions).toHaveAttribute('inert'); + }); + }); + }); + + describe('TypeScript types are correct', () => { + // eslint-disable-next-line jest/no-disabled-tests + test.skip('VerticalStepper component types', () => { + <> + + + {/* @ts-expect-error Missing title and description */} + + + } + actions={} + /> + ; + }); + }); +}); diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.styles.ts b/packages/vertical-stepper/src/VerticalStep/VerticalStep.styles.ts new file mode 100644 index 0000000000..dd31a31b9c --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.styles.ts @@ -0,0 +1,111 @@ +import { css } from '@leafygreen-ui/emotion'; +import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { + breakpoints, + fontWeights, + spacing, + transitionDuration, + typeScales, +} from '@leafygreen-ui/tokens'; + +import { stepIconClassName } from '../StepIcon'; + +import { State } from './VerticalStep.types'; + +export const contentClassName = createUniqueClassName('content'); + +export const baseStyles = css` + display: flex; + gap: ${spacing[200]}px; + + // This adds the line between steps + &:not(:last-of-type) { + .${stepIconClassName} { + &::after { + content: ''; + } + } + } + + &:last-of-type { + .${contentClassName} { + margin: 0; + } + } +`; + +export const getWrapperStyles = (hasMedia = false) => css` + overflow: hidden; + padding-inline-start: ${spacing[200]}px; + + ${hasMedia && + css` + @media (min-width: ${breakpoints.Tablet}px) { + display: grid; + } + + column-gap: ${spacing[400]}px; + grid-template: + 'desc img' + 'cta img' + 'cta img'; + grid-template-columns: minmax(370px, 1fr) auto; + `} +`; + +export const mediaStyles = css` + grid-area: img; + margin-block-start: ${spacing[200]}px; + max-width: 800px; + width: 100%; + + @media (min-width: ${breakpoints.Tablet}px) { + margin-block-start: 0; + } + + img, + svg { + max-width: 100%; + vertical-align: middle; + } +`; + +export const titleStyles: Record> = { + [Theme.Dark]: { + [State.Current]: palette.white, + [State.Completed]: palette.green.base, + [State.Future]: palette.gray.light1, + }, + [Theme.Light]: { + [State.Current]: palette.black, + [State.Completed]: palette.green.dark2, + [State.Future]: palette.gray.dark1, + }, +}; + +export const getTitleStyles = (theme: Theme, state: State) => { + return css` + color: ${titleStyles[theme][state]}; + padding-inline-start: ${spacing[200]}px; + line-height: ${typeScales.body1.lineHeight}px; + transition: ${transitionDuration.default}ms font-weight ease-in-out; + + ${state !== State.Completed && + css` + font-weight: ${fontWeights.bold}; + `} + `; +}; + +export const getContentStyles = (isOpen = false, hasButtons = false) => css` + margin-block-end: ${spacing[400]}px; + transition: margin-block-end ${transitionDuration.slowest}ms ease-in-out; + width: 100%; + + ${isOpen && + hasButtons && + css` + margin-block-end: ${spacing[200]}px; + `} +`; diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.tsx b/packages/vertical-stepper/src/VerticalStep/VerticalStep.tsx new file mode 100644 index 0000000000..b91fa56987 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { useDescendant } from '@leafygreen-ui/descendants'; + +import { + useVerticalStepperContext, + VerticalStepperDescendantsContext, +} from '../context'; + +import { InternalVerticalStep } from './InternalVerticalStep'; +import { State, VerticalStepProps } from './VerticalStep.types'; + +export const VerticalStep = React.forwardRef( + ({ ...rest }: VerticalStepProps, forwardRef) => { + const { index, ref } = useDescendant( + VerticalStepperDescendantsContext, + forwardRef, + ); + + const { currentStep, hasVerticalStepperParent } = + useVerticalStepperContext(); + + const getState = (index: number) => { + if (index === currentStep) return State.Current; + if (index < currentStep) return State.Completed; + return State.Future; + }; + + if (!hasVerticalStepperParent) { + throw Error( + '`VerticalStep` must be a child of a `VerticalStepper` instance', + ); + } + + return ( + + ); + }, +); + +VerticalStep.displayName = 'VerticalStep'; diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.types.ts b/packages/vertical-stepper/src/VerticalStep/VerticalStep.types.ts new file mode 100644 index 0000000000..9d630d0235 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.types.ts @@ -0,0 +1,43 @@ +import { ComponentPropsWithRef } from 'react'; + +export interface VerticalStepProps extends ComponentPropsWithRef<'li'> { + /** + * The title of the step + */ + title: string; + + /** + * The description of the step. This will render below the title + */ + description: React.ReactNode; + + /** + * The image to the right of the text + */ + media?: React.ReactNode; + + /** + * Optional buttons that will render below the text + */ + actions?: React.ReactNode; +} + +export const State = { + Current: 'current', + Completed: 'completed', + Future: 'future', +} as const; + +export type State = (typeof State)[keyof typeof State]; + +export interface InternalVerticalStepProps extends VerticalStepProps { + /** + * The internal state of the step + */ + state: State; + + /** + * The index of the step + */ + index: number; +} diff --git a/packages/vertical-stepper/src/VerticalStep/index.ts b/packages/vertical-stepper/src/VerticalStep/index.ts new file mode 100644 index 0000000000..d1d8a88705 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStep/index.ts @@ -0,0 +1,7 @@ +export { InternalVerticalStep } from './InternalVerticalStep'; +export { VerticalStep } from './VerticalStep'; +export { + InternalVerticalStepProps, + State, + type VerticalStepProps, +} from './VerticalStep.types'; diff --git a/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.styles.tsx b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.styles.tsx new file mode 100644 index 0000000000..86c1c447e9 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.styles.tsx @@ -0,0 +1,47 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { + breakpoints, + spacing, + transitionDuration, +} from '@leafygreen-ui/tokens'; + +export const innerStyles = css` + overflow: hidden; +`; + +export const getBaseStyles = (isCurrent = false) => + cx( + css` + display: grid; + transition: grid-template-rows ${transitionDuration.slowest}ms ease-in-out; + grid-template-rows: 0fr; + margin-inline-start: -${spacing[200]}px; + `, + { + [css` + grid-template-rows: 1fr; + `]: isCurrent, + }, + ); + +export const getWrapperStyles = (isCurrent = false) => + cx( + css` + padding-inline-start: ${spacing[200]}px; + padding-block-end: 0; + padding-block-start: ${spacing[200]}px; + transition: padding-block-end 400ms ease; + + display: flex; + gap: ${spacing[200]}px; + + @media (max-width: ${breakpoints.Tablet}px) { + flex-direction: column; + } + `, + { + [css` + padding-block-end: ${spacing[200]}px; // Add padding in here so that hover states are not cut off by the overflow + `]: isCurrent, + }, + ); diff --git a/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.tsx b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.tsx new file mode 100644 index 0000000000..14c2d9aa5b --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { LGIDS_VERTICAL_STEPPER } from '../constants'; +import { State } from '../VerticalStep/VerticalStep.types'; + +import { + getBaseStyles, + getWrapperStyles, + innerStyles, +} from './VerticalStepActions.styles'; +import { VerticalStepActionsProps } from './VerticalStepActions.types'; + +export const VerticalStepActions = ({ + actions, + state, +}: VerticalStepActionsProps) => { + const isCurrent = state === State.Current; + + return ( +
    +
    +
    {actions}
    +
    +
    + ); +}; + +VerticalStepActions.displayName = 'VerticalStepActions'; diff --git a/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.types.ts b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.types.ts new file mode 100644 index 0000000000..6d7918175b --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.types.ts @@ -0,0 +1,6 @@ +import { InternalVerticalStepProps } from '../VerticalStep/VerticalStep.types'; + +export type VerticalStepActionsProps = Pick< + InternalVerticalStepProps, + 'actions' | 'state' +>; diff --git a/packages/vertical-stepper/src/VerticalStepActions/index.ts b/packages/vertical-stepper/src/VerticalStepActions/index.ts new file mode 100644 index 0000000000..297d0252b9 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepActions/index.ts @@ -0,0 +1 @@ +export { VerticalStepActions } from './VerticalStepActions'; diff --git a/packages/vertical-stepper/src/VerticalStepper.stories.tsx b/packages/vertical-stepper/src/VerticalStepper.stories.tsx new file mode 100644 index 0000000000..71a76bd1ed --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepper.stories.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import Button, { Size, Variant } from '@leafygreen-ui/button'; +import { Link } from '@leafygreen-ui/typography'; + +import { VerticalStep, VerticalStepper } from '.'; + +export default { + title: 'Composition/Data Display/VerticalStepper', + component: VerticalStepper, + parameters: { + default: 'LiveExample', + }, + args: { + darkMode: false, + currentStep: 0, + children: [ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam + efficitur nunc mattis magna pretium, id mattis metus vestibulum. + Integer cursus ex ante, ut molestie lorem vestibulum id.{' '} + Im a link + + } + actions={ + + } + />, + + + + + } + media={test} + />, + + + + + } + media={test} + />, + primary button} + />, + primary button} + media={test} + />, + , + ], + }, + argTypes: { + darkMode: { + control: 'boolean', + }, + currentStep: { + control: { + type: 'number', + min: 0, + max: 6, + }, + }, + }, +} satisfies StoryMetaType; + +export const LiveExample = { + render: ({ ...args }) => , + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, +} satisfies StoryObj; + +export const Generated = { + render: () => <>, + parameters: { + generate: { + combineArgs: { + darkMode: [false, true], + currentStep: [0, 2, 6], + }, + }, + }, +} satisfies StoryObj; diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.spec.tsx b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.spec.tsx new file mode 100644 index 0000000000..76f183e63f --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.spec.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import Button from '@leafygreen-ui/button'; +import { Link } from '@leafygreen-ui/typography'; + +import { LGIDS_VERTICAL_STEPPER } from '../constants'; +import { VerticalStep } from '../VerticalStep/VerticalStep'; + +import { VerticalStepper, VerticalStepperProps } from '.'; + +const currentStepString = 'li[data-state="current"]'; +const completedStepString = 'li[data-state="completed"]'; +const futureStepString = 'li[data-state="future"]'; + +const childrenData = [ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam + efficitur nunc mattis magna pretium, id mattis metus vestibulum. Integer + cursus ex ante, ut molestie lorem vestibulum id.{' '} + Im a link + + } + actions={} + />, + + + + + } + media={test} + />, + + + + + } + media={test} + />, + primary button} + />, + , + primary button} + media={test} + />, +]; + +const renderVerticalStepper = ({ + currentStep: currentStepProp = 2, + children = childrenData, + ...props +}: Partial) => { + const utils = render( + + {children} + , + ); + + const verticalStepper = utils.getByTestId(LGIDS_VERTICAL_STEPPER.root); + const verticalSteps = utils.getAllByTestId(LGIDS_VERTICAL_STEPPER.step); + const currentStep = verticalStepper.querySelectorAll(currentStepString); + const completedSteps = verticalStepper.querySelectorAll(completedStepString); + const futureSteps = verticalStepper.querySelectorAll(futureStepString); + + return { + ...utils, + verticalStepper, + verticalSteps, + currentStep, + completedSteps, + futureSteps, + }; +}; + +describe('packages/vertical-stepper', () => { + describe('a11y', () => { + test('does not have basic accessibility issues', async () => { + const { container } = renderVerticalStepper({}); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + test('warns if there is only 1 step', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render( + + primary button} + media={test} + /> + , + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + 'Two or more components are required', + ); + spy.mockClear(); + }); + + describe('steps', () => { + test('are all rendered', async () => { + const { verticalSteps } = renderVerticalStepper({}); + expect(verticalSteps).toHaveLength(6); + }); + + describe('current', () => { + test('has 1 current step', async () => { + const { currentStep } = renderVerticalStepper({}); + expect(currentStep).toHaveLength(1); + }); + }); + + describe('completed', () => { + test('has correct number of steps', async () => { + const { completedSteps } = renderVerticalStepper({}); + expect(completedSteps).toHaveLength(2); + }); + }); + + describe('future', () => { + test('has correct number of steps', async () => { + const { futureSteps } = renderVerticalStepper({}); + expect(futureSteps).toHaveLength(3); + }); + }); + }); + + describe('TypeScript types are correct', () => { + // eslint-disable-next-line jest/no-disabled-tests + test.skip('VerticalStepper component types', () => { + <> + {/* @ts-expect-error Missing children and currentStep */} + + + {/* @ts-expect-error Missing children */} + + + {/* @ts-expect-error Missing currentStep */} + + {/* @ts-expect-error Missing all props */} + + + + + {/* @ts-expect-error Missing all props */} + + + ; + }); + }); +}); diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.styles.ts b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.styles.ts new file mode 100644 index 0000000000..c89b8fdbdb --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.styles.ts @@ -0,0 +1,7 @@ +import { css } from '@leafygreen-ui/emotion'; + +export const baseStyles = css` + list-style-type: none; + padding: 0; + margin: 0; +`; diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.tsx b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.tsx new file mode 100644 index 0000000000..bcddd8718c --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; + +import { + DescendantsProvider, + useInitDescendants, +} from '@leafygreen-ui/descendants'; +import { cx } from '@leafygreen-ui/emotion'; +import LeafyGreenProvider, { + useDarkMode, +} from '@leafygreen-ui/leafygreen-provider'; + +import { LGIDS_VERTICAL_STEPPER } from '../constants'; +import { + VerticalStepperDescendantsContext, + VerticalStepperProvider, +} from '../context'; + +import { baseStyles } from './VerticalStepper.styles'; +import { VerticalStepperProps } from './VerticalStepper.types'; + +export const VerticalStepper = React.forwardRef< + HTMLOListElement, + VerticalStepperProps +>( + ( + { + currentStep = 0, + darkMode: darkModeProp, + children, + className, + 'data-lgid': dataLgId = LGIDS_VERTICAL_STEPPER.root, + }: VerticalStepperProps, + forwardRef, + ) => { + const { darkMode } = useDarkMode(darkModeProp); + const childrenLength = React.Children.toArray(children).length; + + const { descendants, dispatch } = useInitDescendants( + VerticalStepperDescendantsContext, + ); + + const providerData = useMemo(() => { + return { currentStep, hasVerticalStepperParent: true }; + }, [currentStep]); + + if (childrenLength < 2) { + console.warn('Two or more components are required'); + return null; + } + + return ( + + + +
      + {children} +
    +
    +
    +
    + ); + }, +); + +VerticalStepper.displayName = 'VerticalStepper'; diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.types.ts b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.types.ts new file mode 100644 index 0000000000..136e0782a1 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.types.ts @@ -0,0 +1,18 @@ +import React, { ComponentPropsWithRef } from 'react'; + +import { DarkModeProps, LgIdProps } from '@leafygreen-ui/lib'; + +export interface VerticalStepperProps + extends DarkModeProps, + LgIdProps, + Omit, 'children'> { + /** + * Zero-based. The index of the current step that will appear active. All steps will be marked as completed if the currentStep equals the number of steps. + */ + currentStep: number; + + /** + * Two or more `` components + */ + children: React.ReactNode; +} diff --git a/packages/vertical-stepper/src/VerticalStepper/index.ts b/packages/vertical-stepper/src/VerticalStepper/index.ts new file mode 100644 index 0000000000..1219e654b5 --- /dev/null +++ b/packages/vertical-stepper/src/VerticalStepper/index.ts @@ -0,0 +1,2 @@ +export { VerticalStepper } from './VerticalStepper'; +export { VerticalStepperProps } from './VerticalStepper.types'; diff --git a/packages/vertical-stepper/src/constants.ts b/packages/vertical-stepper/src/constants.ts new file mode 100644 index 0000000000..9dbfbe57df --- /dev/null +++ b/packages/vertical-stepper/src/constants.ts @@ -0,0 +1,10 @@ +const LGID_ROOT = 'lg-vertical_stepper'; + +export const LGIDS_VERTICAL_STEPPER = { + root: LGID_ROOT, + step: `${LGID_ROOT}-step`, + stepTitle: `${LGID_ROOT}-step-title`, + stepDescription: `${LGID_ROOT}-step-description`, + stepMedia: `${LGID_ROOT}-step-media`, + stepActions: `${LGID_ROOT}-step-actions`, +} as const; diff --git a/packages/vertical-stepper/src/context/VerticalStepperContext.tsx b/packages/vertical-stepper/src/context/VerticalStepperContext.tsx new file mode 100644 index 0000000000..e020e63575 --- /dev/null +++ b/packages/vertical-stepper/src/context/VerticalStepperContext.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { createContext, PropsWithChildren, useContext } from 'react'; + +export interface VerticalStepperContextProps { + currentStep: number; + hasVerticalStepperParent: boolean; +} + +export const VerticalStepperContext = + createContext({ + currentStep: 0, + hasVerticalStepperParent: false, + }); + +export const useVerticalStepperContext = () => { + return useContext(VerticalStepperContext); +}; + +export const VerticalStepperProvider = ({ + children, + currentStep, + hasVerticalStepperParent, +}: PropsWithChildren) => { + return ( + + {children} + + ); +}; diff --git a/packages/vertical-stepper/src/context/VerticalStepperDescendantsContext.ts b/packages/vertical-stepper/src/context/VerticalStepperDescendantsContext.ts new file mode 100644 index 0000000000..154d637a3b --- /dev/null +++ b/packages/vertical-stepper/src/context/VerticalStepperDescendantsContext.ts @@ -0,0 +1,11 @@ +import { + createDescendantsContext, + useDescendantsContext, +} from '@leafygreen-ui/descendants'; + +export const VerticalStepperDescendantsContext = + createDescendantsContext('VerticalStepperDescendantsContext'); + +export function useVerticalStepperDescendantsContext() { + return useDescendantsContext(VerticalStepperDescendantsContext); +} diff --git a/packages/vertical-stepper/src/context/index.ts b/packages/vertical-stepper/src/context/index.ts new file mode 100644 index 0000000000..746c7db028 --- /dev/null +++ b/packages/vertical-stepper/src/context/index.ts @@ -0,0 +1,5 @@ +export { + useVerticalStepperContext, + VerticalStepperProvider, +} from './VerticalStepperContext'; +export { VerticalStepperDescendantsContext } from './VerticalStepperDescendantsContext'; diff --git a/packages/vertical-stepper/src/index.ts b/packages/vertical-stepper/src/index.ts new file mode 100644 index 0000000000..91b0c6f61f --- /dev/null +++ b/packages/vertical-stepper/src/index.ts @@ -0,0 +1,2 @@ +export { VerticalStep, type VerticalStepProps } from './VerticalStep'; +export { VerticalStepper, type VerticalStepperProps } from './VerticalStepper'; diff --git a/packages/vertical-stepper/tsconfig.json b/packages/vertical-stepper/tsconfig.json new file mode 100644 index 0000000000..f8088449c0 --- /dev/null +++ b/packages/vertical-stepper/tsconfig.json @@ -0,0 +1,44 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": [ + "../icon/src/generated/*" + ], + "@leafygreen-ui/*": [ + "../*/src" + ] + } + }, + "include": ["src/**/*"], + "exclude": ["**/*.stories.*"], + "references": [ + { + "path": "../button" + }, + { + "path": "../descendants" + }, + { + "path": "../emotion" + }, + { + "path": "../icon" + }, + { + "path": "../leafygreen-provider" + }, + { + "path": "../lib" + }, + { + "path": "../palette" + }, + { + "path": "../tokens" + }, + { + "path": "../typography" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b353d9ba05..a10e6d7287 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3753,6 +3753,43 @@ importers: specifier: workspace:^ version: link:../../tools/build + packages/vertical-stepper: + dependencies: + '@leafygreen-ui/button': + specifier: workspace:^ + version: link:../button + '@leafygreen-ui/descendants': + specifier: workspace:^ + version: link:../descendants + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/icon': + specifier: workspace:^ + version: link:../icon + '@leafygreen-ui/leafygreen-provider': + specifier: workspace:^ + version: link:../leafygreen-provider + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/palette': + specifier: workspace:^ + version: link:../palette + '@leafygreen-ui/tokens': + specifier: workspace:^ + version: link:../tokens + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography + devDependencies: + '@lg-tools/build': + specifier: workspace:^ + version: link:../../tools/build + '@lg-tools/storybook-utils': + specifier: workspace:^ + version: link:../../tools/storybook-utils + tools/build: dependencies: '@babel/core': diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index 7ee8b6b4de..6ee2eddfcc 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -76,6 +76,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/toolbar', '@leafygreen-ui/tooltip', '@leafygreen-ui/typography', + '@leafygreen-ui/vertical-stepper', '@lg-charts/chart-card', '@lg-charts/colors', '@lg-charts/core',