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/lib/src/childQueries/findChild/findChild.spec.tsx b/packages/lib/src/childQueries/findChild/findChild.spec.tsx index c322277615..fb902c7c5c 100644 --- a/packages/lib/src/childQueries/findChild/findChild.spec.tsx +++ b/packages/lib/src/childQueries/findChild/findChild.spec.tsx @@ -30,7 +30,7 @@ Baz.displayName = 'Baz'; (Bar as any).isBar = true; (Baz as any).isBaz = true; -describe('packages/lib/findChild', () => { +describe('packages/compound-component/findChild', () => { test('should find a child component with matching static property', () => { // Create an iterable to test different iteration scenarios const children = [, ]; @@ -77,6 +77,30 @@ describe('packages/lib/findChild', () => { expect((found as React.ReactElement).props.text).toBe('also-in-fragment'); }); + test('should find mapped children', () => { + const COUNT = 5; + const children = new Array(COUNT).fill(null).map((_, i) => { + return ; + }); + + const found = findChild(children, 'isFoo'); + expect((found as React.ReactElement).props.text).toBe('Foo number 0'); + }); + + test('should find deeply mapped children', () => { + const COUNT = 5; + const children = ( + <> + {new Array(COUNT).fill(null).map((_, i) => { + return ; + })} + + ); + + const found = findChild(children, 'isFoo'); + expect((found as React.ReactElement).props.text).toBe('Foo number 0'); + }); + test('should NOT find components in deeply nested fragments (search depth limitation)', () => { const children = ( diff --git a/packages/lib/src/childQueries/findChild/findChild.tsx b/packages/lib/src/childQueries/findChild/findChild.tsx index 26e552cf54..5c7a8e9b1c 100644 --- a/packages/lib/src/childQueries/findChild/findChild.tsx +++ b/packages/lib/src/childQueries/findChild/findChild.tsx @@ -42,8 +42,9 @@ export const findChild = ( } const allChildren = unwrapRootFragment(children); + if (!allChildren) return; - return allChildren?.find(child => - isChildWithProperty(child, staticProperty), - ) as ReactElement | undefined; + return allChildren + .flat() + .find(child => isChildWithProperty(child, staticProperty)) as ReactElement; }; diff --git a/packages/lib/src/childQueries/findChildren/findChildren.spec.tsx b/packages/lib/src/childQueries/findChildren/findChildren.spec.tsx index 6327c51f63..023ae7c7df 100644 --- a/packages/lib/src/childQueries/findChildren/findChildren.spec.tsx +++ b/packages/lib/src/childQueries/findChildren/findChildren.spec.tsx @@ -30,7 +30,7 @@ Baz.displayName = 'Baz'; (Bar as any).isBar = true; (Baz as any).isBaz = true; -describe('packages/lib/findChildren', () => { +describe('packages/compound-component/findChildren', () => { describe('basic functionality', () => { it('should find all children with matching static property', () => { const children = [ @@ -67,120 +67,142 @@ describe('packages/lib/findChildren', () => { }); }); - describe('empty and null children handling', () => { - it('should handle null children', () => { - const found = findChildren(null, 'isFoo'); - expect(found).toEqual([]); + it('should find mapped children', () => { + const COUNT = 5; + const children = new Array(COUNT).fill(null).map((_, i) => { + return ; }); - it('should handle undefined children', () => { - const found = findChildren(undefined, 'isFoo'); - expect(found).toEqual([]); - }); + const found = findChildren(children, 'isFoo'); + expect(found).toHaveLength(COUNT); + }); - it('should handle empty fragment', () => { - const children = <>; - const found = findChildren(children, 'isFoo'); - expect(found).toEqual([]); - }); + it('should find deeply mapped children', () => { + const COUNT = 5; + const children = ( + <> + {new Array(COUNT).fill(null).map((_, i) => { + return ; + })} + + ); + + const found = findChildren(children, 'isFoo'); + expect(found).toHaveLength(COUNT); + }); +}); - it('should handle empty array children', () => { - const children: Array = []; - const found = findChildren(children, 'isFoo'); - expect(found).toEqual([]); - }); +describe('empty and null children handling', () => { + it('should handle null children', () => { + const found = findChildren(null, 'isFoo'); + expect(found).toEqual([]); }); - describe('Fragment handling', () => { - it('should handle single-level fragment children', () => { - const children = ( - - - - - - ); + it('should handle undefined children', () => { + const found = findChildren(undefined, 'isFoo'); + expect(found).toEqual([]); + }); - const found = findChildren(children, 'isFoo'); - expect(found).toHaveLength(2); - expect(found[0].props.text).toBe('foo-in-fragment'); - expect(found[1].props.text).toBe('another-foo'); - }); + it('should handle empty fragment', () => { + const children = <>; + const found = findChildren(children, 'isFoo'); + expect(found).toEqual([]); + }); + + it('should handle empty array children', () => { + const children: Array = []; + const found = findChildren(children, 'isFoo'); + expect(found).toEqual([]); + }); +}); - it('should NOT find children in deeply nested Fragments', () => { - const children = ( +describe('Fragment handling', () => { + it('should handle single-level fragment children', () => { + const children = ( + + + + + + ); + + const found = findChildren(children, 'isFoo'); + expect(found).toHaveLength(2); + expect(found[0].props.text).toBe('foo-in-fragment'); + expect(found[1].props.text).toBe('another-foo'); + }); + + it('should NOT find children in deeply nested Fragments', () => { + const children = ( + + - - - - + - - ); - - // Should only find direct children, not double-nested ones - const found = findChildren(children, 'isFoo'); - expect(found).toHaveLength(1); - expect(found[0].props.text).toBe('direct-foo'); - }); + + + ); + + // Should only find direct children, not double-nested ones + const found = findChildren(children, 'isFoo'); + expect(found).toHaveLength(1); + expect(found[0].props.text).toBe('direct-foo'); }); +}); - describe('styled components', () => { - it('should work with styled components from @emotion/styled', () => { - const StyledFoo = styled(Foo)` - background-color: red; - padding: 8px; - `; - - const children = [ - , - , - , - , - , - ]; - - const found = findChildren(children, 'isFoo'); - expect(found).toHaveLength(4); - expect(found.map(c => c.props.text)).toEqual([ - 'regular-foo', - 'styled-foo', - 'styled-foo-two', - 'another-foo', - ]); - - // Verify the styled component is actually styled - const styledComponent = found[1]; - const styledType = styledComponent.type as any; - const hasEmotionProps = !!( - styledType.target || styledType.__emotion_base - ); - expect(hasEmotionProps).toBe(true); - }); +describe('styled components', () => { + it('should work with styled components from @emotion/styled', () => { + const StyledFoo = styled(Foo)` + background-color: red; + padding: 8px; + `; + + const children = [ + , + , + , + , + , + ]; + + const found = findChildren(children, 'isFoo'); + expect(found).toHaveLength(4); + expect(found.map(c => c.props.text)).toEqual([ + 'regular-foo', + 'styled-foo', + 'styled-foo-two', + 'another-foo', + ]); + + // Verify the styled component is actually styled + const styledComponent = found[1]; + const styledType = styledComponent.type as any; + const hasEmotionProps = !!(styledType.target || styledType.__emotion_base); + expect(hasEmotionProps).toBe(true); }); +}); - describe('search depth limitations', () => { - it('should NOT find deeply nested components', () => { - const children = [ - - - , +describe('search depth limitations', () => { + it('should NOT find deeply nested components', () => { + const children = [ + + + , + - - - - , -
- -
, - , - ]; - - const found = findChildren(children, 'isFoo'); - expect(found).toHaveLength(1); - expect(found[0].props.text).toBe('direct-child'); - }); + +
+ , +
+ +
, + , + ]; + + const found = findChildren(children, 'isFoo'); + expect(found).toHaveLength(1); + expect(found[0].props.text).toBe('direct-child'); }); }); diff --git a/packages/lib/src/childQueries/findChildren/findChildren.ts b/packages/lib/src/childQueries/findChildren/findChildren.ts index 48632be6f1..b5f12cb0d7 100644 --- a/packages/lib/src/childQueries/findChildren/findChildren.ts +++ b/packages/lib/src/childQueries/findChildren/findChildren.ts @@ -15,7 +15,7 @@ import { unwrapRootFragment } from '../unwrapRootFragment'; * **Styled Component Support:** Checks component.target and component.__emotion_base * for styled() wrapped components. * - * * @example + * @example * ```ts * // ✅ Will find: Direct children * findChildren([ @@ -56,7 +56,9 @@ export const findChildren = ( if (!allChildren) return []; - return allChildren.filter(child => - isChildWithProperty(child, staticProperty), - ) as Array; + return allChildren + .flat() + .filter(child => + isChildWithProperty(child, staticProperty), + ) as Array; }; diff --git a/packages/wizard/README.md b/packages/wizard/README.md new file mode 100644 index 0000000000..e9d23c5f71 --- /dev/null +++ b/packages/wizard/README.md @@ -0,0 +1,25 @@ +# 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 + +### PNPM + +```shell +pnpm add @leafygreen-ui/wizard +``` + +### Yarn + +```shell +yarn add @leafygreen-ui/wizard +``` + +### NPM + +```shell +npm install @leafygreen-ui/wizard +``` diff --git a/packages/wizard/package.json b/packages/wizard/package.json new file mode 100644 index 0000000000..5264c0ab18 --- /dev/null +++ b/packages/wizard/package.json @@ -0,0 +1,55 @@ + +{ + "name": "@leafygreen-ui/wizard", + "version": "0.1.0", + "description": "LeafyGreen UI Kit Wizard", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "license": "Apache-2.0", + "exports": { + ".": { + "require": "./dist/umd/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts" + }, + "./testing": { + "require": "./dist/umd/testing/index.js", + "import": "./dist/esm/testing/index.js", + "types": "./dist/types/testing/index.d.ts" + } + }, + "scripts": { + "build": "lg-build bundle", + "tsc": "lg-build tsc", + "docs": "lg-build docs" + }, + "publishConfig": { + "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" : { + "@leafygreen-ui/icon": "workspace:^", + "@faker-js/faker": "^8.0.0" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/wizard", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/LG/summary" + } +} diff --git a/packages/wizard/src/Wizard.stories.tsx b/packages/wizard/src/Wizard.stories.tsx new file mode 100644 index 0000000000..bd0e73875a --- /dev/null +++ b/packages/wizard/src/Wizard.stories.tsx @@ -0,0 +1,118 @@ +/* eslint-disable no-console */ +import React from '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 { css } from '@leafygreen-ui/emotion'; + +import { Wizard } from '.'; + +faker.seed(0); + +export default { + title: 'Composition/Wizard', + component: Wizard, + parameters: { + default: 'LiveExample', + }, + decorators: [ + Fn => ( +
+ +
+ ), + ], +} satisfies StoryMetaType; + +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 new file mode 100644 index 0000000000..31d0906c46 --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.spec.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Wizard } from '.'; + +describe('packages/wizard', () => { + describe('rendering', () => { + test('renders first Wizard.Step', () => { + const { getByTestId, 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 } = 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 } = 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); + }); + + test('warns when activeStep exceeds number of steps', () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render( + + +
Content 1
+
+ +
Content 2
+
+
, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'LeafyGreen Wizard received (zero-indexed) `activeStep` prop exceeding the number of Steps provided\n', + 'Received activeStep: 5, Wizard.Steps count: 2', + ); + + consoleWarnSpy.mockRestore(); + }); + + test('warns when activeStep is negative', () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render( + + +
Content 1
+
+ +
Content 2
+
+
, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'LeafyGreen Wizard received (zero-indexed) `activeStep` prop exceeding the number of Steps provided\n', + 'Received activeStep: -1, Wizard.Steps count: 2', + ); + + consoleWarnSpy.mockRestore(); + }); + }); + }); +}); diff --git a/packages/wizard/src/Wizard/Wizard.styles.ts b/packages/wizard/src/Wizard/Wizard.styles.ts new file mode 100644 index 0000000000..c6ca33aaee --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.styles.ts @@ -0,0 +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 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 new file mode 100644 index 0000000000..ad5ed5d165 --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.tsx @@ -0,0 +1,94 @@ +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'; +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) => { + const stepChildren = findChildren( + children, + WizardSubComponentProperties.Step, + ); + const footerChild = findChild( + children, + WizardSubComponentProperties.Footer, + ); + + // Controlled/Uncontrolled activeStep value + const { value: activeStep, updateValue: setActiveStep } = + useControlled(activeStepProp, onStepChange, 0); + + if ( + activeStepProp && + (activeStepProp < 0 || activeStepProp >= stepChildren.length) + ) { + // Not consoleOnce, since we want to warn again if the step changes + console.warn( + 'LeafyGreen Wizard received (zero-indexed) `activeStep` prop exceeding the number of Steps provided\n', + `Received activeStep: ${activeStepProp}, Wizard.Steps count: ${stepChildren.length}`, + ); + } + + 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); + }, + [activeStep, setActiveStep, stepChildren.length], + ); + + // Get the current step to render + const currentStep = stepChildren[activeStep] || null; + + return ( + +
+
{currentStep}
+ {footerChild} +
+
+ ); + }, + { + displayName: 'Wizard', + Step: WizardStep, + Footer: WizardFooter, + }, +); + +/** + * 🤚 Wizard. + * 🤚 Wizard. + * 🤚 Wizard. + * ... + * 🤚 Wizard. 🤚 Wizard. 🤚 Wizard. + * https://youtu.be/5jGWMtEhS1c + */ diff --git a/packages/wizard/src/Wizard/Wizard.types.ts b/packages/wizard/src/Wizard/Wizard.types.ts new file mode 100644 index 0000000000..7fc1a3901a --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.types.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000000..a6d6cd5342 --- /dev/null +++ b/packages/wizard/src/Wizard/index.ts @@ -0,0 +1,2 @@ +export { Wizard } from './Wizard'; +export { type WizardComponent, type WizardProps } from './Wizard.types'; diff --git a/packages/wizard/src/WizardContext/WizardContext.tsx b/packages/wizard/src/WizardContext/WizardContext.tsx new file mode 100644 index 0000000000..c3d50b9d3f --- /dev/null +++ b/packages/wizard/src/WizardContext/WizardContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, PropsWithChildren, 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 WizardProvider = ({ + children, + activeStep, + updateStep, +}: PropsWithChildren>) => { + return ( + + {children} + + ); +}; + +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..4e4bfdda83 --- /dev/null +++ b/packages/wizard/src/WizardContext/index.ts @@ -0,0 +1,6 @@ +export { + useWizardContext, + WizardContext, + type WizardContextData, + WizardProvider, +} from './WizardContext'; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx new file mode 100644 index 0000000000..5bf606c5f0 --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Wizard } from '../Wizard'; + +import { WizardFooter } from '.'; + +describe('packages/wizard-footer', () => { + 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..a332249682 --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.stories.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import { Variant } from '@leafygreen-ui/button'; +import { glyphs, Icon } from '@leafygreen-ui/icon'; + +import { WizardProvider } from '../WizardContext'; + +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: 'Composition/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, + }, + decorators: [ + Story => ( + {}}> + + + ), + ], + 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 new file mode 100644 index 0000000000..90e2e8cc60 --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.styles.ts @@ -0,0 +1,5 @@ +import { css } from '@leafygreen-ui/emotion'; + +export const baseStyles = css` + width: 100%; +`; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.tsx b/packages/wizard/src/WizardFooter/WizardFooter.tsx new file mode 100644 index 0000000000..2a46c9201a --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.tsx @@ -0,0 +1,66 @@ +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 { WizardFooterProps } from './WizardFooter.types'; + +export const WizardFooter = CompoundSubComponent( + ({ + backButtonProps, + cancelButtonProps, + primaryButtonProps, + className, + ...rest + }: WizardFooterProps) => { + const { isWizardContext, activeStep, updateStep } = useWizardContext(); + + const handleBackButtonClick: MouseEventHandler = e => { + updateStep(Direction.Prev); + backButtonProps?.onClick?.(e); + }; + + const handlePrimaryButtonClick: MouseEventHandler< + HTMLButtonElement + > = e => { + updateStep(Direction.Next); + primaryButtonProps.onClick?.(e); + }; + + if (!isWizardContext) { + consoleOnce.error( + 'Wizard.Footer component must be used within a Wizard context.', + ); + return null; + } + + return ( + 0 + ? { + ...backButtonProps, + onClick: handleBackButtonClick, + } + : undefined + } + cancelButtonProps={cancelButtonProps} + primaryButtonProps={{ + ...primaryButtonProps, + onClick: handlePrimaryButtonClick, + }} + /> + ); + }, + { + displayName: 'WizardFooter', + key: WizardSubComponentProperties.Footer, + }, +); diff --git a/packages/wizard/src/WizardFooter/WizardFooter.types.ts b/packages/wizard/src/WizardFooter/WizardFooter.types.ts new file mode 100644 index 0000000000..cf2617761d --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.types.ts @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000000..10bb26030a --- /dev/null +++ b/packages/wizard/src/WizardFooter/index.ts @@ -0,0 +1,2 @@ +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..e75679d7ed --- /dev/null +++ b/packages/wizard/src/WizardStep/TextNode.tsx @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000000..0e312c3bcd --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Wizard } from '../Wizard/Wizard'; + +import { WizardStep } from '.'; + +describe('packages/wizard-step', () => { + 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..c917ead80f --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.stories.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { storybookArgTypes, StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import { Body } from '@leafygreen-ui/typography'; + +import { WizardProvider } from '../WizardContext'; + +import { WizardStep } from '.'; + +const meta: StoryMetaType = { + title: 'Composition/Wizard/WizardStep', + component: WizardStep, + parameters: { + default: 'LiveExample', + }, + decorators: [ + Story => ( + {}}> + + + ), + ], + argTypes: { + title: storybookArgTypes.children, + description: storybookArgTypes.children, + children: storybookArgTypes.children, + }, +}; + +export default meta; + +export const LiveExample: StoryObj = { + 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. + +
+ ), + }, + 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: ( +
+ Complex form content would go here... + +
+ ), + }, +}; diff --git a/packages/wizard/src/WizardStep/WizardStep.styles.ts b/packages/wizard/src/WizardStep/WizardStep.styles.ts new file mode 100644 index 0000000000..b38acdf587 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.styles.ts @@ -0,0 +1,6 @@ +import { css } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +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 new file mode 100644 index 0000000000..4af5eba958 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { cx } from '@leafygreen-ui/emotion'; +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'; + +export const WizardStep = CompoundSubComponent( + ({ title, description, children, className, ...rest }: WizardStepProps) => { + const { isWizardContext } = useWizardContext(); + + if (!isWizardContext) { + consoleOnce.error( + 'Wizard.Step component must be used within a Wizard context.', + ); + return null; + } + + return ( +
+ {title} + {description && {description}} +
{children}
+
+ ); + }, + { + displayName: 'WizardStep', + key: WizardSubComponentProperties.Step, + }, +); diff --git a/packages/wizard/src/WizardStep/WizardStep.types.ts b/packages/wizard/src/WizardStep/WizardStep.types.ts new file mode 100644 index 0000000000..b0e9e97f70 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.types.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000000..f7e0b02596 --- /dev/null +++ b/packages/wizard/src/WizardStep/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000..1d5270af64 --- /dev/null +++ b/packages/wizard/src/index.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..6e928dca63 --- /dev/null +++ b/packages/wizard/src/testing/getTestUtils.spec.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Wizard } from '.'; + +describe('packages/wizard/getTestUtils', () => { + test('condition', () => {}); +}); diff --git a/packages/wizard/src/testing/getTestUtils.tsx b/packages/wizard/src/testing/getTestUtils.tsx new file mode 100644 index 0000000000..ad89a6e99d --- /dev/null +++ b/packages/wizard/src/testing/getTestUtils.tsx @@ -0,0 +1,15 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; + +import { LgIdString } from '@leafygreen-ui/lib'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; + +import { TestUtilsReturnType } from './getTestUtils.types'; + +export const getTestUtils = ( + lgId: LgIdString = DEFAULT_LGID_ROOT, +): TestUtilsReturnType => { + const lgIds = getLgIds(lgId); + + return {}; +}; diff --git a/packages/wizard/src/testing/getTestUtils.types.ts b/packages/wizard/src/testing/getTestUtils.types.ts new file mode 100644 index 0000000000..4b2df87c73 --- /dev/null +++ b/packages/wizard/src/testing/getTestUtils.types.ts @@ -0,0 +1 @@ +export interface TestUtilsReturnType {} diff --git a/packages/wizard/src/testing/index.ts b/packages/wizard/src/testing/index.ts new file mode 100644 index 0000000000..4c102995fa --- /dev/null +++ b/packages/wizard/src/testing/index.ts @@ -0,0 +1,2 @@ +export { getTestUtils } from './getTestUtils'; +export { type TestUtilsReturnType } from './getTestUtils.types'; diff --git a/packages/wizard/src/utils/getLgIds.ts b/packages/wizard/src/utils/getLgIds.ts new file mode 100644 index 0000000000..9590c84563 --- /dev/null +++ b/packages/wizard/src/utils/getLgIds.ts @@ -0,0 +1,12 @@ +import { LgIdString } from '@leafygreen-ui/lib'; + +export const DEFAULT_LGID_ROOT = 'lg-wizard'; + +export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => { + const ids = { + root, + } as const; + return ids; +}; + +export type GetLgIdsReturnType = ReturnType; diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json new file mode 100644 index 0000000000..26dc97f771 --- /dev/null +++ b/packages/wizard/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": ["../icon/src/generated/*"], + "@leafygreen-ui/*": ["../*/src"] + } + }, + "include": ["src/**/*"], + "exclude": ["**/*.spec.*", "**/*.stories.*"], + "references": [ + { + "path": "../button" + }, + { + "path": "../compound-component" + }, + { + "path": "../button" + }, + { + "path": "../emotion" + }, + { + "path": "../form-footer" + }, + { + "path": "../lib" + }, + { + "path": "../../tools/test-harnesses" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2ecb5c08..a31f6d0f5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3725,6 +3725,49 @@ importers: specifier: workspace:^ version: link:../../tools/storybook-utils + 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 + '@leafygreen-ui/form-footer': + specifier: workspace:^ + version: link:../form-footer + '@leafygreen-ui/hooks': + specifier: workspace:^ + version: link:../hooks + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/polymorphic': + specifier: workspace:^ + version: link:../polymorphic + '@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 + devDependencies: + '@faker-js/faker': + specifier: ^8.0.0 + version: 8.0.2 + '@leafygreen-ui/icon': + specifier: workspace:^ + version: link:../icon + tools/build: dependencies: '@babel/core': diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index 0b921287d8..86cf3ee00f 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -75,6 +75,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/toolbar', '@leafygreen-ui/tooltip', '@leafygreen-ui/typography', + '@leafygreen-ui/wizard', '@leafygreen-ui/vertical-stepper', '@lg-charts/chart-card', '@lg-charts/colors',