From 2de5769fb60155fe5f03991f04ee544f348a07aa Mon Sep 17 00:00:00 2001 From: Adam Thompson Date: Wed, 24 Sep 2025 13:58:45 -0400 Subject: [PATCH 01/10] scaffolds wizard package --- packages/wizard/README.md | 26 +++++++++++ packages/wizard/package.json | 43 +++++++++++++++++++ packages/wizard/src/Wizard.stories.tsx | 17 ++++++++ packages/wizard/src/Wizard/Wizard.spec.tsx | 11 +++++ packages/wizard/src/Wizard/Wizard.styles.ts | 4 ++ packages/wizard/src/Wizard/Wizard.tsx | 8 ++++ packages/wizard/src/Wizard/Wizard.types.ts | 1 + packages/wizard/src/Wizard/index.ts | 3 ++ packages/wizard/src/index.ts | 1 + .../wizard/src/testing/getTestUtils.spec.tsx | 10 +++++ packages/wizard/src/testing/getTestUtils.tsx | 15 +++++++ .../wizard/src/testing/getTestUtils.types.ts | 1 + packages/wizard/src/testing/index.ts | 2 + packages/wizard/src/utils/getLgIds.ts | 12 ++++++ packages/wizard/tsconfig.json | 22 ++++++++++ 15 files changed, 176 insertions(+) create mode 100644 packages/wizard/README.md create mode 100644 packages/wizard/package.json create mode 100644 packages/wizard/src/Wizard.stories.tsx create mode 100644 packages/wizard/src/Wizard/Wizard.spec.tsx create mode 100644 packages/wizard/src/Wizard/Wizard.styles.ts create mode 100644 packages/wizard/src/Wizard/Wizard.tsx create mode 100644 packages/wizard/src/Wizard/Wizard.types.ts create mode 100644 packages/wizard/src/Wizard/index.ts create mode 100644 packages/wizard/src/index.ts create mode 100644 packages/wizard/src/testing/getTestUtils.spec.tsx create mode 100644 packages/wizard/src/testing/getTestUtils.tsx create mode 100644 packages/wizard/src/testing/getTestUtils.types.ts create mode 100644 packages/wizard/src/testing/index.ts create mode 100644 packages/wizard/src/utils/getLgIds.ts create mode 100644 packages/wizard/tsconfig.json diff --git a/packages/wizard/README.md b/packages/wizard/README.md new file mode 100644 index 0000000000..f6d912208f --- /dev/null +++ b/packages/wizard/README.md @@ -0,0 +1,26 @@ + +# 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..91384840b9 --- /dev/null +++ b/packages/wizard/package.json @@ -0,0 +1,43 @@ + +{ + "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/emotion": "workspace:^", + "@leafygreen-ui/lib": "workspace:^", + "@lg-tools/test-harnesses": "workspace:^" + }, + "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..4c4b56cf46 --- /dev/null +++ b/packages/wizard/src/Wizard.stories.tsx @@ -0,0 +1,17 @@ + +import React from 'react'; +import { StoryFn } from '@storybook/react'; + +import { Wizard } from '.'; + +export default { + title: 'Components/Wizard', + component: Wizard, +} + +const Template: StoryFn = (props) => ( + +); + +export const Basic = Template.bind({}); + diff --git a/packages/wizard/src/Wizard/Wizard.spec.tsx b/packages/wizard/src/Wizard/Wizard.spec.tsx new file mode 100644 index 0000000000..07591fd2e6 --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.spec.tsx @@ -0,0 +1,11 @@ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Wizard } from '.'; + +describe('packages/wizard', () => { + test('condition', () => { + + }) +}) diff --git a/packages/wizard/src/Wizard/Wizard.styles.ts b/packages/wizard/src/Wizard/Wizard.styles.ts new file mode 100644 index 0000000000..928608f58d --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.styles.ts @@ -0,0 +1,4 @@ + +import { css } from '@leafygreen-ui/emotion'; + +export const baseStyles = css``; diff --git a/packages/wizard/src/Wizard/Wizard.tsx b/packages/wizard/src/Wizard/Wizard.tsx new file mode 100644 index 0000000000..112fe70c75 --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { WizardProps } from './Wizard.types'; + +export function Wizard({}: WizardProps) { + return
your content here
; +} + +Wizard.displayName = 'Wizard'; diff --git a/packages/wizard/src/Wizard/Wizard.types.ts b/packages/wizard/src/Wizard/Wizard.types.ts new file mode 100644 index 0000000000..cfa270475f --- /dev/null +++ b/packages/wizard/src/Wizard/Wizard.types.ts @@ -0,0 +1 @@ +export interface WizardProps {} \ No newline at end of file diff --git a/packages/wizard/src/Wizard/index.ts b/packages/wizard/src/Wizard/index.ts new file mode 100644 index 0000000000..82aa8f69a6 --- /dev/null +++ b/packages/wizard/src/Wizard/index.ts @@ -0,0 +1,3 @@ + +export { Wizard } from './Wizard'; +export { type WizardProps } from './Wizard.types'; diff --git a/packages/wizard/src/index.ts b/packages/wizard/src/index.ts new file mode 100644 index 0000000000..cfbd7d46d8 --- /dev/null +++ b/packages/wizard/src/index.ts @@ -0,0 +1 @@ +export { Wizard, type WizardProps } from './Wizard'; \ No newline at end of file diff --git a/packages/wizard/src/testing/getTestUtils.spec.tsx b/packages/wizard/src/testing/getTestUtils.spec.tsx new file mode 100644 index 0000000000..99117014a5 --- /dev/null +++ b/packages/wizard/src/testing/getTestUtils.spec.tsx @@ -0,0 +1,10 @@ +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..50d2fb417a --- /dev/null +++ b/packages/wizard/src/testing/getTestUtils.types.ts @@ -0,0 +1 @@ +export interface TestUtilsReturnType {} \ No newline at end of file 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..5a0f368e7f --- /dev/null +++ b/packages/wizard/tsconfig.json @@ -0,0 +1,22 @@ +{ + "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": "../emotion" + }, + { + "path": "../lib" + }, + { + "path": "../../tools/test-harnesses" + } + ] +} From e453185fc7270b6022a3a799c414e9d409c55c0b Mon Sep 17 00:00:00 2001 From: Adam Thompson Date: Wed, 24 Sep 2025 14:09:55 -0400 Subject: [PATCH 02/10] Update pnpm-lock.yaml --- pnpm-lock.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e048eb7d6e..02145aa388 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3813,6 +3813,18 @@ importers: specifier: workspace:^ version: link:../../tools/storybook-utils + packages/wizard: + dependencies: + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@lg-tools/test-harnesses': + specifier: workspace:^ + version: link:../../tools/test-harnesses + tools/build: dependencies: '@babel/core': From 998c5af1e7bb266f4de1ac2f6d14b8af2dca634e Mon Sep 17 00:00:00 2001 From: Adam Thompson Date: Wed, 24 Sep 2025 14:57:06 -0400 Subject: [PATCH 03/10] scaffold WizardFooter --- .../wizard/src/WizardFooter/WizardFooter.spec.tsx | 11 +++++++++++ .../wizard/src/WizardFooter/WizardFooter.styles.ts | 4 ++++ packages/wizard/src/WizardFooter/WizardFooter.tsx | 8 ++++++++ .../wizard/src/WizardFooter/WizardFooter.types.ts | 1 + packages/wizard/src/WizardFooter/index.ts | 3 +++ 5 files changed, 27 insertions(+) create mode 100644 packages/wizard/src/WizardFooter/WizardFooter.spec.tsx create mode 100644 packages/wizard/src/WizardFooter/WizardFooter.styles.ts create mode 100644 packages/wizard/src/WizardFooter/WizardFooter.tsx create mode 100644 packages/wizard/src/WizardFooter/WizardFooter.types.ts create mode 100644 packages/wizard/src/WizardFooter/index.ts diff --git a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx new file mode 100644 index 0000000000..f0081b35c3 --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx @@ -0,0 +1,11 @@ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { WizardFooter } from '.'; + +describe('packages/wizard-footer', () => { + test('condition', () => { + + }) +}) diff --git a/packages/wizard/src/WizardFooter/WizardFooter.styles.ts b/packages/wizard/src/WizardFooter/WizardFooter.styles.ts new file mode 100644 index 0000000000..928608f58d --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.styles.ts @@ -0,0 +1,4 @@ + +import { css } from '@leafygreen-ui/emotion'; + +export const baseStyles = css``; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.tsx b/packages/wizard/src/WizardFooter/WizardFooter.tsx new file mode 100644 index 0000000000..f0b6c5519a --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { WizardFooterProps } from './WizardFooter.types'; + +export function WizardFooter({}: WizardFooterProps) { + return
your content here
; +} + +WizardFooter.displayName = 'WizardFooter'; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.types.ts b/packages/wizard/src/WizardFooter/WizardFooter.types.ts new file mode 100644 index 0000000000..02f3f87b43 --- /dev/null +++ b/packages/wizard/src/WizardFooter/WizardFooter.types.ts @@ -0,0 +1 @@ +export interface WizardFooterProps {} \ No newline at end of file diff --git a/packages/wizard/src/WizardFooter/index.ts b/packages/wizard/src/WizardFooter/index.ts new file mode 100644 index 0000000000..bc9a177cfe --- /dev/null +++ b/packages/wizard/src/WizardFooter/index.ts @@ -0,0 +1,3 @@ + +export { WizardFooter } from './WizardFooter'; +export { type WizardFooterProps } from './WizardFooter.types'; From 2096173b574760b2c1fe7d1ec5048830b5ae2560 Mon Sep 17 00:00:00 2001 From: Adam Thompson Date: Wed, 24 Sep 2025 14:57:19 -0400 Subject: [PATCH 04/10] scaffold wizard step --- packages/wizard/src/WizardStep/WizardStep.spec.tsx | 11 +++++++++++ packages/wizard/src/WizardStep/WizardStep.styles.ts | 4 ++++ packages/wizard/src/WizardStep/WizardStep.tsx | 8 ++++++++ packages/wizard/src/WizardStep/WizardStep.types.ts | 1 + packages/wizard/src/WizardStep/index.ts | 3 +++ 5 files changed, 27 insertions(+) create mode 100644 packages/wizard/src/WizardStep/WizardStep.spec.tsx create mode 100644 packages/wizard/src/WizardStep/WizardStep.styles.ts create mode 100644 packages/wizard/src/WizardStep/WizardStep.tsx create mode 100644 packages/wizard/src/WizardStep/WizardStep.types.ts create mode 100644 packages/wizard/src/WizardStep/index.ts diff --git a/packages/wizard/src/WizardStep/WizardStep.spec.tsx b/packages/wizard/src/WizardStep/WizardStep.spec.tsx new file mode 100644 index 0000000000..fb00cde028 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.spec.tsx @@ -0,0 +1,11 @@ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { WizardStep } from '.'; + +describe('packages/wizard-step', () => { + test('condition', () => { + + }) +}) diff --git a/packages/wizard/src/WizardStep/WizardStep.styles.ts b/packages/wizard/src/WizardStep/WizardStep.styles.ts new file mode 100644 index 0000000000..928608f58d --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.styles.ts @@ -0,0 +1,4 @@ + +import { css } from '@leafygreen-ui/emotion'; + +export const baseStyles = css``; diff --git a/packages/wizard/src/WizardStep/WizardStep.tsx b/packages/wizard/src/WizardStep/WizardStep.tsx new file mode 100644 index 0000000000..6c699df9e8 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { WizardStepProps } from './WizardStep.types'; + +export function WizardStep({}: WizardStepProps) { + return
your content here
; +} + +WizardStep.displayName = 'WizardStep'; diff --git a/packages/wizard/src/WizardStep/WizardStep.types.ts b/packages/wizard/src/WizardStep/WizardStep.types.ts new file mode 100644 index 0000000000..3998534991 --- /dev/null +++ b/packages/wizard/src/WizardStep/WizardStep.types.ts @@ -0,0 +1 @@ +export interface WizardStepProps {} \ No newline at end of file diff --git a/packages/wizard/src/WizardStep/index.ts b/packages/wizard/src/WizardStep/index.ts new file mode 100644 index 0000000000..866f9c3f6c --- /dev/null +++ b/packages/wizard/src/WizardStep/index.ts @@ -0,0 +1,3 @@ + +export { WizardStep } from './WizardStep'; +export { type WizardStepProps } from './WizardStep.types'; From 95c816eec3d7b42c93368eefc4cd764a41d041b6 Mon Sep 17 00:00:00 2001 From: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:36:00 -0400 Subject: [PATCH 05/10] [LG-5563] feat(Wizard) Adds Wizard (#3161) * initial Wizard component * Creates basic Wizard.tsx component Prompt: In the newly created package, create the Wizard component. Note: these docs mention `Wizard.Step` and `Wizard.Footer`. DO NOT create these yet. They will be created later The `@leafygreen-ui/wizard` is a general-purpose, multi-step page template, designed to create guided in-app flows and wizards: Based on the MultiStepWizard component in MMS, and intended to be used in the Product Deletion template. Feature Overview: - Takes in all Steps in the flow as children. - Renders the appropriate content for the current step - Internally handles step changing (with optional external control) Non-goals: - We will not be implementing this across MMS (MultiStepWizard is currently used in 26 files) - This will not support different url routes per step Wizard component The root flow component. Controls the rendering of the appropriate step based on a controlled prop, or uncontrolled internal state. Example ```tsx const [activeStep, setActiveStep] = useState(0) Some description with a link} > Some Content. Lorem ipsum dolor. ``` Props: ```ts activeStep?: number; onStepChange?: (step: number) => void showStepper?: boolean; // omit for v1 ``` State: `[activeStep, setActiveStep] = useState // if none provided as a prop` Events: - `onStepChange` : fired when the activeStep changes - this should still fire when controlled? Rendering: - Renders the appropriate Step based on the activeStep prop/state - Renders the Footer element, with enabled/hidden buttons based on the activeStep - If activeStep === 0, hides back button - Injects setActiveStep into Back and Primary buttons (if uncontrolled) * Creates WizardStep and WizardFooter Prompt: The Footer and Step components have been scaffolded. Create both components with the following spec: Step: A single Step in the multi-step flow. Must be rendered within a Wizard. ```ts title: ReactNode; description: ReactNode; children: ReactNode; ``` Footer: The footer element for the Wizard. A wrapper around LeafyGreen `FormFooter`, but allows us to optionally inject event handlers into the buttons. ``` backButtonProps: ButtonProps; cancelButtonProps: ButtonProps; primaryButtonProps: ButtonProps; ``` * footer& step stories * temp useWizardControlledValue * fix useWizardControlledValue * update Footer * Update package.json * use typography in Step * update descendants * update packages * the rest of the owl * update width * fix nits * Squashed commit of the following: commit c8260339daec472f2bfa45499c5fa1ad29195163 Author: Adam Thompson Date: Tue Sep 30 15:54:03 2025 -0400 Update isChildWithProperty.spec.tsx commit 01585d35fb95f29ae33132c69e46ad1447ae5813 Merge: f3570c4dc 94745fb5a Author: Adam Thompson Date: Tue Sep 30 13:28:59 2025 -0400 Merge branch 'main' into ac/cc-utils commit f3570c4dc8fe739db755c8f03cb134e70803390a Author: Adam Thompson Date: Tue Sep 30 13:28:37 2025 -0400 rm todo commit becf667062f0f5f6471295106e2347d0d87220a5 Author: Adam Thompson Date: Fri Sep 26 16:50:05 2025 -0400 rm wizard commit f8463ac5d1f8bbe4982b0b8f00f7c289c4b880af Author: Adam Thompson Date: Fri Sep 26 16:50:00 2025 -0400 update index files commit 5e0d157861de78bd68b3a0a2e97a90465c5c3d19 Author: Adam Thompson Date: Fri Sep 26 16:49:50 2025 -0400 adds 2 level fragment test commit caf8a93d9e8fc466a8ad1473735b5e88a9e49e36 Author: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Fri Sep 26 16:39:09 2025 -0400 Update packages/lib/src/childQueries/findChildren/findChildren.ts Co-authored-by: Stephen Lee commit ee977a1c198c8368db5ec33f2af81df4f02b3089 Author: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Fri Sep 26 16:38:18 2025 -0400 Update packages/lib/src/childQueries/findChild/findChild.tsx Co-authored-by: Stephen Lee commit ee32a26a94fbade49836d7aa2fe2585c76e5c69d Merge: ac2c48548 366e8515c Author: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Thu Sep 25 15:20:23 2025 -0400 Merge branch 'main' into ac/cc-utils commit ac2c485484b206591856ccc463bec9f6ae612c02 Author: Adam Thompson Date: Thu Sep 25 14:03:09 2025 -0400 Create lib-find-children.md commit 9cd7489c6c6c557e55c9a455b6bd6d32c62e56f4 Author: Adam Thompson Date: Thu Sep 25 14:00:05 2025 -0400 Update findChildren.ts commit 90e8208132bf86ae9410d33a73074ae222c9842e Author: Adam Thompson Date: Thu Sep 25 13:59:35 2025 -0400 Update findChildren.ts commit d7ae970488b742498fe95b9e4227f3d0e6c1a01b Author: Adam Thompson Date: Thu Sep 25 13:52:04 2025 -0400 update findChild/children with unwrapRootFragment commit a64ff9ebcd7b990fee1ff3647f7afb4b5ea2c20e Author: Adam Thompson Date: Thu Sep 25 13:49:27 2025 -0400 Creates unwrapRootFragment commit 000f71361f1af4370540e9ccb4c5b5a280b7c506 Author: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Thu Sep 25 13:05:35 2025 -0400 Apply suggestions from code review `allChildren.length === 1` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit c6d9c9df76172b6c87a11df19c4080c6550e1324 Author: Adam Thompson Date: Thu Sep 25 13:00:30 2025 -0400 Update index.ts commit c3699570bcbd98e01facee4f6a06720b6ffa548b Author: Adam Thompson Date: Thu Sep 25 13:00:12 2025 -0400 mv child queries commit 5fe4f9d58f70e03a0c83ae3fe49d7963b22c9ceb Author: Adam Thompson Date: Thu Sep 25 12:59:35 2025 -0400 update index files commit c9261c8e29776e64d4ab06d8020c0a6ccef3a8bc Author: Adam Thompson Date: Thu Sep 25 12:58:48 2025 -0400 mv componentQueries commit be05c4d518ad02cc02ecfc864a309d8bd98f6e1b Author: Adam Thompson Date: Thu Sep 25 12:55:19 2025 -0400 Update findChildren.spec.tsx commit f493f6d58ac1ceaf3d7aea6901affa4e1e73e769 Author: Adam Thompson Date: Thu Sep 25 12:46:47 2025 -0400 update findChild tests commit 74f5f7e48dea9637c8a6f19566e52a8450f9f9fc Author: Adam Thompson Date: Thu Sep 25 12:46:28 2025 -0400 Fix isChildWithProperty tests commit 5439034bc2b9bbabb83d0577fb417f12231bb12d Author: Adam Thompson Date: Wed Sep 24 19:05:18 2025 -0400 findChildren commit aa89584e801012d6623c6dc0526f5a31db56caf9 Author: Adam Thompson Date: Wed Sep 24 19:05:10 2025 -0400 Update findChild.tsx commit dda7ad54e043711db2ce384e86329c8195565e21 Author: Adam Thompson Date: Wed Sep 24 19:05:01 2025 -0400 isChildWithProperty commit ae3a41b61f012e445dddd77edd5dad713fb0a68f Author: Adam Thompson Date: Wed Sep 24 17:02:37 2025 -0400 mv existing utils * adds findChildren * adds TextNode * Update Wizard.spec.tsx * minor fixes * spread rest * adds wizard context assertions * fix exports * fix exports * Update TextNode.tsx * creates compound component * lint * update CompoundSubComponent api * update packages * add WizardProvider * update stories * Wizard * update findChild/ren * spread className * add "exceeded steps" warning * adds warning tests --- .changeset/descendants-exports.md | 5 + .changeset/lib-find-children.md | 5 + packages/descendants/src/Highlight/index.ts | 13 +- packages/descendants/src/index.ts | 3 +- .../childQueries/findChild/findChild.spec.tsx | 26 +- .../src/childQueries/findChild/findChild.tsx | 7 +- .../findChildren/findChildren.spec.tsx | 220 +++++++------ .../childQueries/findChildren/findChildren.ts | 10 +- packages/wizard/README.md | 3 +- packages/wizard/package.json | 11 + packages/wizard/src/Wizard.stories.tsx | 119 ++++++- packages/wizard/src/Wizard/Wizard.spec.tsx | 294 +++++++++++++++++- packages/wizard/src/Wizard/Wizard.styles.ts | 15 +- packages/wizard/src/Wizard/Wizard.tsx | 93 +++++- packages/wizard/src/Wizard/Wizard.types.ts | 29 +- packages/wizard/src/Wizard/index.ts | 5 +- .../src/WizardContext/WizardContext.tsx | 35 +++ packages/wizard/src/WizardContext/index.ts | 6 + .../src/WizardFooter/WizardFooter.spec.tsx | 32 +- .../src/WizardFooter/WizardFooter.stories.tsx | 84 +++++ .../src/WizardFooter/WizardFooter.styles.ts | 5 +- .../wizard/src/WizardFooter/WizardFooter.tsx | 68 +++- .../src/WizardFooter/WizardFooter.types.ts | 19 +- packages/wizard/src/WizardFooter/index.ts | 3 +- packages/wizard/src/WizardStep/TextNode.tsx | 32 ++ .../wizard/src/WizardStep/WizardStep.spec.tsx | 26 +- .../src/WizardStep/WizardStep.stories.tsx | 74 +++++ .../src/WizardStep/WizardStep.styles.ts | 6 +- packages/wizard/src/WizardStep/WizardStep.tsx | 38 ++- .../wizard/src/WizardStep/WizardStep.types.ts | 20 +- packages/wizard/src/WizardStep/index.ts | 3 +- packages/wizard/src/constants.ts | 6 + packages/wizard/src/index.ts | 9 +- .../wizard/src/testing/getTestUtils.spec.tsx | 6 +- .../wizard/src/testing/getTestUtils.types.ts | 2 +- .../wizard/src/utils/CompoundComponent.tsx | 27 ++ .../wizard/src/utils/CompoundSubComponent.tsx | 41 +++ .../utils/useWizardControlledValue/index.ts | 1 + .../useWizardControlledValue.ts | 96 ++++++ packages/wizard/tsconfig.json | 6 + pnpm-lock.yaml | 28 ++ 41 files changed, 1359 insertions(+), 172 deletions(-) create mode 100644 .changeset/descendants-exports.md create mode 100644 .changeset/lib-find-children.md create mode 100644 packages/wizard/src/WizardContext/WizardContext.tsx create mode 100644 packages/wizard/src/WizardContext/index.ts create mode 100644 packages/wizard/src/WizardFooter/WizardFooter.stories.tsx create mode 100644 packages/wizard/src/WizardStep/TextNode.tsx create mode 100644 packages/wizard/src/WizardStep/WizardStep.stories.tsx create mode 100644 packages/wizard/src/constants.ts create mode 100644 packages/wizard/src/utils/CompoundComponent.tsx create mode 100644 packages/wizard/src/utils/CompoundSubComponent.tsx create mode 100644 packages/wizard/src/utils/useWizardControlledValue/index.ts create mode 100644 packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts 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 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..1d5213c36e 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -28,10 +28,21 @@ "access": "public" }, "dependencies": { + "@leafygreen-ui/button": "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", diff --git a/packages/wizard/src/Wizard.stories.tsx b/packages/wizard/src/Wizard.stories.tsx index 4c4b56cf46..bd0e73875a 100644 --- a/packages/wizard/src/Wizard.stories.tsx +++ b/packages/wizard/src/Wizard.stories.tsx @@ -1,17 +1,118 @@ - +/* 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 { css } from '@leafygreen-ui/emotion'; import { Wizard } from '.'; +faker.seed(0); + export default { - title: 'Components/Wizard', + title: 'Composition/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..31d0906c46 100644 --- a/packages/wizard/src/Wizard/Wizard.spec.tsx +++ b/packages/wizard/src/Wizard/Wizard.spec.tsx @@ -1,11 +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', () => { - test('condition', () => { + 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 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..6add408f04 100644 --- a/packages/wizard/src/Wizard/Wizard.tsx +++ b/packages/wizard/src/Wizard/Wizard.tsx @@ -1,8 +1,93 @@ import React from 'react'; + +import { Direction } from '@leafygreen-ui/descendants'; +import { findChild, findChildren } from '@leafygreen-ui/lib'; + +import { WizardSubComponentProperties } from '../constants'; +import { CompoundComponent } from '../utils/CompoundComponent'; +import { useWizardControlledValue } from '../utils/useWizardControlledValue/useWizardControlledValue'; +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 function Wizard({}: WizardProps) { - return
your content here
; -} +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 { + isControlled, + value: activeStep, + setValue: setActiveStep, + } = useWizardControlledValue(activeStepProp, undefined, 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 = (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; + + return ( + +
+
{currentStep}
+ {footerChild} +
+
+ ); + }, + { + displayName: 'Wizard', + Step: WizardStep, + Footer: WizardFooter, + }, +); -Wizard.displayName = 'Wizard'; +/** + * 🤚 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 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.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 index f0081b35c3..5bf606c5f0 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx @@ -1,11 +1,35 @@ - import React from 'react'; import { render } from '@testing-library/react'; +import { Wizard } from '../Wizard'; + import { WizardFooter } from '.'; 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..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 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..6a8d7d99c2 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.tsx @@ -1,8 +1,66 @@ -import React from 'react'; +import React, { MouseEventHandler } from 'react'; + +import { Direction } from '@leafygreen-ui/descendants'; +import { FormFooter } from '@leafygreen-ui/form-footer'; +import { consoleOnce } from '@leafygreen-ui/lib'; + +import { WizardSubComponentProperties } from '../constants'; +import { CompoundSubComponent } from '../utils/CompoundSubComponent'; +import { useWizardContext } from '../WizardContext'; + import { WizardFooterProps } from './WizardFooter.types'; -export function WizardFooter({}: WizardFooterProps) { - return
your content here
; -} +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; + } -WizardFooter.displayName = 'WizardFooter'; + 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 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..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 index fb00cde028..0e312c3bcd 100644 --- a/packages/wizard/src/WizardStep/WizardStep.spec.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.spec.tsx @@ -1,11 +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('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..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 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..28ea51c963 100644 --- a/packages/wizard/src/WizardStep/WizardStep.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.tsx @@ -1,8 +1,38 @@ import React from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { consoleOnce } from '@leafygreen-ui/lib'; +import { Description, H3 } from '@leafygreen-ui/typography'; + +import { WizardSubComponentProperties } from '../constants'; +import { CompoundSubComponent } from '../utils/CompoundSubComponent'; +import { useWizardContext } from '../WizardContext'; + +import { TextNode } from './TextNode'; +import { stepStyles } from './WizardStep.styles'; import { WizardStepProps } from './WizardStep.types'; -export function WizardStep({}: WizardStepProps) { - return
your content here
; -} +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; + } -WizardStep.displayName = 'WizardStep'; + 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 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/CompoundComponent.tsx b/packages/wizard/src/utils/CompoundComponent.tsx new file mode 100644 index 0000000000..2af7309c04 --- /dev/null +++ b/packages/wizard/src/utils/CompoundComponent.tsx @@ -0,0 +1,27 @@ +import { FunctionComponent } from 'react'; + +/** Generic properties applied to a Compound component */ +export interface CompoundComponentProperties { + displayName: string; + [key: string]: any; // Typed as `any` to avoid issues with a mapped object +} + +/** Return type of a CompoundComponent */ +export type CompoundComponentType< + Props extends {} = {}, + Properties extends CompoundComponentProperties = CompoundComponentProperties, +> = FunctionComponent & Properties; + +/** + * Factory function used to create a compound component parent + * @returns {CompoundComponentType} + */ +export const CompoundComponent = < + Props extends {} = {}, + Properties extends CompoundComponentProperties = CompoundComponentProperties, +>( + componentRenderFn: FunctionComponent, + properties: Properties, +): CompoundComponentType => { + return Object.assign(componentRenderFn, properties); +}; diff --git a/packages/wizard/src/utils/CompoundSubComponent.tsx b/packages/wizard/src/utils/CompoundSubComponent.tsx new file mode 100644 index 0000000000..f60f9603b6 --- /dev/null +++ b/packages/wizard/src/utils/CompoundSubComponent.tsx @@ -0,0 +1,41 @@ +import { FunctionComponent } from 'react'; + +interface SubComponentProperties { + displayName: string; + key: Key; +} + +export type SubComponentType< + Key extends string, + Props extends {} = {}, +> = FunctionComponent & { + [key in Key]: true; +}; + +/** + * Factory function to create a compound sub-component with a static `key` property. + * Sets the given `key` property on the resulting component to true. + * + * @example + * ```tsx + * const MySubComponent = CompoundSubComponent(() =>
, { + * displayName: 'MySubComponent', + * key: 'isSubComponent' + * }) + * MySubComponent.isSubComponent // true + * ``` + * + * @param componentRenderFn The component render function + * @param properties Object describing the `displayName` and `key` + * @returns {SubComponentType} + */ +export const CompoundSubComponent = ( + componentRenderFn: FunctionComponent, + properties: SubComponentProperties, +): SubComponentType => { + const { key, ...rest } = properties; + return Object.assign(componentRenderFn, { + ...rest, + [key]: true, + }) as SubComponentType; +}; 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 02145aa388..b831eecd6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3815,15 +3815,43 @@ importers: packages/wizard: dependencies: + '@leafygreen-ui/button': + specifier: workspace:^ + version: link:../button + '@leafygreen-ui/descendants': + specifier: workspace:^ + version: link:../descendants '@leafygreen-ui/emotion': specifier: workspace:^ version: link:../emotion + '@leafygreen-ui/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: From 10532afeb7d4db6c80126f4914636f85e63a8dcf Mon Sep 17 00:00:00 2001 From: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:54:52 -0400 Subject: [PATCH 06/10] chore(Wizard) Updates wizard utilities to use `hooks` and `compound-components` (#3200) * install cc * use CC in wiz * useControlled * rm isControlled check * lint --- packages/wizard/package.json | 1 + packages/wizard/src/Wizard/Wizard.tsx | 49 +++++----- .../wizard/src/WizardFooter/WizardFooter.tsx | 2 +- packages/wizard/src/WizardStep/WizardStep.tsx | 2 +- .../wizard/src/utils/CompoundComponent.tsx | 27 ------ .../wizard/src/utils/CompoundSubComponent.tsx | 41 -------- .../utils/useWizardControlledValue/index.ts | 1 - .../useWizardControlledValue.ts | 96 ------------------- packages/wizard/tsconfig.json | 6 ++ pnpm-lock.yaml | 3 + 10 files changed, 37 insertions(+), 191 deletions(-) delete mode 100644 packages/wizard/src/utils/CompoundComponent.tsx delete mode 100644 packages/wizard/src/utils/CompoundSubComponent.tsx delete mode 100644 packages/wizard/src/utils/useWizardControlledValue/index.ts delete mode 100644 packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts diff --git a/packages/wizard/package.json b/packages/wizard/package.json index 1d5213c36e..5264c0ab18 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@leafygreen-ui/button": "workspace:^", + "@leafygreen-ui/compound-component": "workspace:^", "@leafygreen-ui/descendants": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/form-footer": "workspace:^", diff --git a/packages/wizard/src/Wizard/Wizard.tsx b/packages/wizard/src/Wizard/Wizard.tsx index 6add408f04..ad5ed5d165 100644 --- a/packages/wizard/src/Wizard/Wizard.tsx +++ b/packages/wizard/src/Wizard/Wizard.tsx @@ -1,11 +1,14 @@ -import React from 'react'; +import React, { useCallback } from 'react'; +import { + CompoundComponent, + findChild, + findChildren, +} from '@leafygreen-ui/compound-component'; import { Direction } from '@leafygreen-ui/descendants'; -import { findChild, findChildren } from '@leafygreen-ui/lib'; +import { useControlled } from '@leafygreen-ui/hooks'; import { WizardSubComponentProperties } from '../constants'; -import { CompoundComponent } from '../utils/CompoundComponent'; -import { useWizardControlledValue } from '../utils/useWizardControlledValue/useWizardControlledValue'; import { WizardProvider } from '../WizardContext/WizardContext'; import { WizardFooter } from '../WizardFooter'; import { WizardStep } from '../WizardStep'; @@ -30,11 +33,8 @@ export const Wizard = CompoundComponent( ); // Controlled/Uncontrolled activeStep value - const { - isControlled, - value: activeStep, - setValue: setActiveStep, - } = useWizardControlledValue(activeStepProp, undefined, 0); + const { value: activeStep, updateValue: setActiveStep } = + useControlled(activeStepProp, onStepChange, 0); if ( activeStepProp && @@ -47,22 +47,23 @@ export const Wizard = CompoundComponent( ); } - 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); - } - }; + 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); + } + }; - if (!isControlled) { - setActiveStep(getNextStep); - } - - onStepChange?.(getNextStep(activeStep)); - }; + // 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; diff --git a/packages/wizard/src/WizardFooter/WizardFooter.tsx b/packages/wizard/src/WizardFooter/WizardFooter.tsx index 6a8d7d99c2..2a46c9201a 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.tsx @@ -1,11 +1,11 @@ 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 { CompoundSubComponent } from '../utils/CompoundSubComponent'; import { useWizardContext } from '../WizardContext'; import { WizardFooterProps } from './WizardFooter.types'; diff --git a/packages/wizard/src/WizardStep/WizardStep.tsx b/packages/wizard/src/WizardStep/WizardStep.tsx index 28ea51c963..4af5eba958 100644 --- a/packages/wizard/src/WizardStep/WizardStep.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.tsx @@ -1,11 +1,11 @@ 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 { CompoundSubComponent } from '../utils/CompoundSubComponent'; import { useWizardContext } from '../WizardContext'; import { TextNode } from './TextNode'; diff --git a/packages/wizard/src/utils/CompoundComponent.tsx b/packages/wizard/src/utils/CompoundComponent.tsx deleted file mode 100644 index 2af7309c04..0000000000 --- a/packages/wizard/src/utils/CompoundComponent.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FunctionComponent } from 'react'; - -/** Generic properties applied to a Compound component */ -export interface CompoundComponentProperties { - displayName: string; - [key: string]: any; // Typed as `any` to avoid issues with a mapped object -} - -/** Return type of a CompoundComponent */ -export type CompoundComponentType< - Props extends {} = {}, - Properties extends CompoundComponentProperties = CompoundComponentProperties, -> = FunctionComponent & Properties; - -/** - * Factory function used to create a compound component parent - * @returns {CompoundComponentType} - */ -export const CompoundComponent = < - Props extends {} = {}, - Properties extends CompoundComponentProperties = CompoundComponentProperties, ->( - componentRenderFn: FunctionComponent, - properties: Properties, -): CompoundComponentType => { - return Object.assign(componentRenderFn, properties); -}; diff --git a/packages/wizard/src/utils/CompoundSubComponent.tsx b/packages/wizard/src/utils/CompoundSubComponent.tsx deleted file mode 100644 index f60f9603b6..0000000000 --- a/packages/wizard/src/utils/CompoundSubComponent.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { FunctionComponent } from 'react'; - -interface SubComponentProperties { - displayName: string; - key: Key; -} - -export type SubComponentType< - Key extends string, - Props extends {} = {}, -> = FunctionComponent & { - [key in Key]: true; -}; - -/** - * Factory function to create a compound sub-component with a static `key` property. - * Sets the given `key` property on the resulting component to true. - * - * @example - * ```tsx - * const MySubComponent = CompoundSubComponent(() =>
, { - * displayName: 'MySubComponent', - * key: 'isSubComponent' - * }) - * MySubComponent.isSubComponent // true - * ``` - * - * @param componentRenderFn The component render function - * @param properties Object describing the `displayName` and `key` - * @returns {SubComponentType} - */ -export const CompoundSubComponent = ( - componentRenderFn: FunctionComponent, - properties: SubComponentProperties, -): SubComponentType => { - const { key, ...rest } = properties; - return Object.assign(componentRenderFn, { - ...rest, - [key]: true, - }) as SubComponentType; -}; diff --git a/packages/wizard/src/utils/useWizardControlledValue/index.ts b/packages/wizard/src/utils/useWizardControlledValue/index.ts deleted file mode 100644 index 07ea91dbb7..0000000000 --- a/packages/wizard/src/utils/useWizardControlledValue/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useWizardControlledValue } from './useWizardControlledValue'; diff --git a/packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts b/packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts deleted file mode 100644 index 1081c3691c..0000000000 --- a/packages/wizard/src/utils/useWizardControlledValue/useWizardControlledValue.ts +++ /dev/null @@ -1,96 +0,0 @@ -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 d245893b68..26dc97f771 100644 --- a/packages/wizard/tsconfig.json +++ b/packages/wizard/tsconfig.json @@ -9,6 +9,12 @@ "include": ["src/**/*"], "exclude": ["**/*.spec.*", "**/*.stories.*"], "references": [ + { + "path": "../button" + }, + { + "path": "../compound-component" + }, { "path": "../button" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b831eecd6e..94ab044575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3818,6 +3818,9 @@ importers: '@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 From c6a94eca434201e7d4a69eb2991e53540214cc1e Mon Sep 17 00:00:00 2001 From: Adam Thompson Date: Fri, 10 Oct 2025 16:44:14 -0400 Subject: [PATCH 07/10] init wizard changeset --- .changeset/wizard.md | 5 +++++ packages/wizard/package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/wizard.md diff --git a/.changeset/wizard.md b/.changeset/wizard.md new file mode 100644 index 0000000000..9d2bf9262d --- /dev/null +++ b/.changeset/wizard.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/wizard': minor +--- + +Initial Wizard package release diff --git a/packages/wizard/package.json b/packages/wizard/package.json index 5264c0ab18..be08f12182 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -1,7 +1,7 @@ { "name": "@leafygreen-ui/wizard", - "version": "0.1.0", + "version": "0.0.1", "description": "LeafyGreen UI Kit Wizard", "main": "./dist/umd/index.js", "module": "./dist/esm/index.js", From b48488b3658a5075f52180759f6960e46a0d9006 Mon Sep 17 00:00:00 2001 From: Adam Thompson Date: Tue, 21 Oct 2025 17:32:51 -0400 Subject: [PATCH 08/10] refactor(WizardFooter): simplify props by extending FormFooterProps --- .../src/WizardFooter/WizardFooter.types.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/wizard/src/WizardFooter/WizardFooter.types.ts b/packages/wizard/src/WizardFooter/WizardFooter.types.ts index cf2617761d..26590f1442 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.types.ts +++ b/packages/wizard/src/WizardFooter/WizardFooter.types.ts @@ -1,18 +1,5 @@ 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']; -} +export interface WizardFooterProps + extends React.ComponentProps<'footer'>, + FormFooterProps {} From d8ab48d454f59786954f3bf94993bd07ea17b474 Mon Sep 17 00:00:00 2001 From: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:49:14 -0500 Subject: [PATCH 09/10] [LG-5562] feat(Wizard) Updates `Wizard` API (#3336) * rm step wrapper * rm descendants dep * export WizardProvider * delete-wizard-demo private endpoints useFetchRequiredActionTableData renam ReqAct cards composable basic table stream processing card federated db card applications card clusters card wizard step context Delete requiredActionsConfig.tsx re-enable wizard add useRequiredActionAcknowledgements mv required action. add skeleton Update ModelApiKeysCard.tsx * Update pnpm Update package.json * fix wizard changes * Adds `requiresAcknowledgement` prop to Wizard.Step * Implements `isAcknowledged` state inside provider * Update Wizard.stories.tsx * rm delete demo * Update wizard.md * rm temp changesets * Update README.md * Update WizardStep.spec.tsx * footer tests * Update Wizard.spec.tsx * update package json * update provider props * revert toast changes? * Update .npmrc * Update pnpm-lock.yaml * Update WizardStep.spec.tsx * exports form footer types * Update WizardFooter.types.ts * adds `totalSteps` to wizard context * fix bad merge * updates readme * updates tsdoc * fixes tests * fixes ack reset test * Update WizardStep.spec.tsx * fixes stories --- .changeset/form-footer-interfaces.md | 5 + .changeset/wizard.md | 4 +- packages/form-footer/src/FormFooter.types.ts | 22 +- packages/form-footer/src/index.ts | 13 +- packages/wizard/README.md | 85 +++++++ packages/wizard/package.json | 20 +- packages/wizard/src/Wizard.stories.tsx | 182 ++++++++------ packages/wizard/src/Wizard/Wizard.spec.tsx | 210 +++++++++++----- packages/wizard/src/Wizard/Wizard.styles.ts | 5 - packages/wizard/src/Wizard/Wizard.tsx | 61 ++--- packages/wizard/src/Wizard/Wizard.types.ts | 24 +- packages/wizard/src/Wizard/index.ts | 2 +- .../src/WizardContext/WizardContext.tsx | 36 ++- .../src/WizardFooter/WizardFooter.spec.tsx | 229 +++++++++++++++++- .../wizard/src/WizardFooter/WizardFooter.tsx | 12 +- .../src/WizardFooter/WizardFooter.types.ts | 15 +- .../wizard/src/WizardStep/WizardStep.spec.tsx | 152 +++++++++++- .../src/WizardStep/WizardStep.stories.tsx | 58 ++--- packages/wizard/src/WizardStep/WizardStep.tsx | 36 ++- .../wizard/src/WizardStep/WizardStep.types.ts | 21 +- .../src/WizardStep/WizardStepContext.tsx | 47 ++++ packages/wizard/src/WizardStep/index.ts | 1 + packages/wizard/src/index.ts | 3 +- packages/wizard/tsconfig.json | 31 ++- pnpm-lock.yaml | 39 ++- tools/install/src/ALL_PACKAGES.ts | 1 + 26 files changed, 1010 insertions(+), 304 deletions(-) create mode 100644 .changeset/form-footer-interfaces.md create mode 100644 packages/wizard/src/WizardStep/WizardStepContext.tsx 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 94ab044575..8d11ca2f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3815,15 +3815,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 @@ -3842,9 +3836,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 @@ -3852,9 +3843,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', From c462dca179c2a34394a37c79f2a28268643292b2 Mon Sep 17 00:00:00 2001 From: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:59:13 -0500 Subject: [PATCH 10/10] [LG-5566] tests(Wizard) Implement TestUtils & LGIDs for `Wizard` (#3338) * rm step wrapper * rm descendants dep * export WizardProvider * delete-wizard-demo private endpoints useFetchRequiredActionTableData renam ReqAct cards composable basic table stream processing card federated db card applications card clusters card wizard step context Delete requiredActionsConfig.tsx re-enable wizard add useRequiredActionAcknowledgements mv required action. add skeleton Update ModelApiKeysCard.tsx * Update pnpm Update package.json * fix wizard changes * Adds `requiresAcknowledgement` prop to Wizard.Step * Implements `isAcknowledged` state inside provider * Update Wizard.stories.tsx * rm delete demo * Update wizard.md * rm temp changesets * Update README.md * Update WizardStep.spec.tsx * footer tests * Update Wizard.spec.tsx * update package json * update provider props * revert toast changes? * Update .npmrc * Update pnpm-lock.yaml * Update WizardStep.spec.tsx * exports form footer types * Update WizardFooter.types.ts * adds `totalSteps` to wizard context * fix bad merge * adds LGIDs * adds test utils * lint * fix bad merge * removes Step test utils * add layout comments * form-footer lgids * updates wizard testids * updates readme * updates tsdoc * fixes tests * fixes ack reset test * Squashed commit of the following: commit 4fd366809bbfe07bea85c64377aa707c4ef410c9 Author: Adam Michael Thompson Date: Tue Nov 25 13:18:59 2025 -0500 fixes ack reset test commit 4f024b1cdbde2a0908e0b70540b6522878a7e975 Author: Adam Michael Thompson Date: Tue Nov 25 13:11:35 2025 -0500 fixes tests commit f919ecc0fa1155acfdcdbb50e42d710f102ae727 Author: Adam Michael Thompson Date: Tue Nov 25 13:11:29 2025 -0500 updates tsdoc commit 6842bbb7edd877ab1a2306958c4ee19b409b4da0 Author: Adam Michael Thompson Date: Tue Nov 25 13:02:43 2025 -0500 updates readme * Update WizardStep.spec.tsx * Update WizardContext.tsx * Update WizardStep.spec.tsx * Squashed commit of the following: commit 982ef7265162cf067f489f2c10fe162394d16736 Author: Adam Michael Thompson Date: Tue Nov 25 13:52:15 2025 -0500 Update WizardStep.spec.tsx * fixes stories * Squashed commit of the following: commit 4b32ed6cc24aa347aeb79630ea146bf27ea28839 Author: Adam Michael Thompson Date: Tue Nov 25 17:49:16 2025 -0500 fixes stories commit 982ef7265162cf067f489f2c10fe162394d16736 Author: Adam Michael Thompson Date: Tue Nov 25 13:52:15 2025 -0500 Update WizardStep.spec.tsx * Update WizardStep.stories.tsx * Update packages/wizard/src/testing/getTestUtils.tsx Co-authored-by: Shaneeza * Update README.md * use Button test utils * use test utils * Update pnpm-lock.yaml --------- Co-authored-by: Shaneeza --- .changeset/form-footer-lgids.md | 5 + packages/form-footer/src/FormFooter.tsx | 1 + packages/wizard/README.md | 48 +++ packages/wizard/package.json | 2 +- packages/wizard/src/Wizard/Wizard.spec.tsx | 78 +++-- packages/wizard/src/Wizard/Wizard.tsx | 14 +- packages/wizard/src/Wizard/Wizard.types.ts | 4 +- .../src/WizardContext/WizardContext.tsx | 7 + .../src/WizardFooter/WizardFooter.spec.tsx | 69 +++-- .../wizard/src/WizardFooter/WizardFooter.tsx | 11 +- .../wizard/src/WizardStep/WizardStep.spec.tsx | 8 +- .../src/WizardStep/WizardStep.stories.tsx | 8 +- packages/wizard/src/WizardStep/WizardStep.tsx | 4 + .../wizard/src/testing/getTestUtils.spec.tsx | 280 +++++++++++++++++- packages/wizard/src/testing/getTestUtils.tsx | 114 ++++++- .../wizard/src/testing/getTestUtils.types.ts | 25 +- packages/wizard/src/utils/getLgIds.ts | 6 +- pnpm-lock.yaml | 6 +- 18 files changed, 608 insertions(+), 82 deletions(-) create mode 100644 .changeset/form-footer-lgids.md diff --git a/.changeset/form-footer-lgids.md b/.changeset/form-footer-lgids.md new file mode 100644 index 0000000000..df3b330741 --- /dev/null +++ b/.changeset/form-footer-lgids.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/form-footer': patch +--- + +Passes `data-lgid` to the root `footer` element diff --git a/packages/form-footer/src/FormFooter.tsx b/packages/form-footer/src/FormFooter.tsx index 390583b014..91aab13658 100644 --- a/packages/form-footer/src/FormFooter.tsx +++ b/packages/form-footer/src/FormFooter.tsx @@ -47,6 +47,7 @@ export default function FormFooter({