diff --git a/.changeset/form-footer-interfaces.md b/.changeset/form-footer-interfaces.md new file mode 100644 index 0000000000..5012b2b263 --- /dev/null +++ b/.changeset/form-footer-interfaces.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/form-footer': minor +--- + +Exports button prop interfaces diff --git a/.changeset/wizard.md b/.changeset/wizard.md index 9d2bf9262d..43a87b291f 100644 --- a/.changeset/wizard.md +++ b/.changeset/wizard.md @@ -2,4 +2,6 @@ '@leafygreen-ui/wizard': minor --- -Initial Wizard package release +Initial Wizard package release. + +See [README.md](./README.md) for usage guidelines \ No newline at end of file diff --git a/packages/form-footer/src/FormFooter.types.ts b/packages/form-footer/src/FormFooter.types.ts index ae516c4387..8ea2f29de3 100644 --- a/packages/form-footer/src/FormFooter.types.ts +++ b/packages/form-footer/src/FormFooter.types.ts @@ -20,26 +20,30 @@ type OmittedSplitButtonProps = Omit< 'children' | 'variant' >; -type BackStandardButtonProps = ButtonPropsOmittingVariant & { +export type BackStandardButtonProps = ButtonPropsOmittingVariant & { variant?: Extract; }; -type BackSplitButtonProps = OmittedSplitButtonProps & { +export type BackSplitButtonProps = OmittedSplitButtonProps & { variant?: Extract; }; -type BackButtonProps = BackStandardButtonProps | BackSplitButtonProps; +export type BackButtonProps = BackStandardButtonProps | BackSplitButtonProps; -type CancelStandardButtonProps = ButtonPropsOmittingVariant; -type CancelSplitButtonProps = OmittedSplitButtonProps; -type CancelButtonProps = CancelStandardButtonProps | CancelSplitButtonProps; +export type CancelStandardButtonProps = ButtonPropsOmittingVariant; +export type CancelSplitButtonProps = OmittedSplitButtonProps; +export type CancelButtonProps = + | CancelStandardButtonProps + | CancelSplitButtonProps; -type PrimaryStandardButtonProps = ButtonPropsOmittingVariant & +export type PrimaryStandardButtonProps = ButtonPropsOmittingVariant & ButtonPropsWithRequiredChildren & { variant?: Extract; }; -type PrimarySplitButtonProps = OmittedSplitButtonProps & { +export type PrimarySplitButtonProps = OmittedSplitButtonProps & { variant?: Extract; }; -type PrimaryButtonProps = PrimaryStandardButtonProps | PrimarySplitButtonProps; +export type PrimaryButtonProps = + | PrimaryStandardButtonProps + | PrimarySplitButtonProps; export interface FormFooterProps extends React.ComponentProps<'footer'>, diff --git a/packages/form-footer/src/index.ts b/packages/form-footer/src/index.ts index e78a13d456..86271bbc7c 100644 --- a/packages/form-footer/src/index.ts +++ b/packages/form-footer/src/index.ts @@ -5,5 +5,16 @@ export { default, default as FormFooter, } from './FormFooter'; -export { type FormFooterProps } from './FormFooter.types'; +export { + type BackButtonProps, + type BackSplitButtonProps, + type BackStandardButtonProps, + type CancelButtonProps, + type CancelSplitButtonProps, + type CancelStandardButtonProps, + type FormFooterProps, + type PrimaryButtonProps, + type PrimarySplitButtonProps, + type PrimaryStandardButtonProps, +} from './FormFooter.types'; export { getLgIds } from './utils'; diff --git a/packages/wizard/README.md b/packages/wizard/README.md index e9d23c5f71..cf9cf69046 100644 --- a/packages/wizard/README.md +++ b/packages/wizard/README.md @@ -23,3 +23,88 @@ yarn add @leafygreen-ui/wizard ```shell npm install @leafygreen-ui/wizard ``` + +```tsx + + +
Step 1 contents
+ + + + +
Step 2 contents
+ + + +``` + +### Wizard + +The `Wizard` component establishes a context with an internal state, and will render only the `activeStep`. + +You can also control the Wizard externally using the `activeStep` and `onStepChange` callback. + +```tsx + { + /* do something */ + }} +/> +``` + +Note: When the `activeStep` is externally controlled, ensure that the provided `activeStep` index is valid relative to the count of steps available. If the zero-indexed `activeStep` value exceeds the count of steps provided (or is negative), nothing will render inside the Wizard. (i.e. passing `activeStep={2}` to a Wizard with only 2 steps, nothing will render) + +### Wizard.Step + +Defines a discrete step in the wizard. Only the step matching the internal (or provided) `activeStep` index will be displayed. + +Both `Wizard` and `Wizard.Step` are only wrapped in a `Fragment` to allow for more versatile styling. + +#### `requiresAcknowledgement` + +If `requiresAcknowledgement` is true, the step must be acknowledged for the Footer's primary button to be enabled. By default (or when explicitly set to `false`) the primary button will always be enabled. + +To set a step to be acknowledged, call `setIsAcknowledged` provided from the `useWizardStepContext` hook. +e.g. + +```tsx +// App.tsx + + + +; + +// MyWizardStepContents.tsx +const MyWizardStepContents = () => { + const { isAcknowledged, setAcknowledged } = useWizardStepContext(); + + return ( + <> + setAcknowledged(e.target.checked)} + /> + + ); +}; +``` + +### Wizard.Footer + +The `Wizard.Footer` is a convenience wrapper around the `FormFooter` component. Each step should render its own Footer component diff --git a/packages/wizard/package.json b/packages/wizard/package.json index be08f12182..b26c4b8c13 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -1,7 +1,6 @@ - { "name": "@leafygreen-ui/wizard", - "version": "0.0.1", + "version": "0.1.0-local.1", "description": "LeafyGreen UI Kit Wizard", "main": "./dist/umd/index.js", "module": "./dist/esm/index.js", @@ -28,21 +27,28 @@ "access": "public" }, "dependencies": { - "@leafygreen-ui/button": "workspace:^", "@leafygreen-ui/compound-component": "workspace:^", - "@leafygreen-ui/descendants": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/form-footer": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/polymorphic": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", - "@leafygreen-ui/typography": "workspace:^", "@lg-tools/test-harnesses": "workspace:^" }, - "devDependencies" : { + "devDependencies": { + "@faker-js/faker": "^8.0.0", + "@leafygreen-ui/button": "workspace:^", + "@leafygreen-ui/badge": "workspace:^", + "@leafygreen-ui/banner": "workspace:^", + "@leafygreen-ui/card": "workspace:^", + "@leafygreen-ui/checkbox": "workspace:^", + "@leafygreen-ui/expandable-card": "workspace:^", "@leafygreen-ui/icon": "workspace:^", - "@faker-js/faker": "^8.0.0" + "@leafygreen-ui/loading-indicator": "workspace:^", + "@leafygreen-ui/segmented-control": "workspace:^", + "@leafygreen-ui/skeleton-loader": "workspace:^", + "@leafygreen-ui/typography": "workspace:^" }, "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/wizard", "repository": { diff --git a/packages/wizard/src/Wizard.stories.tsx b/packages/wizard/src/Wizard.stories.tsx index bd0e73875a..165a55f7ee 100644 --- a/packages/wizard/src/Wizard.stories.tsx +++ b/packages/wizard/src/Wizard.stories.tsx @@ -1,16 +1,44 @@ /* eslint-disable no-console */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { faker } from '@faker-js/faker'; import { StoryMetaType } from '@lg-tools/storybook-utils'; import { StoryObj } from '@storybook/react'; +import { Banner } from '@leafygreen-ui/banner'; import { Card } from '@leafygreen-ui/card'; +import { Checkbox } from '@leafygreen-ui/checkbox'; import { css } from '@leafygreen-ui/emotion'; +import { isDefined } from '@leafygreen-ui/lib'; +import { Body, Description, H3, InlineCode } from '@leafygreen-ui/typography'; -import { Wizard } from '.'; +import { useWizardStepContext, Wizard } from '.'; faker.seed(0); +const ExampleStepConfig = [ + { + title: 'Apple', + description: faker.lorem.paragraph(), + content: faker.lorem.paragraph(10), + requiresAcknowledgement: true, + primaryButtonText: 'Continue', + }, + { + title: 'Banana', + description: faker.lorem.paragraph(), + content: faker.lorem.paragraph(10), + requiresAcknowledgement: false, + primaryButtonText: 'Continue', + }, + { + title: 'Carrot', + description: faker.lorem.paragraph(), + content: faker.lorem.paragraph(10), + requiresAcknowledgement: true, + primaryButtonText: 'Finish', + }, +]; + export default { title: 'Composition/Wizard', component: Wizard, @@ -19,46 +47,96 @@ export default { }, decorators: [ Fn => ( -
+
), ], } satisfies StoryMetaType; +const ExampleContentCard = ({ children }: PropsWithChildren<{}>) => { + const { isAcknowledged, setAcknowledged, requiresAcknowledgement } = + useWizardStepContext(); + + return ( + * { + margin-block: 8px; + } + `} + > + {requiresAcknowledgement && ( + setAcknowledged(e.target.checked)} + /> + )} + {children} + + ); +}; + 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'), - }} - /> + render: args => ( + + console.log(`[Storybook] activeStep should change to ${x}`) + } + {...args} + > + {ExampleStepConfig.map( + ( + { + title, + description, + content, + primaryButtonText, + requiresAcknowledgement, + }, + i, + ) => ( + +

+ Step {i + 1}: {title} +

+ {description} + + {isDefined(args.activeStep) && ( + + activeStep is controlled. Use + Storybook controls to update the step + + )} + {content} + + console.log('[Storybook] Clicked Back'), + }} + cancelButtonProps={{ + children: 'Cancel', + onClick: () => console.log('[Storybook] Clicked Cancel'), + }} + primaryButtonProps={{ + children: primaryButtonText, + onClick: () => console.log('[Storybook] Clicked Primary'), + }} + /> +
+ ), + )}
), }; @@ -72,47 +150,5 @@ export const Controlled: StoryObj = { 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'), - }} - /> -
- ); - }, + render: LiveExample.render, }; diff --git a/packages/wizard/src/Wizard/Wizard.spec.tsx b/packages/wizard/src/Wizard/Wizard.spec.tsx index 31d0906c46..77144652ee 100644 --- a/packages/wizard/src/Wizard/Wizard.spec.tsx +++ b/packages/wizard/src/Wizard/Wizard.spec.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useWizardStepContext } from '../WizardStep'; + import { Wizard } from '.'; describe('packages/wizard', () => { @@ -9,10 +11,10 @@ describe('packages/wizard', () => { test('renders first Wizard.Step', () => { const { getByTestId, queryByTestId } = render( - +
Step 1 content
- +
Step 2 content
, @@ -24,14 +26,14 @@ describe('packages/wizard', () => { test('renders Wizard.Footer', () => { const { getByTestId } = render( - +
Content
+
-
, ); @@ -52,10 +54,10 @@ describe('packages/wizard', () => { test('renders correct step when activeStep is provided', () => { const { queryByTestId, getByTestId } = render( - +
Step 1 content
- +
Step 2 content
, @@ -69,16 +71,20 @@ describe('packages/wizard', () => { test('does not render back button on first step', () => { const { queryByRole, getByRole } = render( - +
Content 1
+
- +
Content 2
+
-
, ); @@ -90,16 +96,20 @@ describe('packages/wizard', () => { test('renders back button on second step', () => { const { getByRole } = render( - +
Content 1
+
- +
Content 2
+
-
, ); @@ -114,13 +124,14 @@ describe('packages/wizard', () => { const { getByRole } = render( - +
Content 1
+
- +
Content 2
+
-
, ); @@ -134,16 +145,20 @@ describe('packages/wizard', () => { const { getByRole } = render( - +
Content 1
+
- +
Content 2
+
-
, ); @@ -160,17 +175,22 @@ describe('packages/wizard', () => { const { getByRole } = render( - +
Content 1
+
- +
Content 2
+
-
, ); @@ -188,30 +208,31 @@ describe('packages/wizard', () => { describe('uncontrolled', () => { test('does not increment step beyond Steps count', async () => { - const { getByText, queryByText, getByRole } = render( + const { getByTestId, queryByTestId, getByRole } = render( - +
Content 1
+
- +
Content 2
+
-
, ); // Start at step 1 - expect(getByText('Step 1')).toBeInTheDocument(); + expect(getByTestId('step-1-content')).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(); + expect(getByTestId('step-2-content')).toBeInTheDocument(); + expect(queryByTestId('step-1-content')).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(); + expect(getByTestId('step-2-content')).toBeInTheDocument(); + expect(queryByTestId('step-1-content')).not.toBeInTheDocument(); }); }); @@ -219,27 +240,28 @@ describe('packages/wizard', () => { test('does not change steps internally when controlled', async () => { const onStepChange = jest.fn(); - const { getByText, queryByText, getByRole } = render( + const { getByTestId, queryByTestId, getByRole } = render( - +
Content 1
+
- +
Content 2
+
-
, ); // Should start at step 1 - expect(getByText('Step 1')).toBeInTheDocument(); + expect(getByTestId('step-1-content')).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(); + expect(getByTestId('step-1-content')).toBeInTheDocument(); + expect(queryByTestId('step-2-content')).not.toBeInTheDocument(); // But onStepChange should have been called expect(onStepChange).toHaveBeenCalledWith(1); @@ -252,10 +274,10 @@ describe('packages/wizard', () => { render( - +
Content 1
- +
Content 2
, @@ -276,10 +298,10 @@ describe('packages/wizard', () => { render( - +
Content 1
- +
Content 2
, @@ -293,5 +315,77 @@ describe('packages/wizard', () => { consoleWarnSpy.mockRestore(); }); }); + + describe('requiresAcknowledgement', () => { + test('disables primary button when requiresAcknowledgement is true and not acknowledged', () => { + const { getByRole } = render( + + +
Content 1
+ +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Next' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + }); + + test('enables primary button when requiresAcknowledgement is true and acknowledged', async () => { + const AcknowledgeButton = () => { + const { setAcknowledged } = useWizardStepContext(); + return ( + + ); + }; + + const { getByRole } = render( + + +
Content 1
+ + +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Next' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + + // Acknowledge the step + const acknowledgeButton = getByRole('button', { name: 'Acknowledge' }); + await userEvent.click(acknowledgeButton); + + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + }); + + test('enables primary button when requiresAcknowledgement is false', () => { + const { getByRole } = render( + + +
Content 1
+ +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Next' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + }); + + test('enables primary button when requiresAcknowledgement is not set (default)', () => { + const { getByRole } = render( + + +
Content 1
+ +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Next' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + }); + }); }); }); diff --git a/packages/wizard/src/Wizard/Wizard.styles.ts b/packages/wizard/src/Wizard/Wizard.styles.ts index c6ca33aaee..a20d2f2a15 100644 --- a/packages/wizard/src/Wizard/Wizard.styles.ts +++ b/packages/wizard/src/Wizard/Wizard.styles.ts @@ -8,8 +8,3 @@ export const wizardContainerStyles = css` flex-direction: column; gap: ${spacing[600]}px; `; - -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 ad5ed5d165..2b2383b5e1 100644 --- a/packages/wizard/src/Wizard/Wizard.tsx +++ b/packages/wizard/src/Wizard/Wizard.tsx @@ -2,10 +2,8 @@ import React, { useCallback } from 'react'; import { CompoundComponent, - findChild, findChildren, } from '@leafygreen-ui/compound-component'; -import { Direction } from '@leafygreen-ui/descendants'; import { useControlled } from '@leafygreen-ui/hooks'; import { WizardSubComponentProperties } from '../constants'; @@ -13,24 +11,14 @@ import { WizardProvider } from '../WizardContext/WizardContext'; import { WizardFooter } from '../WizardFooter'; import { WizardStep } from '../WizardStep'; -import { stepContentStyles, wizardContainerStyles } from './Wizard.styles'; import { WizardProps } from './Wizard.types'; export const Wizard = CompoundComponent( - ({ - activeStep: activeStepProp, - onStepChange, - children, - ...rest - }: WizardProps) => { + ({ activeStep: activeStepProp, onStepChange, children }: WizardProps) => { const stepChildren = findChildren( children, WizardSubComponentProperties.Step, ); - const footerChild = findChild( - children, - WizardSubComponentProperties.Footer, - ); // Controlled/Uncontrolled activeStep value const { value: activeStep, updateValue: setActiveStep } = @@ -48,38 +36,41 @@ export const Wizard = CompoundComponent( } const updateStep = useCallback( - (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); - } - }; - - // TODO pass getNextStep into setter as callback https://jira.mongodb.org/browse/LG-5607 - const nextStep = getNextStep(activeStep); - setActiveStep(nextStep); + (step: number) => { + // Clamp the step value between 0 and stepChildren.length - 1 + const clampedStep = Math.max( + 0, + Math.min(step, stepChildren.length - 1), + ); + setActiveStep(clampedStep); }, - [activeStep, setActiveStep, stepChildren.length], + [setActiveStep, stepChildren.length], ); - // Get the current step to render - const currentStep = stepChildren[activeStep] || null; - return ( - -
-
{currentStep}
- {footerChild} -
+ + {stepChildren.map((child, i) => (i === activeStep ? child : null))} ); }, { displayName: 'Wizard', + /** + * A single step in the wizard. A Wizard will only render Steps as children + */ Step: WizardStep, + + /** + * The footer of a Step component. + * Render this inside of each Step with the relevant button props for that Step. + * + * Back and Primary buttons trigger onStepChange. + * Automatically renders the "Back" button for all Steps except the first + */ Footer: WizardFooter, }, ); diff --git a/packages/wizard/src/Wizard/Wizard.types.ts b/packages/wizard/src/Wizard/Wizard.types.ts index 7fc1a3901a..35d05e8481 100644 --- a/packages/wizard/src/Wizard/Wizard.types.ts +++ b/packages/wizard/src/Wizard/Wizard.types.ts @@ -1,16 +1,19 @@ -import { ComponentPropsWithRef, ReactNode } from 'react'; +import { ReactNode } from 'react'; -import { WizardFooter } from '../WizardFooter'; -import { WizardStep } from '../WizardStep'; - -export interface WizardProps extends ComponentPropsWithRef<'div'> { +export interface WizardProps { /** - * The current active step index (0-based). If provided, the component operates in controlled mode. + * The current active step index (0-based). + * + * If provided, the component operates in controlled mode, and any interaction will not update internal state. + * Use `onStepChange` to update your external state + * + * Note: when controlling this externally, ensure that the provided `activeStep` index is valid relative to the count of steps available. + * If the zero-indexed `activeStep` value exceeds the count of steps provided (or is negative), nothing will render inside the Wizard. */ activeStep?: number; /** - * Callback fired when the active step changes + * Callback fired when the active step changes. */ onStepChange?: (step: number) => void; @@ -19,10 +22,3 @@ export interface WizardProps extends ComponentPropsWithRef<'div'> { */ 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 a6d6cd5342..639f0423de 100644 --- a/packages/wizard/src/Wizard/index.ts +++ b/packages/wizard/src/Wizard/index.ts @@ -1,2 +1,2 @@ export { Wizard } from './Wizard'; -export { type WizardComponent, type WizardProps } from './Wizard.types'; +export { type WizardProps } from './Wizard.types'; diff --git a/packages/wizard/src/WizardContext/WizardContext.tsx b/packages/wizard/src/WizardContext/WizardContext.tsx index c3d50b9d3f..2c29f302ea 100644 --- a/packages/wizard/src/WizardContext/WizardContext.tsx +++ b/packages/wizard/src/WizardContext/WizardContext.tsx @@ -1,30 +1,58 @@ import React, { createContext, PropsWithChildren, useContext } from 'react'; -import { Direction } from '@leafygreen-ui/descendants'; - export interface WizardContextData { + /** + * Defines whether the consuming component is within a Wizard context. + * This is used to log warnings in sub-components that must be rendered as a Wizard child. + */ isWizardContext: boolean; + + /** + * Defines the currently active Wizard.Step. + * Note: when controlling this externally, ensure that the provided `activeStep` index is valid relative to the count of steps available. + * If the zero-indexed `activeStep` value exceeds the count of steps provided (or is negative), nothing will render inside the Wizard. + */ activeStep: number; - updateStep: (direction: Direction) => void; + + /** + * Updates the Wizard `activeStep` to the provided step number. + * Note: The Wizard implementation internally handles clamping the step number + * to the available number of steps + * @param step + * @returns + */ + updateStep: (step: number) => void; + + /** + * @internal + * Internally sets the number of steps in the Wizard + */ + totalSteps: number; } export const WizardContext = createContext({ isWizardContext: false, activeStep: 0, + totalSteps: 0, updateStep: () => {}, }); +interface WizardProviderProps + extends PropsWithChildren> {} + export const WizardProvider = ({ children, activeStep, updateStep, -}: PropsWithChildren>) => { + totalSteps, +}: WizardProviderProps) => { return ( {children} diff --git a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx index 5bf606c5f0..25698458d7 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Wizard } from '../Wizard'; +import { useWizardStepContext } from '../WizardStep'; import { WizardFooter } from '.'; @@ -18,18 +20,233 @@ describe('packages/wizard-footer', () => { expect(container.firstChild).toBeNull(); }); + test('renders in WizardContext', () => { const { getByTestId } = render( - - Content - + + + Content + + , ); expect(getByTestId('footer')).toBeInTheDocument(); }); + + describe('primary button behavior', () => { + test('primary button is enabled by default', () => { + const { getByRole } = render( + + + + + , + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toBeEnabled(); + }); + + test('primary button advances to next step when clicked', async () => { + const onStepChange = jest.fn(); + + const { getByRole, getByTestId } = render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + expect(getByTestId('step-1')).toBeInTheDocument(); + + const nextButton = getByRole('button', { name: 'Next' }); + await userEvent.click(nextButton); + + expect(onStepChange).toHaveBeenCalledWith(1); + expect(getByTestId('step-2')).toBeInTheDocument(); + }); + + describe('requiresAcknowledgement', () => { + test('primary button is disabled when step requires acknowledgement and is not acknowledged', () => { + const { getByRole } = render( + + +
Step content
+ +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + }); + + test('primary button is enabled when step requires acknowledgement and is acknowledged', async () => { + const TestComponent = () => { + const { setAcknowledged } = useWizardStepContext(); + return ( + <> +
Step content
+ + + + ); + }; + + const { getByRole } = render( + + + + + , + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + + await userEvent.click(getByRole('button', { name: 'Acknowledge' })); + + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + }); + + test('primary button is enabled when step does not require acknowledgement', () => { + const { getByRole } = render( + + +
Step content
+ +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + }); + + test('primary button can advance step after acknowledgement', async () => { + const TestComponent = () => { + const { setAcknowledged } = useWizardStepContext(); + return ( + <> +
Step content
+ + + + ); + }; + + const { getByRole, getByTestId } = render( + + +
Step 1
+ +
+ +
Step 2
+
+
, + ); + + expect(getByTestId('step-1')).toBeInTheDocument(); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + + // Acknowledge the step + await userEvent.click(getByRole('button', { name: 'Acknowledge' })); + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + + // Advance to next step + await userEvent.click(primaryButton); + expect(getByTestId('step-2')).toBeInTheDocument(); + }); + }); + }); + + describe('back button', () => { + test('back button is not rendered on first step', () => { + const { queryByRole } = render( + + + + + , + ); + + expect(queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + }); + + test('back button is rendered on subsequent steps', async () => { + const { getByRole } = render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + // Move to step 2 + await userEvent.click(getByRole('button', { name: 'Next' })); + + // Back button should now be visible + expect(getByRole('button', { name: 'Back' })).toBeInTheDocument(); + }); + + test('back button navigates to previous step', async () => { + const onStepChange = jest.fn(); + + const { getByRole, getByTestId } = render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + // Move to step 2 + await userEvent.click(getByRole('button', { name: 'Next' })); + expect(getByTestId('step-2')).toBeInTheDocument(); + + // Go back to step 1 + await userEvent.click(getByRole('button', { name: 'Back' })); + expect(onStepChange).toHaveBeenCalledWith(0); + expect(getByTestId('step-1')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/wizard/src/WizardFooter/WizardFooter.tsx b/packages/wizard/src/WizardFooter/WizardFooter.tsx index 2a46c9201a..5a3ae710b2 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.tsx @@ -1,12 +1,12 @@ import React, { MouseEventHandler } from 'react'; import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; -import { Direction } from '@leafygreen-ui/descendants'; import { FormFooter } from '@leafygreen-ui/form-footer'; import { consoleOnce } from '@leafygreen-ui/lib'; import { WizardSubComponentProperties } from '../constants'; import { useWizardContext } from '../WizardContext'; +import { useWizardStepContext } from '../WizardStep'; import { WizardFooterProps } from './WizardFooter.types'; @@ -19,16 +19,21 @@ export const WizardFooter = CompoundSubComponent( ...rest }: WizardFooterProps) => { const { isWizardContext, activeStep, updateStep } = useWizardContext(); + const { isAcknowledged, requiresAcknowledgement } = useWizardStepContext(); + const isPrimaryButtonDisabled = + (requiresAcknowledgement && !isAcknowledged) || + primaryButtonProps.disabled || + false; const handleBackButtonClick: MouseEventHandler = e => { - updateStep(Direction.Prev); + updateStep(activeStep - 1); backButtonProps?.onClick?.(e); }; const handlePrimaryButtonClick: MouseEventHandler< HTMLButtonElement > = e => { - updateStep(Direction.Next); + updateStep(activeStep + 1); primaryButtonProps.onClick?.(e); }; @@ -54,6 +59,7 @@ export const WizardFooter = CompoundSubComponent( cancelButtonProps={cancelButtonProps} primaryButtonProps={{ ...primaryButtonProps, + disabled: isPrimaryButtonDisabled, onClick: handlePrimaryButtonClick, }} /> diff --git a/packages/wizard/src/WizardFooter/WizardFooter.types.ts b/packages/wizard/src/WizardFooter/WizardFooter.types.ts index 26590f1442..ec7756342c 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.types.ts +++ b/packages/wizard/src/WizardFooter/WizardFooter.types.ts @@ -1,5 +1,12 @@ -import { FormFooterProps } from '@leafygreen-ui/form-footer'; +import type { + BackStandardButtonProps, + CancelStandardButtonProps, + FormFooterProps, + PrimaryStandardButtonProps, +} from '@leafygreen-ui/form-footer'; -export interface WizardFooterProps - extends React.ComponentProps<'footer'>, - FormFooterProps {} +export interface WizardFooterProps extends FormFooterProps { + backButtonProps?: BackStandardButtonProps; + cancelButtonProps?: CancelStandardButtonProps; + primaryButtonProps: PrimaryStandardButtonProps; +} diff --git a/packages/wizard/src/WizardStep/WizardStep.spec.tsx b/packages/wizard/src/WizardStep/WizardStep.spec.tsx index 0e312c3bcd..fc49b27c22 100644 --- a/packages/wizard/src/WizardStep/WizardStep.spec.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.spec.tsx @@ -1,29 +1,165 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getLgIds as getFooterLgIds } from '@leafygreen-ui/form-footer'; import { Wizard } from '../Wizard/Wizard'; +import { useWizardContext } from '../WizardContext'; +import { WizardFooter } from '../WizardFooter'; -import { WizardStep } from '.'; +import { useWizardStepContext, WizardStep } from '.'; describe('packages/wizard-step', () => { test('does not render outside WizardContext', () => { const { container } = render( - - Content - , + Content, ); expect(container.firstChild).toBeNull(); }); + test('renders in WizardContext', () => { const { getByTestId } = render( - - Content - + +
Content
+
, ); expect(getByTestId('step-1')).toBeInTheDocument(); }); + + describe('requiresAcknowledgement', () => { + test('by default, does not require acknowledgement', () => { + const TestComponent = () => { + const { requiresAcknowledgement } = useWizardStepContext(); + return ( +
+ {String(requiresAcknowledgement)} +
+ ); + }; + + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('requires-ack')).toHaveTextContent('false'); + }); + + test('when true, requires acknowledgement', () => { + const TestComponent = () => { + const { requiresAcknowledgement } = useWizardStepContext(); + return ( +
+ {String(requiresAcknowledgement)} +
+ ); + }; + + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('requires-ack')).toHaveTextContent('true'); + }); + + test('isAcknowledged is false by default', () => { + const TestComponent = () => { + const { isAcknowledged } = useWizardStepContext(); + return
{String(isAcknowledged)}
; + }; + + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('is-ack')).toHaveTextContent('false'); + }); + + test('setAcknowledged updates isAcknowledged state', async () => { + const TestComponent = () => { + const { isAcknowledged, setAcknowledged } = useWizardStepContext(); + return ( + <> +
{String(isAcknowledged)}
+ + + ); + }; + + const { getByTestId, getByRole } = render( + + + + + , + ); + + expect(getByTestId('is-ack')).toHaveTextContent('false'); + + await userEvent.click(getByRole('button', { name: 'Acknowledge' })); + + expect(getByTestId('is-ack')).toHaveTextContent('true'); + }); + + test('acknowledgement state resets between steps', async () => { + const TestComponent = () => { + const { activeStep } = useWizardContext(); + const { isAcknowledged, setAcknowledged } = useWizardStepContext(); + return ( +
+
{String(isAcknowledged)}
+ +
+ ); + }; + + const { getByTestId, getByRole } = render( + + + + + + + + + + , + ); + + // Step 1: acknowledge and move forward + expect(getByTestId('step-0')).toBeInTheDocument(); + expect(getByTestId('is-ack')).toHaveTextContent('false'); + userEvent.click(getByRole('button', { name: 'Acknowledge' })); + expect(getByTestId('is-ack')).toHaveTextContent('true'); + + // TODO: replace with Wizard test harness + const primaryBtn = getByTestId(getFooterLgIds().primaryButton); + userEvent.click(primaryBtn); + + await waitFor(() => { + expect(getByTestId('step-1')).toBeInTheDocument(); + }); + + // Step 2: Acknowledgement is reset + expect(getByTestId('is-ack')).toHaveTextContent('false'); + userEvent.click(getByRole('button', { name: 'Acknowledge' })); + expect(getByTestId('is-ack')).toHaveTextContent('true'); + }); + }); }); diff --git a/packages/wizard/src/WizardStep/WizardStep.stories.tsx b/packages/wizard/src/WizardStep/WizardStep.stories.tsx index c917ead80f..814548dba3 100644 --- a/packages/wizard/src/WizardStep/WizardStep.stories.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.stories.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { storybookArgTypes, StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; import { StoryObj } from '@storybook/react'; -import { Body } from '@leafygreen-ui/typography'; +import { Body, Description, H3 } from '@leafygreen-ui/typography'; import { WizardProvider } from '../WizardContext'; -import { WizardStep } from '.'; +import { WizardStep, WizardStepProps } from '.'; const meta: StoryMetaType = { title: 'Composition/Wizard/WizardStep', @@ -16,21 +16,21 @@ const meta: StoryMetaType = { }, decorators: [ Story => ( - {}}> + {}} totalSteps={1}> ), ], - argTypes: { - title: storybookArgTypes.children, - description: storybookArgTypes.children, - children: storybookArgTypes.children, - }, }; export default meta; -export const LiveExample: StoryObj = { +interface WizardStepStoryProps extends WizardStepProps { + title: string; + description: string; +} + +export const LiveExample: StoryObj = { args: { title: 'Step 1: Basic Information', description: 'Please provide your basic information to get started.', @@ -43,32 +43,18 @@ export const LiveExample: StoryObj = {
), }, - render: args => , -}; - -export const WithLongDescription: StoryObj = { - 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: ( + argTypes: { + title: { control: 'text' }, + description: { control: 'text' }, + children: { control: 'text' }, + }, + render: args => ( +
- Complex form content would go here... - +

{args.title}

+ {args.description} + {args.children}
- ), - }, +
+ ), }; diff --git a/packages/wizard/src/WizardStep/WizardStep.tsx b/packages/wizard/src/WizardStep/WizardStep.tsx index 4af5eba958..4be39a9c69 100644 --- a/packages/wizard/src/WizardStep/WizardStep.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.tsx @@ -1,19 +1,22 @@ import React from 'react'; -import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; -import { cx } from '@leafygreen-ui/emotion'; +import { + CompoundSubComponent, + filterChildren, + findChild, +} from '@leafygreen-ui/compound-component'; +import { useIdAllocator } from '@leafygreen-ui/hooks'; import { consoleOnce } from '@leafygreen-ui/lib'; -import { Description, H3 } from '@leafygreen-ui/typography'; import { WizardSubComponentProperties } from '../constants'; import { useWizardContext } from '../WizardContext'; -import { TextNode } from './TextNode'; -import { stepStyles } from './WizardStep.styles'; import { WizardStepProps } from './WizardStep.types'; +import { WizardStepProvider } from './WizardStepContext'; export const WizardStep = CompoundSubComponent( - ({ title, description, children, className, ...rest }: WizardStepProps) => { + ({ children, requiresAcknowledgement = false }: WizardStepProps) => { + const stepId = useIdAllocator({ prefix: 'wizard-step' }); const { isWizardContext } = useWizardContext(); if (!isWizardContext) { @@ -23,12 +26,23 @@ export const WizardStep = CompoundSubComponent( return null; } + const footerChild = findChild( + children, + WizardSubComponentProperties.Footer, + ); + + const restChildren = filterChildren(children, [ + WizardSubComponentProperties.Footer, + ]); + return ( -
- {title} - {description && {description}} -
{children}
-
+ + {restChildren} + {footerChild} + ); }, { diff --git a/packages/wizard/src/WizardStep/WizardStep.types.ts b/packages/wizard/src/WizardStep/WizardStep.types.ts index b0e9e97f70..a3e609731a 100644 --- a/packages/wizard/src/WizardStep/WizardStep.types.ts +++ b/packages/wizard/src/WizardStep/WizardStep.types.ts @@ -1,19 +1,8 @@ -import { ReactNode } from 'react'; - -export interface WizardStepProps - extends Omit, 'title'> { +export interface WizardStepProps extends React.PropsWithChildren<{}> { /** - * The title of the step + * Defines whether some action in Step must be taken by the user before enabling the primary action button + * + * @default false */ - title: ReactNode; - - /** - * The description of the step - */ - description?: ReactNode; - - /** - * The content of the step - */ - children: ReactNode; + requiresAcknowledgement?: boolean; } diff --git a/packages/wizard/src/WizardStep/WizardStepContext.tsx b/packages/wizard/src/WizardStep/WizardStepContext.tsx new file mode 100644 index 0000000000..b16d516074 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStepContext.tsx @@ -0,0 +1,47 @@ +import React, { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useState, +} from 'react'; + +export interface WizardStepContextData { + stepId: string; + requiresAcknowledgement: boolean; + isAcknowledged: boolean; + setAcknowledged: Dispatch>; +} + +export const WizardStepContext = createContext({ + stepId: '', + requiresAcknowledgement: false, + isAcknowledged: false, + setAcknowledged: () => {}, +}); + +export const WizardStepProvider = ({ + stepId, + requiresAcknowledgement, + children, +}: PropsWithChildren< + Omit +>) => { + const [isAcknowledged, setAcknowledged] = useState(false); + + return ( + + {children} + + ); +}; + +export const useWizardStepContext = () => useContext(WizardStepContext); diff --git a/packages/wizard/src/WizardStep/index.ts b/packages/wizard/src/WizardStep/index.ts index f7e0b02596..6ef4b499f6 100644 --- a/packages/wizard/src/WizardStep/index.ts +++ b/packages/wizard/src/WizardStep/index.ts @@ -1,2 +1,3 @@ export { WizardStep } from './WizardStep'; export { type WizardStepProps } from './WizardStep.types'; +export { useWizardStepContext } from './WizardStepContext'; diff --git a/packages/wizard/src/index.ts b/packages/wizard/src/index.ts index 1d5270af64..1c370edbab 100644 --- a/packages/wizard/src/index.ts +++ b/packages/wizard/src/index.ts @@ -3,6 +3,7 @@ export { useWizardContext, WizardContext, type WizardContextData, + WizardProvider, } from './WizardContext'; export { type WizardFooterProps } from './WizardFooter'; -export { type WizardStepProps } from './WizardStep'; +export { useWizardStepContext, type WizardStepProps } from './WizardStep'; diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json index 26dc97f771..693201d7c6 100644 --- a/packages/wizard/tsconfig.json +++ b/packages/wizard/tsconfig.json @@ -13,20 +13,47 @@ "path": "../button" }, { - "path": "../compound-component" + "path": "../badge" }, { - "path": "../button" + "path": "../card" + }, + { + "path": "../checkbox" + }, + { + "path": "../compound-component" }, { "path": "../emotion" }, + { + "path": "../expandable-card" + }, { "path": "../form-footer" }, + { + "path": "../icon" + }, { "path": "../lib" }, + { + "path": "../loading-indicator" + }, + { + "path": "../segmented-control" + }, + { + "path": "../skeleton-loader" + }, + { + "path": "../table" + }, + { + "path": "../typography" + }, { "path": "../../tools/test-harnesses" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc3b47cf80..d9287f9bb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3809,15 +3809,9 @@ importers: packages/wizard: dependencies: - '@leafygreen-ui/button': - specifier: workspace:^ - version: link:../button '@leafygreen-ui/compound-component': specifier: workspace:^ version: link:../compound-component - '@leafygreen-ui/descendants': - specifier: workspace:^ - version: link:../descendants '@leafygreen-ui/emotion': specifier: workspace:^ version: link:../emotion @@ -3836,9 +3830,6 @@ importers: '@leafygreen-ui/tokens': specifier: workspace:^ version: link:../tokens - '@leafygreen-ui/typography': - specifier: workspace:^ - version: link:../typography '@lg-tools/test-harnesses': specifier: workspace:^ version: link:../../tools/test-harnesses @@ -3846,9 +3837,39 @@ importers: '@faker-js/faker': specifier: ^8.0.0 version: 8.0.2 + '@leafygreen-ui/badge': + specifier: workspace:^ + version: link:../badge + '@leafygreen-ui/banner': + specifier: workspace:^ + version: link:../banner + '@leafygreen-ui/button': + specifier: workspace:^ + version: link:../button + '@leafygreen-ui/card': + specifier: workspace:^ + version: link:../card + '@leafygreen-ui/checkbox': + specifier: workspace:^ + version: link:../checkbox + '@leafygreen-ui/expandable-card': + specifier: workspace:^ + version: link:../expandable-card '@leafygreen-ui/icon': specifier: workspace:^ version: link:../icon + '@leafygreen-ui/loading-indicator': + specifier: workspace:^ + version: link:../loading-indicator + '@leafygreen-ui/segmented-control': + specifier: workspace:^ + version: link:../segmented-control + '@leafygreen-ui/skeleton-loader': + specifier: workspace:^ + version: link:../skeleton-loader + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography tools/build: dependencies: diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index 1306b5f615..c2964f5cfc 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -79,6 +79,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/tooltip', '@leafygreen-ui/typography', '@leafygreen-ui/vertical-stepper', + '@leafygreen-ui/wizard', '@lg-charts/chart-card', '@lg-charts/colors', '@lg-charts/core',