diff --git a/.changeset/descendants-exports.md b/.changeset/descendants-exports.md new file mode 100644 index 0000000000..dc9167bd65 --- /dev/null +++ b/.changeset/descendants-exports.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/descendants': minor +--- + +Exports `Position` enum. Removes type annotation from `Direction` export diff --git a/.changeset/lib-find-children.md b/.changeset/lib-find-children.md new file mode 100644 index 0000000000..7c0127e7d5 --- /dev/null +++ b/.changeset/lib-find-children.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/lib': minor +--- + +Adds `findChildren` utility to `lib`. Also adds `unwrapRootFragment` and `isChildWithProperty` helpers diff --git a/packages/descendants/src/Highlight/index.ts b/packages/descendants/src/Highlight/index.ts index 32c2e6536f..0ac8e5e878 100644 --- a/packages/descendants/src/Highlight/index.ts +++ b/packages/descendants/src/Highlight/index.ts @@ -1,10 +1,11 @@ -export type { +export { Direction, - HighlightChangeHandler, - HighlightContextProps, - HighlightHookReturnType, - Index, - UseHighlightOptions, + type HighlightChangeHandler, + type HighlightContextProps, + type HighlightHookReturnType, + type Index, + Position, + type UseHighlightOptions, } from './highlight.types'; export { createHighlightContext, diff --git a/packages/descendants/src/index.ts b/packages/descendants/src/index.ts index c5c6aada04..5f722dab2b 100644 --- a/packages/descendants/src/index.ts +++ b/packages/descendants/src/index.ts @@ -15,13 +15,14 @@ export { // Highlight export { createHighlightContext, - type Direction, + Direction, type HighlightChangeHandler, type HighlightContextProps, type HighlightContextType, type HighlightHookReturnType, HighlightProvider, type Index, + Position, useHighlight, useHighlightContext, type UseHighlightOptions, diff --git a/packages/wizard/README.md b/packages/wizard/README.md index f6d912208f..e9d23c5f71 100644 --- a/packages/wizard/README.md +++ b/packages/wizard/README.md @@ -1,7 +1,7 @@ - # Wizard ![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/wizard.svg) + #### [View on MongoDB.design](https://www.mongodb.design/component/wizard/live-example/) ## Installation @@ -23,4 +23,3 @@ yarn add @leafygreen-ui/wizard ```shell npm install @leafygreen-ui/wizard ``` - diff --git a/packages/wizard/package.json b/packages/wizard/package.json index 91384840b9..18685e8e0c 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -28,10 +28,17 @@ "access": "public" }, "dependencies": { + "@leafygreen-ui/button": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", + "@leafygreen-ui/form-footer": "workspace:^", "@leafygreen-ui/lib": "workspace:^", + "@leafygreen-ui/typography": "workspace:^", "@lg-tools/test-harnesses": "workspace:^" }, + "devDependencies" : { + "@leafygreen-ui/icon": "workspace:^", + "@faker-js/faker": "^8.0.0" + }, "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/wizard", "repository": { "type": "git", diff --git a/packages/wizard/src/Wizard.stories.tsx b/packages/wizard/src/Wizard.stories.tsx index 4c4b56cf46..d920911775 100644 --- a/packages/wizard/src/Wizard.stories.tsx +++ b/packages/wizard/src/Wizard.stories.tsx @@ -1,17 +1,111 @@ - +/* eslint-disable no-console */ import React from 'react'; -import { StoryFn } from '@storybook/react'; +import { faker } from '@faker-js/faker'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import Card from '@leafygreen-ui/card'; import { Wizard } from '.'; +faker.seed(0); + export default { - title: 'Components/Wizard', + title: 'Composition/Data Display/Wizard', component: Wizard, -} - -const Template: StoryFn = (props) => ( - -); + parameters: { + default: 'LiveExample', + }, + decorators: [ + Fn => ( +
+ +
+ ), + ], +} satisfies StoryMetaType; -export const Basic = Template.bind({}); +export const LiveExample: StoryObj = { + parameters: { + controls: { + exclude: ['children', 'activeStep', 'onStepChange'], + }, + }, + render: props => ( + + {['Apple', 'Banana', 'Carrot'].map((title, i) => ( + + {faker.lorem.paragraph(10)} + + ))} + console.log('[Storybook] Clicked Back'), + }} + cancelButtonProps={{ + children: 'Cancel', + onClick: () => console.log('[Storybook] Clicked Cancel'), + }} + primaryButtonProps={{ + children: 'Primary', + onClick: () => console.log('[Storybook] Clicked Primary'), + }} + /> + + ), +}; +export const Controlled: StoryObj = { + parameters: { + controls: { + exclude: ['children', 'onStepChange'], + }, + }, + args: { + activeStep: 0, + }, + render: ({ activeStep, ...props }) => { + return ( + + console.log(`[Storybook] activeStep should change to ${x}`) + } + {...props} + > + {['Apple', 'Banana', 'Carrot'].map((title, i) => ( + + +

+ This Wizard is controlled. Clicking the buttons will not do + anything. Use the Storybook controls to see the next step +

+ {faker.lorem.paragraph(10)} +
+
+ ))} + console.log('[Storybook] Clicked Back'), + }} + cancelButtonProps={{ + children: 'Cancel', + onClick: () => console.log('[Storybook] Clicked Cancel'), + }} + primaryButtonProps={{ + children: 'Primary', + onClick: () => console.log('[Storybook] Clicked Primary'), + }} + /> +
+ ); + }, +}; diff --git a/packages/wizard/src/Wizard/Wizard.spec.tsx b/packages/wizard/src/Wizard/Wizard.spec.tsx index 07591fd2e6..da37f7f7b1 100644 --- a/packages/wizard/src/Wizard/Wizard.spec.tsx +++ b/packages/wizard/src/Wizard/Wizard.spec.tsx @@ -1,11 +1,249 @@ - import React from 'react'; import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Wizard } from '.'; describe('packages/wizard', () => { - test('condition', () => { + describe('rendering', () => { + test('renders first Wizard.Step', () => { + const { getByText, getByTestId, queryByText, queryByTestId } = render( + + +
Step 1 content
+
+ +
Step 2 content
+
+
, + ); + expect(getByTestId('step-1-content')).toBeInTheDocument(); + expect(queryByTestId('step-2-content')).not.toBeInTheDocument(); + }); + + test('renders Wizard.Footer', () => { + const { getByTestId } = render( + + +
Content
+
+ +
, + ); + + expect(getByTestId('wizard-footer')).toBeInTheDocument(); + }); + + test('does not render any other elements', () => { + const { queryByTestId } = render( + +
This should not render
+
, + ); + + // Non-wizard elements should not be rendered + expect(queryByTestId('invalid-element-1')).not.toBeInTheDocument(); + }); + + test('renders correct step when activeStep is provided', () => { + const { queryByTestId, getByTestId } = render( + + +
Step 1 content
+
+ +
Step 2 content
+
+
, + ); + + // Should render the second step when activeStep is 1 + expect(queryByTestId('step-1-content')).not.toBeInTheDocument(); + expect(getByTestId('step-2-content')).toBeInTheDocument(); + }); + + test('does not render back button on first step', () => { + const { queryByRole, getByRole } = render( + + +
Content 1
+
+ +
Content 2
+
+ +
, + ); + + // Back button should not be rendered on first step + expect(queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + expect(getByRole('button', { name: 'Next' })).toBeInTheDocument(); + }); + + test('renders back button on second step', () => { + const { getByRole } = render( + + +
Content 1
+
+ +
Content 2
+
+ +
, + ); + + expect(getByRole('button', { name: 'Back' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Next' })).toBeInTheDocument(); + }); + }); + + describe('interaction', () => { + test('calls `onStepChange` when incrementing step', async () => { + const onStepChange = jest.fn(); + + const { getByRole } = render( + + +
Content 1
+
+ +
Content 2
+
+ +
, + ); + + await userEvent.click(getByRole('button', { name: 'Next' })); + + expect(onStepChange).toHaveBeenCalledWith(1); + }); + + test('calls `onStepChange` when decrementing step', async () => { + const onStepChange = jest.fn(); + + const { getByRole } = render( + + +
Content 1
+
+ +
Content 2
+
+ +
, + ); + + await userEvent.click(getByRole('button', { name: 'Back' })); + + expect(onStepChange).toHaveBeenCalledWith(0); + }); + + test('calls custom button onClick handlers', async () => { + const onStepChange = jest.fn(); + const onBackClick = jest.fn(); + const onPrimaryClick = jest.fn(); + const onCancelClick = jest.fn(); + + const { getByRole } = render( + + +
Content 1
+
+ +
Content 2
+
+ +
, + ); + + await userEvent.click(getByRole('button', { name: 'Back' })); + expect(onBackClick).toHaveBeenCalled(); + expect(onStepChange).toHaveBeenCalledWith(0); + + await userEvent.click(getByRole('button', { name: 'Next' })); + expect(onPrimaryClick).toHaveBeenCalled(); + expect(onStepChange).toHaveBeenCalledWith(1); + + await userEvent.click(getByRole('button', { name: 'Cancel' })); + expect(onCancelClick).toHaveBeenCalled(); + }); + + describe('uncontrolled', () => { + test('does not increment step beyond Steps count', async () => { + const { getByText, queryByText, getByRole, queryByRole } = render( + + +
Content 1
+
+ +
Content 2
+
+ +
, + ); + + // Start at step 1 + expect(getByText('Step 1')).toBeInTheDocument(); + + // Click next to go to step 2 + await userEvent.click(getByRole('button', { name: 'Next' })); + expect(getByText('Step 2')).toBeInTheDocument(); + expect(queryByText('Step 1')).not.toBeInTheDocument(); + + // Click next again - should stay at step 2 (last step) + await userEvent.click(getByRole('button', { name: 'Next' })); + expect(getByText('Step 2')).toBeInTheDocument(); + expect(queryByText('Step 1')).not.toBeInTheDocument(); + }); + }); + + describe('controlled', () => { + test('does not change steps internally when controlled', async () => { + const onStepChange = jest.fn(); + + const { getByText, queryByText, getByRole, queryByRole } = render( + + +
Content 1
+
+ +
Content 2
+
+ +
, + ); + + // Should start at step 1 + expect(getByText('Step 1')).toBeInTheDocument(); + + // Click next + await userEvent.click(getByRole('button', { name: 'Next' })); + + // Should still be at step 1 since it's controlled + expect(getByText('Step 1')).toBeInTheDocument(); + expect(queryByText('Step 2')).not.toBeInTheDocument(); - }) -}) + // But onStepChange should have been called + expect(onStepChange).toHaveBeenCalledWith(1); + }); + }); + }); +}); diff --git a/packages/wizard/src/Wizard/Wizard.styles.ts b/packages/wizard/src/Wizard/Wizard.styles.ts index 928608f58d..c6ca33aaee 100644 --- a/packages/wizard/src/Wizard/Wizard.styles.ts +++ b/packages/wizard/src/Wizard/Wizard.styles.ts @@ -1,4 +1,15 @@ - import { css } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +export const wizardContainerStyles = css` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: ${spacing[600]}px; +`; -export const baseStyles = css``; +export const stepContentStyles = css` + flex: 1; + min-height: 0; /* Allow content to shrink */ +`; diff --git a/packages/wizard/src/Wizard/Wizard.tsx b/packages/wizard/src/Wizard/Wizard.tsx index 112fe70c75..350f5fd3e0 100644 --- a/packages/wizard/src/Wizard/Wizard.tsx +++ b/packages/wizard/src/Wizard/Wizard.tsx @@ -1,8 +1,72 @@ -import React from 'react'; +import React, { Children, isValidElement } from 'react'; + +import { Direction } from '@leafygreen-ui/descendants'; +import { findChild, findChildren } from '@leafygreen-ui/lib'; + +import { useWizardControlledValue } from '../utils/useWizardControlledValue/useWizardControlledValue'; +import { WizardContext } from '../WizardContext'; +import { WizardFooter } from '../WizardFooter'; +import { WizardStep } from '../WizardStep'; + +import { stepContentStyles, wizardContainerStyles } from './Wizard.styles'; import { WizardProps } from './Wizard.types'; +import { WizardSubComponentProperties } from '../constants'; + +export function Wizard({ + activeStep: activeStepProp, + onStepChange, + children, + ...rest +}: WizardProps) { + // Controlled/Uncontrolled activeStep value + const { + isControlled, + value: activeStep, + setValue: setActiveStep, + } = useWizardControlledValue(activeStepProp, undefined, 0); + + const stepChildren = findChildren( + children, + WizardSubComponentProperties.Step, + ); + const footerChild = findChild(children, WizardSubComponentProperties.Footer); + + const updateStep = (direction: Direction) => { + const getNextStep = (curr: number) => { + switch (direction) { + case Direction.Next: + return Math.min(curr + 1, stepChildren.length - 1); + case Direction.Prev: + return Math.max(curr - 1, 0); + } + }; + + if (!isControlled) { + setActiveStep(getNextStep); + } + + onStepChange?.(getNextStep(activeStep)); + }; + + // Get the current step to render + const currentStep = stepChildren[activeStep] || null; -export function Wizard({}: WizardProps) { - return
your content here
; + return ( + +
+
{currentStep}
+ {footerChild} +
+
+ ); } Wizard.displayName = 'Wizard'; +Wizard.Step = WizardStep; +Wizard.Footer = WizardFooter; diff --git a/packages/wizard/src/Wizard/Wizard.types.ts b/packages/wizard/src/Wizard/Wizard.types.ts index cfa270475f..7fc1a3901a 100644 --- a/packages/wizard/src/Wizard/Wizard.types.ts +++ b/packages/wizard/src/Wizard/Wizard.types.ts @@ -1 +1,28 @@ -export interface WizardProps {} \ No newline at end of file +import { ComponentPropsWithRef, ReactNode } from 'react'; + +import { WizardFooter } from '../WizardFooter'; +import { WizardStep } from '../WizardStep'; + +export interface WizardProps extends ComponentPropsWithRef<'div'> { + /** + * The current active step index (0-based). If provided, the component operates in controlled mode. + */ + activeStep?: number; + + /** + * Callback fired when the active step changes + */ + onStepChange?: (step: number) => void; + + /** + * The wizard steps and footer as children + */ + children: ReactNode; +} + +export interface WizardComponent { + (props: WizardProps): JSX.Element; + Step: typeof WizardStep; + Footer: typeof WizardFooter; + displayName: string; +} diff --git a/packages/wizard/src/Wizard/index.ts b/packages/wizard/src/Wizard/index.ts index 82aa8f69a6..a6d6cd5342 100644 --- a/packages/wizard/src/Wizard/index.ts +++ b/packages/wizard/src/Wizard/index.ts @@ -1,3 +1,2 @@ - -export { Wizard } from './Wizard'; -export { type WizardProps } from './Wizard.types'; +export { Wizard } from './Wizard'; +export { type WizardComponent, type WizardProps } from './Wizard.types'; diff --git a/packages/wizard/src/WizardContext/WizardContext.ts b/packages/wizard/src/WizardContext/WizardContext.ts new file mode 100644 index 0000000000..5bb84163f2 --- /dev/null +++ b/packages/wizard/src/WizardContext/WizardContext.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; + +import { Direction } from '@leafygreen-ui/descendants'; + +export interface WizardContextData { + isWizardContext: boolean; + activeStep: number; + updateStep: (direction: Direction) => void; +} + +export const WizardContext = createContext({ + isWizardContext: false, + activeStep: 0, + updateStep: () => {}, +}); + +export const useWizardContext = () => useContext(WizardContext); diff --git a/packages/wizard/src/WizardContext/index.ts b/packages/wizard/src/WizardContext/index.ts new file mode 100644 index 0000000000..c2c46e1543 --- /dev/null +++ b/packages/wizard/src/WizardContext/index.ts @@ -0,0 +1,5 @@ +export { + useWizardContext, + WizardContext, + type WizardContextData, +} from './WizardContext'; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx index f0081b35c3..3437d27b5c 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx @@ -1,11 +1,34 @@ - import React from 'react'; import { render } from '@testing-library/react'; import { WizardFooter } from '.'; +import { Wizard } from '../Wizard'; describe('packages/wizard-footer', () => { - test('condition', () => { + test('does not render outside WizardContext', () => { + const { container } = render( + + Content + , + ); + + expect(container.firstChild).toBeNull(); + }); + test('renders in WizardContext', () => { + const { getByTestId } = render( + + + Content + + , + ); - }) -}) + expect(getByTestId('footer')).toBeInTheDocument(); + }); +}); diff --git a/packages/wizard/src/WizardFooter/WizardFooter.stories.tsx b/packages/wizard/src/WizardFooter/WizardFooter.stories.tsx new file mode 100644 index 0000000000..17811859c9 --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.stories.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import { Variant } from '@leafygreen-ui/button'; +import Icon, { glyphs } from '@leafygreen-ui/icon'; + +import { WizardFooter, type WizardFooterProps } from '.'; + +type PrimaryButtonVariant = + Required['primaryButtonProps']['variant']; +interface StoryArgs { + backButtonText: string; + backButtonIcon: keyof typeof glyphs; + cancelButtonText: string; + primaryButtonText: string; + primaryButtonIcon: keyof typeof glyphs; + primaryButtonVariant: PrimaryButtonVariant; +} + +const meta: StoryMetaType = { + title: 'Components/Wizard/WizardFooter', + component: WizardFooter, + parameters: { + default: 'LiveExample', + controls: { + exclude: ['backButtonProps', 'cancelButtonProps', 'primaryButtonProps'], + }, + }, + args: {}, + argTypes: { + backButtonText: { control: 'text' }, + backButtonIcon: { control: 'select', options: Object.keys(glyphs) }, + cancelButtonText: { control: 'text' }, + primaryButtonText: { control: 'text' }, + primaryButtonIcon: { control: 'select', options: Object.keys(glyphs) }, + primaryButtonVariant: { + control: 'select', + options: [Variant.Primary, Variant.Danger], + }, + }, +}; + +export default meta; + +export const LiveExample: StoryObj = { + args: { + backButtonText: 'Back', + backButtonIcon: 'ArrowLeft', + cancelButtonText: 'Cancel', + primaryButtonText: 'Continue', + primaryButtonIcon: 'Ellipsis', + primaryButtonVariant: Variant.Primary, + }, + render: args => ( + + ) : undefined, + children: args.backButtonText, + }} + cancelButtonProps={{ + children: args.cancelButtonText, + }} + primaryButtonProps={{ + leftGlyph: args.primaryButtonIcon ? ( + + ) : undefined, + children: args.primaryButtonText, + variant: args.primaryButtonVariant, + }} + /> + ), +}; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.styles.ts b/packages/wizard/src/WizardFooter/WizardFooter.styles.ts index 928608f58d..90e2e8cc60 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.styles.ts +++ b/packages/wizard/src/WizardFooter/WizardFooter.styles.ts @@ -1,4 +1,5 @@ - import { css } from '@leafygreen-ui/emotion'; -export const baseStyles = css``; +export const baseStyles = css` + width: 100%; +`; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.tsx b/packages/wizard/src/WizardFooter/WizardFooter.tsx index f0b6c5519a..314808ab3d 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.tsx @@ -1,8 +1,58 @@ -import React from 'react'; +import React, { MouseEventHandler } from 'react'; + +import { Direction } from '@leafygreen-ui/descendants'; +import FormFooter from '@leafygreen-ui/form-footer'; + +import { useWizardContext } from '../WizardContext'; + import { WizardFooterProps } from './WizardFooter.types'; +import { WizardSubComponentProperties } from '../constants'; +import { consoleOnce } from '@leafygreen-ui/lib'; + +export const WizardFooter = ({ + backButtonProps, + cancelButtonProps, + primaryButtonProps, + ...rest +}: WizardFooterProps) => { + const { isWizardContext, activeStep, updateStep } = useWizardContext(); + + const handleBackButtonClick: MouseEventHandler = e => { + updateStep(Direction.Prev); + backButtonProps?.onClick?.(e); + }; + + const handlePrimaryButtonClick: MouseEventHandler = e => { + updateStep(Direction.Next); + primaryButtonProps.onClick?.(e); + }; + + if (!isWizardContext) { + consoleOnce.error( + 'Wizard.Footer component must be used within a Wizard context.', + ); + return null; + } -export function WizardFooter({}: WizardFooterProps) { - return
your content here
; -} + return ( + 0 + ? { + ...backButtonProps, + onClick: handleBackButtonClick, + } + : undefined + } + cancelButtonProps={cancelButtonProps} + primaryButtonProps={{ + ...primaryButtonProps, + onClick: handlePrimaryButtonClick, + }} + /> + ); +}; WizardFooter.displayName = 'WizardFooter'; +WizardFooter[WizardSubComponentProperties.Footer] = true; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.types.ts b/packages/wizard/src/WizardFooter/WizardFooter.types.ts index 02f3f87b43..cf2617761d 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.types.ts +++ b/packages/wizard/src/WizardFooter/WizardFooter.types.ts @@ -1 +1,18 @@ -export interface WizardFooterProps {} \ No newline at end of file +import { FormFooterProps } from '@leafygreen-ui/form-footer'; + +export interface WizardFooterProps extends React.ComponentProps<'footer'> { + /** + * Props for the back button (left-most button) + */ + backButtonProps?: FormFooterProps['backButtonProps']; + + /** + * Props for the cancel button (center button) + */ + cancelButtonProps?: FormFooterProps['cancelButtonProps']; + + /** + * Props for the primary button (right-most button) + */ + primaryButtonProps: FormFooterProps['primaryButtonProps']; +} diff --git a/packages/wizard/src/WizardFooter/index.ts b/packages/wizard/src/WizardFooter/index.ts index bc9a177cfe..10bb26030a 100644 --- a/packages/wizard/src/WizardFooter/index.ts +++ b/packages/wizard/src/WizardFooter/index.ts @@ -1,3 +1,2 @@ - -export { WizardFooter } from './WizardFooter'; +export { WizardFooter } from './WizardFooter'; export { type WizardFooterProps } from './WizardFooter.types'; diff --git a/packages/wizard/src/WizardStep/TextNode.tsx b/packages/wizard/src/WizardStep/TextNode.tsx new file mode 100644 index 0000000000..e4c5ee3410 --- /dev/null +++ b/packages/wizard/src/WizardStep/TextNode.tsx @@ -0,0 +1,31 @@ +import React, { PropsWithChildren } from 'react'; +import { Polymorph, PolymorphicAs } from '@leafygreen-ui/polymorphic'; + +/** + * Wraps a string in the provided `as` component, + * or renders the provided `ReactNode`. + * + * Useful when rendering `children` props that can be any react node + * + * @example + * ``` + * Hello! //

Hello!

+ * ``` + * + * @example + * ``` + *

Hello!

//

Hello!

+ * ``` + * + */ +// TODO: Move to `Typography` +export const TextNode = ({ + children, + as, +}: PropsWithChildren<{ as?: PolymorphicAs }>) => { + return typeof children === 'string' || typeof children === 'number' ? ( + {children} + ) : ( + children + ); +}; diff --git a/packages/wizard/src/WizardStep/WizardStep.spec.tsx b/packages/wizard/src/WizardStep/WizardStep.spec.tsx index fb00cde028..2f1dab139e 100644 --- a/packages/wizard/src/WizardStep/WizardStep.spec.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.spec.tsx @@ -1,11 +1,28 @@ - import React from 'react'; import { render } from '@testing-library/react'; import { WizardStep } from '.'; +import { Wizard } from '../Wizard/Wizard'; describe('packages/wizard-step', () => { - test('condition', () => { + test('does not render outside WizardContext', () => { + const { container } = render( + + Content + , + ); + + expect(container.firstChild).toBeNull(); + }); + test('renders in WizardContext', () => { + const { getByTestId } = render( + + + Content + + , + ); - }) -}) + expect(getByTestId('step-1')).toBeInTheDocument(); + }); +}); diff --git a/packages/wizard/src/WizardStep/WizardStep.stories.tsx b/packages/wizard/src/WizardStep/WizardStep.stories.tsx new file mode 100644 index 0000000000..fade5bcd60 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.stories.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { + storybookArgTypes, + StoryMetaType, + StoryType, +} from '@lg-tools/storybook-utils'; + +import { WizardStep } from '.'; + +const meta: StoryMetaType = { + title: 'Components/Wizard/WizardStep', + component: WizardStep, + parameters: { + default: 'LiveExample', + }, + argTypes: { + title: storybookArgTypes.children, + description: storybookArgTypes.children, + children: storybookArgTypes.children, + }, +}; + +export default meta; + +const Template: StoryType = args => ; + +export const LiveExample = Template.bind({}); +LiveExample.args = { + title: 'Step 1: Basic Information', + description: 'Please provide your basic information to get started.', + children: ( +
+

This is the content of the step.

+

You can include forms, instructions, or any other content here.

+
+ ), +}; + +export const WithLongDescription = Template.bind({}); +WithLongDescription.args = { + title: 'Step 2: Detailed Configuration', + description: ( +
+

+ This step involves more complex configuration options. Please read + carefully before proceeding. +

+
    +
  • Configure your primary settings
  • +
  • Set up your preferences
  • +
  • Review the terms and conditions
  • +
+
+ ), + children: ( +
+

Complex form content would go here...

+ +
+ ), +}; + +export const MinimalStep = Template.bind({}); +MinimalStep.args = { + title: 'Final Step', + description: 'Review and submit.', + children:

Simple content for the final step.

, +}; + +export const WithoutDescription = Template.bind({}); +WithoutDescription.args = { + title: 'Step Without Description', + children: ( +
+

This step doesn't have a description.

+

Sometimes you may want to omit the description for simpler steps.

+
+ ), +}; diff --git a/packages/wizard/src/WizardStep/WizardStep.styles.ts b/packages/wizard/src/WizardStep/WizardStep.styles.ts index 928608f58d..b38acdf587 100644 --- a/packages/wizard/src/WizardStep/WizardStep.styles.ts +++ b/packages/wizard/src/WizardStep/WizardStep.styles.ts @@ -1,4 +1,6 @@ - import { css } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; -export const baseStyles = css``; +export const stepStyles = css` + padding: 0 ${spacing[1800]}px; +`; diff --git a/packages/wizard/src/WizardStep/WizardStep.tsx b/packages/wizard/src/WizardStep/WizardStep.tsx index 6c699df9e8..4b28109c1b 100644 --- a/packages/wizard/src/WizardStep/WizardStep.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.tsx @@ -1,8 +1,37 @@ import React from 'react'; + +import { Description, H3 } from '@leafygreen-ui/typography'; + +import { stepStyles } from './WizardStep.styles'; import { WizardStepProps } from './WizardStep.types'; +import { WizardSubComponentProperties } from '../constants'; +import { TextNode } from './TextNode'; +import { useWizardContext } from '../WizardContext'; +import { consoleOnce } from '@leafygreen-ui/lib'; + +export function WizardStep({ + title, + description, + children, + ...rest +}: WizardStepProps) { + const { isWizardContext } = useWizardContext(); + + if (!isWizardContext) { + consoleOnce.error( + 'Wizard.Step component must be used within a Wizard context.', + ); + return null; + } -export function WizardStep({}: WizardStepProps) { - return
your content here
; + return ( +
+ {title} + {description && {description}} +
{children}
+
+ ); } WizardStep.displayName = 'WizardStep'; +WizardStep[WizardSubComponentProperties.Step] = true; diff --git a/packages/wizard/src/WizardStep/WizardStep.types.ts b/packages/wizard/src/WizardStep/WizardStep.types.ts index 3998534991..b0e9e97f70 100644 --- a/packages/wizard/src/WizardStep/WizardStep.types.ts +++ b/packages/wizard/src/WizardStep/WizardStep.types.ts @@ -1 +1,19 @@ -export interface WizardStepProps {} \ No newline at end of file +import { ReactNode } from 'react'; + +export interface WizardStepProps + extends Omit, 'title'> { + /** + * The title of the step + */ + title: ReactNode; + + /** + * The description of the step + */ + description?: ReactNode; + + /** + * The content of the step + */ + children: ReactNode; +} diff --git a/packages/wizard/src/WizardStep/index.ts b/packages/wizard/src/WizardStep/index.ts index 866f9c3f6c..f7e0b02596 100644 --- a/packages/wizard/src/WizardStep/index.ts +++ b/packages/wizard/src/WizardStep/index.ts @@ -1,3 +1,2 @@ - -export { WizardStep } from './WizardStep'; +export { WizardStep } from './WizardStep'; export { type WizardStepProps } from './WizardStep.types'; diff --git a/packages/wizard/src/constants.ts b/packages/wizard/src/constants.ts new file mode 100644 index 0000000000..38d0121456 --- /dev/null +++ b/packages/wizard/src/constants.ts @@ -0,0 +1,6 @@ +export const WizardSubComponentProperties = { + Step: 'isWizardStep', + Footer: 'isWizardFooter', +} as const; +export type WizardSubComponentProperties = + (typeof WizardSubComponentProperties)[keyof typeof WizardSubComponentProperties]; diff --git a/packages/wizard/src/index.ts b/packages/wizard/src/index.ts index cfbd7d46d8..1d5270af64 100644 --- a/packages/wizard/src/index.ts +++ b/packages/wizard/src/index.ts @@ -1 +1,8 @@ -export { Wizard, type WizardProps } from './Wizard'; \ No newline at end of file +export { Wizard, type WizardProps } from './Wizard'; +export { + useWizardContext, + WizardContext, + type WizardContextData, +} from './WizardContext'; +export { type WizardFooterProps } from './WizardFooter'; +export { type WizardStepProps } from './WizardStep'; diff --git a/packages/wizard/src/testing/getTestUtils.spec.tsx b/packages/wizard/src/testing/getTestUtils.spec.tsx index 99117014a5..6e928dca63 100644 --- a/packages/wizard/src/testing/getTestUtils.spec.tsx +++ b/packages/wizard/src/testing/getTestUtils.spec.tsx @@ -4,7 +4,5 @@ import { render } from '@testing-library/react'; import { Wizard } from '.'; describe('packages/wizard/getTestUtils', () => { - test('condition', () => { - - }) -}) + test('condition', () => {}); +}); diff --git a/packages/wizard/src/testing/getTestUtils.types.ts b/packages/wizard/src/testing/getTestUtils.types.ts index 50d2fb417a..4b2df87c73 100644 --- a/packages/wizard/src/testing/getTestUtils.types.ts +++ b/packages/wizard/src/testing/getTestUtils.types.ts @@ -1 +1 @@ -export interface TestUtilsReturnType {} \ No newline at end of file +export interface TestUtilsReturnType {} diff --git a/packages/wizard/src/utils/useWizardControlledValue/index.ts b/packages/wizard/src/utils/useWizardControlledValue/index.ts new file mode 100644 index 0000000000..07ea91dbb7 --- /dev/null +++ b/packages/wizard/src/utils/useWizardControlledValue/index.ts @@ -0,0 +1 @@ +export { useWizardControlledValue } from './useWizardControlledValue'; diff --git a/packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts b/packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts new file mode 100644 index 0000000000..1081c3691c --- /dev/null +++ b/packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts @@ -0,0 +1,96 @@ +import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'; +import isUndefined from 'lodash/isUndefined'; + +import { usePrevious } from '@leafygreen-ui/hooks'; +import { consoleOnce } from '@leafygreen-ui/lib'; + +interface ControlledValueReturnObject { + /** Whether the value is controlled */ + isControlled: boolean; + + /** The controlled or uncontrolled value */ + value: T; + + /** + * Either updates the uncontrolled value, + * or calls the provided `onChange` callback + */ + setValue: Dispatch>; +} + +/** + * A hook that enables a component to be both controlled or uncontrolled. + * + * Returns a {@link ControlledValueReturnObject} + * @deprecated Use `useControlled` from `@leafygreen-ui/hooks` instead + * https://github.com/mongodb/leafygreen-ui/pull/3153 + */ +export const useWizardControlledValue = ( + valueProp?: T, + onChange?: (val?: T, ...args: Array) => void, + initialProp?: T, +): ControlledValueReturnObject => { + // Initially set isControlled to the existence of `valueProp`. + // If the value prop changes from undefined to something defined, + // then isControlled is set to true, + // and will remain true for the life of the component + const [isControlled, setControlled] = useState(!isUndefined(valueProp)); + useEffect(() => { + setControlled(isControlled || !isUndefined(valueProp)); + }, [isControlled, valueProp]); + + const wasControlled = usePrevious(isControlled); + + useEffect(() => { + if (isUndefined(isControlled) || isUndefined(wasControlled)) return; + + if (isControlled !== wasControlled) { + const err = `WARN: A component changed from ${ + wasControlled ? 'controlled' : 'uncontrolled' + } to ${ + isControlled ? 'controlled' : 'uncontrolled' + }. This can cause issues with React states. ${ + isControlled + ? 'To control a component, but have an initially empty input, consider setting the `value` prop to `null`.' + : '' + }`; + + consoleOnce.warn(err); + } + }, [isControlled, wasControlled]); + + // We set the initial value to either the `value` + // or the temporary `initialValue` prop + const initialValue: T = useMemo( + () => (isControlled ? (valueProp as T) : (initialProp as T)), + [initialProp, isControlled, valueProp], + ); + + // Keep track of the internal value state + const [uncontrolledValue, setUncontrolledValue] = useState( + initialValue as T, + ); + + // The returned value is wither the provided value prop + // or the uncontrolled value + const value = useMemo( + () => (isControlled ? (valueProp as T) : (uncontrolledValue as T)), + [isControlled, uncontrolledValue, valueProp], + ); + + // A wrapper around `handleChange` that fires a simulated event + const setValue: Dispatch> = newVal => { + if (!isControlled) { + setUncontrolledValue(newVal); + } + + const val = typeof newVal === 'function' ? (newVal as Function)() : newVal; + onChange?.(val); + }; + + return { + isControlled, + value, + setValue, + }; +}; diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json index 5a0f368e7f..d245893b68 100644 --- a/packages/wizard/tsconfig.json +++ b/packages/wizard/tsconfig.json @@ -9,9 +9,15 @@ "include": ["src/**/*"], "exclude": ["**/*.spec.*", "**/*.stories.*"], "references": [ + { + "path": "../button" + }, { "path": "../emotion" }, + { + "path": "../form-footer" + }, { "path": "../lib" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4e76f3070..0fd8eb3ad3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3792,15 +3792,31 @@ importers: packages/wizard: dependencies: + '@leafygreen-ui/button': + specifier: workspace:^ + version: link:../button '@leafygreen-ui/emotion': specifier: workspace:^ version: link:../emotion + '@leafygreen-ui/form-footer': + specifier: workspace:^ + version: link:../form-footer '@leafygreen-ui/lib': specifier: workspace:^ version: link:../lib + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography '@lg-tools/test-harnesses': specifier: workspace:^ version: link:../../tools/test-harnesses + devDependencies: + '@faker-js/faker': + specifier: ^8.0.0 + version: 8.0.2 + '@leafygreen-ui/icon': + specifier: workspace:^ + version: link:../icon tools/build: dependencies: