From 5810d879cc9aee37ab2f530bf81ba6ab74eba8aa Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 14 Oct 2026 14:24:46 -0400 Subject: [PATCH 01/13] Migrate Animation stories into gamut Storybook Co-locate Rotation, ExpandInCollapseOut, and FadeInSlideOut stories into packages/gamut/src/Animation (behavior + a11y via play), and remove the old styleguide Animations folder. Also drop the now-empty Atoms About/AboutToC. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Animation/ExpandInCollapseOut.stories.tsx | 59 +++++++++++++++++ .../src/Animation/FadeInSlideOut.stories.tsx | 53 +++++++++++++++ .../gamut/src/Animation/Rotation.stories.tsx | 65 +++++++++++++++++++ packages/styleguide/src/lib/Atoms/About.mdx | 22 ------- .../styleguide/src/lib/Atoms/AboutToC.tsx | 45 ------------- .../src/lib/Atoms/Animations/About.mdx | 29 --------- .../ExpandInCollapseOut.mdx | 32 --------- .../ExpandInCollapseOut.stories.tsx | 38 ----------- .../FadeInSlideOut/FadeInSlideOut.mdx | 31 --------- .../FadeInSlideOut/FadeInSlideOut.stories.tsx | 37 ----------- .../Atoms/Animations/Rotation/Rotation.mdx | 32 --------- .../Animations/Rotation/Rotation.stories.tsx | 30 --------- 12 files changed, 177 insertions(+), 296 deletions(-) create mode 100644 packages/gamut/src/Animation/ExpandInCollapseOut.stories.tsx create mode 100644 packages/gamut/src/Animation/FadeInSlideOut.stories.tsx create mode 100644 packages/gamut/src/Animation/Rotation.stories.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/About.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/AboutToC.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/Animations/About.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.stories.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.stories.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.stories.tsx diff --git a/packages/gamut/src/Animation/ExpandInCollapseOut.stories.tsx b/packages/gamut/src/Animation/ExpandInCollapseOut.stories.tsx new file mode 100644 index 0000000000..69cfb0a051 --- /dev/null +++ b/packages/gamut/src/Animation/ExpandInCollapseOut.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AnimatePresence } from 'framer-motion'; +import { useState } from 'react'; +import { expect, waitFor } from 'storybook/test'; + +import { Box, FlexBox } from '../Box'; +import { FillButton } from '../Button'; +import { ExpandInCollapseOut } from './ExpandInCollapseOut'; + +const meta: Meta = { + title: 'Media & Assets/Animations/ExpandInCollapseOut', + component: ExpandInCollapseOut, + args: { + children: + "I got bored one day - and I put everything on a bagel. Everything. All my hopes and dreams, my old report cards, every breed of dog, every last personal ad on craigslist. Sesame. Poppy seed. Salt. And it collapsed in on itself. 'Cause, you see, when you really put everything on a bagel, it becomes this.", + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: function ExpandInCollapseOutDemo({ children }) { + const [isExpanded, setExpanded] = useState(true); + + return ( + + + setExpanded(!isExpanded)}> + Look, the text expands in and collapses out! + + + + + {isExpanded && ( + {children} + )} + + + + ); + }, + play: async ({ canvas, userEvent }) => { + const toggle = canvas.getByRole('button', { + name: 'Look, the text expands in and collapses out!', + }); + + canvas.getByText(/put everything on a bagel/i); + + await userEvent.click(toggle); + await waitFor(() => + expect(canvas.queryByText(/put everything on a bagel/i)).toBeNull() + ); + + await userEvent.click(toggle); + await canvas.findByText(/put everything on a bagel/i); + }, +}; diff --git a/packages/gamut/src/Animation/FadeInSlideOut.stories.tsx b/packages/gamut/src/Animation/FadeInSlideOut.stories.tsx new file mode 100644 index 0000000000..266a3db628 --- /dev/null +++ b/packages/gamut/src/Animation/FadeInSlideOut.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AnimatePresence } from 'framer-motion'; +import { useState } from 'react'; +import { expect } from 'storybook/test'; + +import { Box, FlexBox } from '../Box'; +import { FillButton } from '../Button'; +import { FadeInSlideOut } from './FadeInSlideOut'; + +const meta: Meta = { + title: 'Media & Assets/Animations/FadeInSlideOut', + component: FadeInSlideOut, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: function FadeInSlideOutDemo() { + const [isVisible, setIsVisible] = useState(false); + + return ( + + + setIsVisible(!isVisible)}> + {isVisible ? 'Thanks!' : 'Click me.'} + + + + + {isVisible && ( + + + Even in a stupid, stupid universe where we have hot dogs for + fingers, we get very good with our feet. + + + )} + + + + ); + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.queryByText(/hot dogs for/i)).toBeNull(); + + await userEvent.click(canvas.getByRole('button', { name: 'Click me.' })); + + await canvas.findByText(/hot dogs for/i); + canvas.getByRole('button', { name: 'Thanks!' }); + }, +}; diff --git a/packages/gamut/src/Animation/Rotation.stories.tsx b/packages/gamut/src/Animation/Rotation.stories.tsx new file mode 100644 index 0000000000..d6fc0a80b9 --- /dev/null +++ b/packages/gamut/src/Animation/Rotation.stories.tsx @@ -0,0 +1,65 @@ +import { MiniChevronDownIcon } from '@skillsoft/gamut-icons'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect, useState } from 'react'; + +import { StrokeButton } from '../Button'; +import { Rotation } from './Rotation'; + +const meta: Meta = { + title: 'Media & Assets/Animations/Rotation', + component: Rotation, + args: { + rotated: false, + }, + argTypes: { + degrees: { + table: { + type: { summary: 'number' }, + defaultValue: { summary: '180' }, + }, + }, + height: { + table: { + type: { summary: 'number' }, + defaultValue: { summary: '16' }, + }, + }, + width: { + table: { + type: { summary: 'number' }, + defaultValue: { summary: '16' }, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: function RotationDemo(args) { + const [isRotated, setRotated] = useState(args.rotated); + + useEffect(() => { + setRotated(args.rotated); + }, [args.rotated]); + + return ( + setRotated(!isRotated)} + > + + + + + ); + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click( + canvas.getByRole('button', { name: 'Toggle rotation demo' }) + ); + canvas.getByRole('img', { hidden: true }); + }, +}; diff --git a/packages/styleguide/src/lib/Atoms/About.mdx b/packages/styleguide/src/lib/Atoms/About.mdx deleted file mode 100644 index 9ae6f134c3..0000000000 --- a/packages/styleguide/src/lib/Atoms/About.mdx +++ /dev/null @@ -1,22 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; - -import { AboutHeader } from '~styleguide/blocks'; - -import { AtomAboutToC } from './AboutToC'; - -export const parameters = { - id: 'Atoms', - title: 'Atoms', - subtitle: - 'Atoms are our basic building block components. They are the most granular, low-level visuals in our system.', -}; - - - - - -> Atoms are the basic building blocks of matter. Applied to web interfaces, atoms are our HTML tags, such as a form label, an input or a button. Atoms can also include more abstract elements like color palettes, fonts and even more invisible aspects of an interface like animations. -> -> [- Brad Frost, author of "Atomic Design"](https://bradfrost.com/blog/post/atomic-web-design/#atoms) - - diff --git a/packages/styleguide/src/lib/Atoms/AboutToC.tsx b/packages/styleguide/src/lib/Atoms/AboutToC.tsx deleted file mode 100644 index 57a99ffed7..0000000000 --- a/packages/styleguide/src/lib/Atoms/AboutToC.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { addParentPath, TableOfContents } from '~styleguide/blocks'; - -import { parameters as animationsParameters } from './Animations/About.mdx'; -import { parameters as badgeParameters } from './Badge/Badge.mdx'; -import { parameters as buttonsParameters } from './Buttons/About.mdx'; -import { parameters as cardParameters } from './Card/Card.mdx'; -import { parameters as drawerParameters } from './Drawer/Drawer.mdx'; -import { parameters as formElementsParameters } from './FormElements/About.mdx'; -import { parameters as formInputsParameters } from './FormInputs/About.mdx'; -import { parameters as iconsParameters } from './Icons/About.mdx'; -import { parameters as illustrationsParameters } from './Illustrations/Illustrations.mdx'; -import { parameters as loadersParameters } from './Loaders/About.mdx'; -import { parameters as patternsParameters } from './Patterns/Patterns.mdx'; -import { parameters as popoverContainerParameters } from './PopoverContainer/PopoverContainer.mdx'; -import { parameters as progressBarParameters } from './ProgressBar/ProgressBar.mdx'; -import { parameters as radialProgressParameters } from './RadialProgress/RadialProgress.mdx'; -import { parameters as skipToContentParameters } from './SkipToContent/SkipToContent.mdx'; -import { parameters as tagParameters } from './Tag/Tag.mdx'; -import { parameters as toggleParameters } from './Toggle/Toggle.mdx'; -import { parameters as utilityComponentsParameters } from './UtilityComponents/About.mdx'; - -export const AtomAboutToC = () => ( - -); diff --git a/packages/styleguide/src/lib/Atoms/Animations/About.mdx b/packages/styleguide/src/lib/Atoms/Animations/About.mdx deleted file mode 100644 index ec2e441007..0000000000 --- a/packages/styleguide/src/lib/Atoms/Animations/About.mdx +++ /dev/null @@ -1,29 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; - -import { - AboutHeader, - addParentPath, - TableOfContents, -} from '~styleguide/blocks'; - -import { parameters as expandInCollapseOutParameters } from './ExpandInCollapseOut/ExpandInCollapseOut.mdx'; -import { parameters as rotationParameters } from './Rotation/Rotation.mdx'; - -export const parameters = { - id: 'Atoms/Animations', - title: 'Animations', - subtitle: 'Controlled containers for predictable animations.', -}; - - - - - -Animations should not be the click target of actions, but rather the container for the content you wish to animate. - - diff --git a/packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.mdx b/packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.mdx deleted file mode 100644 index 7efc5c02fe..0000000000 --- a/packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.mdx +++ /dev/null @@ -1,32 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as ExpandInCollapseOut from './ExpandInCollapseOut.stories'; - -export const parameters = { - title: 'ExpandInCollapseOut', - subtitle: `A container that applies an expand and collapse animation to its children.`, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Animation/ExpandInCollapseOut.tsx', - }, -}; - - - - - -## Usage - -This animation uses [Framer Motion](https://motion.dev/) to animate the expansion and collapse of a component. The initial and exit state is `"collapsed"` and the animation is `"expanded"`. - -Wrap the `ExpandInCollapseOut` component with an `AnimatePresence` and use a state to control whether or not the children of the ExpandInCollapseOut component are rendered. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.stories.tsx b/packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.stories.tsx deleted file mode 100644 index bd8a911cc2..0000000000 --- a/packages/styleguide/src/lib/Atoms/Animations/ExpandInCollapseOut/ExpandInCollapseOut.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { - Box, - ExpandInCollapseOut, - FillButton, - FlexBox, -} from '@skillsoft/gamut'; -import type { Meta } from '@storybook/react'; -import { AnimatePresence } from 'framer-motion'; -import { PropsWithChildren, useState } from 'react'; - -const meta: Meta = { - component: ExpandInCollapseOut, - args: { - children: - "I got bored one day - and I put everything on a bagel. Everything. All my hopes and dreams, my old report cards, every breed of dog, every last personal ad on craigslist. Sesame. Poppy seed. Salt. And it collapsed in on itself. 'Cause, you see, when you really put everything on a bagel, it becomes this.", - }, -}; - -export default meta; - -export const Default: React.FC = ({ children }) => { - const [isExpanded, setExpanded] = useState(true); - - return ( - - - setExpanded(!isExpanded)}> - Look, the text expands in and collapses out! - - - - - {isExpanded && {children}} - - - - ); -}; diff --git a/packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.mdx b/packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.mdx deleted file mode 100644 index fb6350845b..0000000000 --- a/packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.mdx +++ /dev/null @@ -1,31 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as FadeInSlideOut from './FadeInSlideOut.stories'; - -export const parameters = { - subtitle: `An animation component that fades in when appearing and slides out when disappearing.`, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Animation/FadeInSlideOut.tsx', - }, -}; - - - - - -## Usage - -This animation uses [Framer Motion](https://motion.dev/) to animate the appearance and disappearance of a component. The animation has three states, `initial`, `visible`, and `exit`. - -Wrap the `FadeInSlideOut` component with an `AnimatePresence` and pass in children components to be animated. The component should be conditionally rendered (using `&&` or a ternary operator). The animation is triggered when the component is added to or removed from the DOM, not by changing styles or props. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.stories.tsx b/packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.stories.tsx deleted file mode 100644 index 2b8197371f..0000000000 --- a/packages/styleguide/src/lib/Atoms/Animations/FadeInSlideOut/FadeInSlideOut.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Box, FadeInSlideOut, FillButton, FlexBox } from '@skillsoft/gamut'; -import type { Meta } from '@storybook/react'; -import { AnimatePresence } from 'framer-motion'; -import { useState } from 'react'; - -const meta: Meta = { - component: FadeInSlideOut, - args: {}, -}; - -export default meta; - -export const Default: React.FC = () => { - const [isVisible, setIsVisible] = useState(false); - - return ( - - - setIsVisible(!isVisible)}> - {isVisible ? 'Thanks!' : 'Click me.'} - - - - - {isVisible && ( - - - Even in a stupid, stupid universe where we have hot dogs for - fingers, we get very good with our feet. - - - )} - - - - ); -}; diff --git a/packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.mdx b/packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.mdx deleted file mode 100644 index 2145707986..0000000000 --- a/packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.mdx +++ /dev/null @@ -1,32 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as RotationStories from './Rotation.stories'; - -export const parameters = { - title: 'Rotation', - subtitle: 'A container that can rotate its children.', - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Animation/Rotation.tsx', - }, -}; - - - - - -## Usage - -The Rotation component is a container that can rotate its children and specify the degree of the rotation. - -One common usage is to set the rotation of an arrow icon to indicate that a component can expand and then rotate back to collapse. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.stories.tsx b/packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.stories.tsx deleted file mode 100644 index 11aa89d009..0000000000 --- a/packages/styleguide/src/lib/Atoms/Animations/Rotation/Rotation.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Rotation, StrokeButton } from '@skillsoft/gamut'; -import { MiniChevronDownIcon } from '@skillsoft/gamut-icons'; -import type { Meta } from '@storybook/react'; -import { ComponentProps, useEffect, useState } from 'react'; - -const meta: Meta = { - component: Rotation, - args: {}, -}; - -export default meta; - -export const Default: React.FC> = (args) => { - const [isRotated, setRotated] = useState(args.rotated); - - useEffect(() => { - setRotated(args.rotated); - }, [args.rotated]); - - return ( - setRotated(!isRotated)} - > - - - - - ); -}; From 9b89c9becc13df243009bed7ae1422864066362f Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 14 Oct 2026 15:37:45 -0400 Subject: [PATCH 02/13] Migrate Button stories into gamut Storybook Co-locate Button atoms (ButtonExamples, CTAButton, FillButton, IconButton, StrokeButton, TextButton) as stories under Actions/Buttons, folding the old IconButton/TextButton node tests into play assertions. Add a variant argType + full variant/size matrix (with a labeled column) to the examples grid, and a storySort pinning Button Examples first in the folder. Remove the old styleguide Buttons folder and the Button __tests__. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/.storybook/preview.ts | 5 + .../src/Button/ButtonExamples.stories.tsx | 133 ++++++++++++++++++ .../src/Button}/CTAButton.stories.tsx | 19 ++- .../src/Button}/FillButton.stories.tsx | 22 ++- .../src/Button}/IconButton.stories.tsx | 77 ++++++++-- .../src/Button}/StrokeButton.stories.tsx | 22 ++- .../gamut/src/Button/TextButton.stories.tsx | 74 ++++++++++ .../src/Button/__tests__/IconButton.test.tsx | 103 -------------- .../src/Button/__tests__/TextButton.test.tsx | 29 ---- packages/gamut/src/Button/__tests__/mocks.tsx | 20 --- .../src/lib/Atoms/Buttons/About.mdx | 35 ----- .../src/lib/Atoms/Buttons/Button/Button.mdx | 109 -------------- .../Atoms/Buttons/Button/Button.stories.tsx | 119 ---------------- .../lib/Atoms/Buttons/CTAButton/CTAButton.mdx | 36 ----- .../Atoms/Buttons/FillButton/FillButton.mdx | 36 ----- .../Atoms/Buttons/IconButton/IconButton.mdx | 44 ------ .../Buttons/StrokeButton/StrokeButton.mdx | 36 ----- .../Atoms/Buttons/TextButton/TextButton.mdx | 42 ------ .../Buttons/TextButton/TextButton.stories.tsx | 39 ----- 19 files changed, 340 insertions(+), 660 deletions(-) create mode 100644 packages/gamut/src/Button/ButtonExamples.stories.tsx rename packages/{styleguide/src/lib/Atoms/Buttons/CTAButton => gamut/src/Button}/CTAButton.stories.tsx (50%) rename packages/{styleguide/src/lib/Atoms/Buttons/FillButton => gamut/src/Button}/FillButton.stories.tsx (57%) rename packages/{styleguide/src/lib/Atoms/Buttons/IconButton => gamut/src/Button}/IconButton.stories.tsx (51%) rename packages/{styleguide/src/lib/Atoms/Buttons/StrokeButton => gamut/src/Button}/StrokeButton.stories.tsx (57%) create mode 100644 packages/gamut/src/Button/TextButton.stories.tsx delete mode 100644 packages/gamut/src/Button/__tests__/IconButton.test.tsx delete mode 100644 packages/gamut/src/Button/__tests__/TextButton.test.tsx delete mode 100644 packages/gamut/src/Button/__tests__/mocks.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/About.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/CTAButton/CTAButton.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/FillButton/FillButton.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/StrokeButton/StrokeButton.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.stories.tsx diff --git a/packages/.storybook/preview.ts b/packages/.storybook/preview.ts index fb4b74209c..b771e46bbd 100644 --- a/packages/.storybook/preview.ts +++ b/packages/.storybook/preview.ts @@ -14,6 +14,11 @@ const preview: Preview = { disable: true, }, deepControls: { enabled: true }, + options: { + storySort: { + order: ['Actions', ['Buttons', ['Button Examples', '*']], '*'], + }, + }, docs: { container: DocsContainer, theme, diff --git a/packages/gamut/src/Button/ButtonExamples.stories.tsx b/packages/gamut/src/Button/ButtonExamples.stories.tsx new file mode 100644 index 0000000000..8822aa8c56 --- /dev/null +++ b/packages/gamut/src/Button/ButtonExamples.stories.tsx @@ -0,0 +1,133 @@ +import { + MiniArrowLeftIcon, + MiniArrowRightIcon, + MiniDeleteIcon, + MiniRibbonIcon, + SearchIcon, +} from '@skillsoft/gamut-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Box, FlexBox, GridBox } from '../Box'; +import { FillButton } from './FillButton'; +import { IconButton } from './IconButton'; +import { buttonVariants } from './shared'; +import { StrokeButton } from './StrokeButton'; +import { TextButton } from './TextButton'; + +const meta: Meta = { + title: 'Actions/Buttons/Button Examples', + component: FillButton, + args: {}, +}; + +export default meta; +type Story = StoryObj; + +export const InlineIcons: Story = { + render: () => ( + + + FillButton + + + Leading icon + + + TextButton + + + ), + play: ({ canvas }) => { + canvas.getByRole('button', { name: 'FillButton' }); + canvas.getByRole('button', { name: 'Leading icon' }); + canvas.getByRole('button', { name: 'TextButton' }); + }, +}; + +const buttonScaleItems = [ + { name: 'FillButton', Component: FillButton }, + { name: 'IconButton', Component: IconButton }, + { name: 'StrokeButton', Component: StrokeButton }, + { name: 'TextButton', Component: TextButton }, +] as const; + +const sizes = ['normal', 'small', 'large'] as const; + +const ButtonScale = ({ mode }: { mode: 'dark' | 'light' }) => { + const grid: React.ReactNode[] = [ + , + ...buttonScaleItems.map(({ name }) => ( + + {name} + + )), + ]; + + buttonVariants.forEach((variant) => { + sizes.forEach((size) => { + grid.push( + + {variant} · {size} + + ); + + buttonScaleItems.forEach(({ name, Component }) => { + const key = `${name}-${mode}-${variant}-${size}`; + + if (name === 'IconButton') { + grid.push( + + ); + return; + } + + grid.push( + + {mode} + + ); + }); + }); + }); + + return ( + + {grid} + + ); +}; + +export const ButtonsLightMode: Story = { + render: () => , +}; + +export const ButtonsDarkMode: Story = { + render: () => , +}; diff --git a/packages/styleguide/src/lib/Atoms/Buttons/CTAButton/CTAButton.stories.tsx b/packages/gamut/src/Button/CTAButton.stories.tsx similarity index 50% rename from packages/styleguide/src/lib/Atoms/Buttons/CTAButton/CTAButton.stories.tsx rename to packages/gamut/src/Button/CTAButton.stories.tsx index ead0551e4a..7829f28610 100644 --- a/packages/styleguide/src/lib/Atoms/Buttons/CTAButton/CTAButton.stories.tsx +++ b/packages/gamut/src/Button/CTAButton.stories.tsx @@ -1,7 +1,10 @@ -import { CTAButton } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; + +import { CTAButton } from './CTAButton'; const meta: Meta = { + title: 'Actions/Buttons/CTAButton', component: CTAButton, args: { children: 'Click Me', @@ -21,8 +24,22 @@ const meta: Meta = { }; export default meta; + type Story = StoryObj; export const Default: Story = { args: {}, + play: ({ canvas }) => { + canvas.getByRole('button', { name: 'Click Me' }); + }, +}; + +export const AsAnchor: Story = { + args: { + href: 'https://www.codecademy.com', + }, + play: async ({ canvas }) => { + const link = canvas.getByRole('link', { name: 'Click Me' }); + await expect(link).toHaveAttribute('href', 'https://www.codecademy.com'); + }, }; diff --git a/packages/styleguide/src/lib/Atoms/Buttons/FillButton/FillButton.stories.tsx b/packages/gamut/src/Button/FillButton.stories.tsx similarity index 57% rename from packages/styleguide/src/lib/Atoms/Buttons/FillButton/FillButton.stories.tsx rename to packages/gamut/src/Button/FillButton.stories.tsx index e951db8d48..92ae93ce32 100644 --- a/packages/styleguide/src/lib/Atoms/Buttons/FillButton/FillButton.stories.tsx +++ b/packages/gamut/src/Button/FillButton.stories.tsx @@ -1,22 +1,38 @@ -import { FillButton } from '@skillsoft/gamut'; import * as icons from '@skillsoft/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; +import { FillButton } from './FillButton'; +import { buttonVariants } from './shared'; + const meta: Meta = { + title: 'Actions/Buttons/FillButton', component: FillButton, args: { children: 'Click Me', disabled: false, size: 'normal', + variant: 'primary', }, argTypes: { href: { description: 'If defined, component will use an anchor tag', type: 'string', }, + variant: { + control: 'radio', + options: buttonVariants, + table: { + type: { summary: buttonVariants.join(' | ') }, + defaultValue: { summary: 'primary' }, + }, + }, size: { control: 'radio', options: ['normal', 'small', 'large'], + table: { + type: { summary: 'small | normal | large' }, + defaultValue: { summary: 'normal' }, + }, }, icon: { control: 'select', @@ -32,8 +48,12 @@ const meta: Meta = { }; export default meta; + type Story = StoryObj; export const Default: Story = { args: {}, + play: ({ canvas }) => { + canvas.getByRole('button', { name: 'Click Me' }); + }, }; diff --git a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.stories.tsx b/packages/gamut/src/Button/IconButton.stories.tsx similarity index 51% rename from packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.stories.tsx rename to packages/gamut/src/Button/IconButton.stories.tsx index 48b8a9de60..e81dbdd6df 100644 --- a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.stories.tsx +++ b/packages/gamut/src/Button/IconButton.stories.tsx @@ -1,10 +1,15 @@ -import { FlexBox, IconButton } from '@skillsoft/gamut'; -import { SparkleIcon } from '@skillsoft/gamut-icons'; import * as icons from '@skillsoft/gamut-icons'; +import { SparkleIcon } from '@skillsoft/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, screen, waitFor } from 'storybook/test'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; +import { FlexBox } from '../Box'; +import { IconButton } from './IconButton'; +import { buttonVariants } from './shared'; + const meta: TypeWithDeepControls> = { + title: 'Actions/Buttons/IconButton', component: IconButton, args: { disabled: false, @@ -12,15 +17,29 @@ const meta: TypeWithDeepControls> = { icon: icons.SearchIcon, tip: 'ToolTip', tipProps: { placement: 'floating', alignment: 'top-center' }, + variant: 'secondary', + onClick: fn(), }, argTypes: { href: { description: 'If defined, component will use an anchor tag', type: 'string', }, + variant: { + control: 'radio', + options: buttonVariants, + table: { + type: { summary: buttonVariants.join(' | ') }, + defaultValue: { summary: 'secondary' }, + }, + }, size: { control: 'radio', options: ['normal', 'small', 'large'], + table: { + type: { summary: 'small | normal | large' }, + defaultValue: { summary: 'normal' }, + }, }, icon: { control: 'select', @@ -44,29 +63,55 @@ const meta: TypeWithDeepControls> = { }; export default meta; + type Story = StoryObj; export const Default: Story = { args: {}, + play: async ({ args, canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'ToolTip' }); + canvas.getByRole('img', { hidden: true }); + await expect( + canvas.getByRole('tooltip', { hidden: true }) + ).toHaveTextContent('ToolTip'); + + await expect(screen.queryAllByText('ToolTip')).toHaveLength(1); + await userEvent.hover(button); + await waitFor(() => + expect(screen.queryAllByText('ToolTip')).toHaveLength(2) + ); + + await userEvent.click(button); + await expect(args.onClick).toHaveBeenCalled(); + }, }; -// eslint-disable-next-line no-console -const logClick = () => console.log('button onClick fired'); +export const AriaLabel: Story = { + args: { + 'aria-label': 'Search the catalog', + }, + play: async ({ canvas }) => { + canvas.getByRole('button', { name: 'Search the catalog' }); + await expect( + canvas.getByRole('tooltip', { hidden: true }) + ).toHaveTextContent('ToolTip'); + }, +}; export const CloseOnClick: Story = { - render: () => ( + render: (args) => ( ), + play: async ({ args, canvas, userEvent }) => { + const tips = [ + 'Closes on click (floating)', + 'Closes on click (inline)', + 'Stays open on click (floating)', + 'Stays open on click (inline)', + ]; + + for (const tip of tips) { + await userEvent.click(canvas.getByRole('button', { name: tip })); + } + + await expect(args.onClick).toHaveBeenCalledTimes(tips.length); + }, }; diff --git a/packages/styleguide/src/lib/Atoms/Buttons/StrokeButton/StrokeButton.stories.tsx b/packages/gamut/src/Button/StrokeButton.stories.tsx similarity index 57% rename from packages/styleguide/src/lib/Atoms/Buttons/StrokeButton/StrokeButton.stories.tsx rename to packages/gamut/src/Button/StrokeButton.stories.tsx index 8f87862c9c..6991009c59 100644 --- a/packages/styleguide/src/lib/Atoms/Buttons/StrokeButton/StrokeButton.stories.tsx +++ b/packages/gamut/src/Button/StrokeButton.stories.tsx @@ -1,22 +1,38 @@ -import { StrokeButton } from '@skillsoft/gamut'; import * as icons from '@skillsoft/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; +import { buttonVariants } from './shared'; +import { StrokeButton } from './StrokeButton'; + const meta: Meta = { + title: 'Actions/Buttons/StrokeButton', component: StrokeButton, args: { children: 'Click Me', disabled: false, size: 'normal', + variant: 'primary', }, argTypes: { href: { description: 'If defined, component will use an anchor tag', type: 'string', }, + variant: { + control: 'radio', + options: buttonVariants, + table: { + type: { summary: buttonVariants.join(' | ') }, + defaultValue: { summary: 'primary' }, + }, + }, size: { control: 'radio', options: ['normal', 'small', 'large'], + table: { + type: { summary: 'small | normal | large' }, + defaultValue: { summary: 'normal' }, + }, }, icon: { control: 'select', @@ -32,8 +48,12 @@ const meta: Meta = { }; export default meta; + type Story = StoryObj; export const Default: Story = { args: {}, + play: ({ canvas }) => { + canvas.getByRole('button', { name: 'Click Me' }); + }, }; diff --git a/packages/gamut/src/Button/TextButton.stories.tsx b/packages/gamut/src/Button/TextButton.stories.tsx new file mode 100644 index 0000000000..a269c74527 --- /dev/null +++ b/packages/gamut/src/Button/TextButton.stories.tsx @@ -0,0 +1,74 @@ +import * as icons from '@skillsoft/gamut-icons'; +import { StarIcon } from '@skillsoft/gamut-icons'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn } from 'storybook/test'; + +import { buttonVariants } from './shared'; +import { TextButton } from './TextButton'; + +const meta: Meta = { + title: 'Actions/Buttons/TextButton', + component: TextButton, + args: { + children: 'Click Me', + disabled: false, + size: 'normal', + variant: 'primary', + onClick: fn(), + }, + argTypes: { + href: { + description: 'If defined, component will use an anchor tag', + type: 'string', + }, + variant: { + control: 'radio', + options: buttonVariants, + table: { + type: { summary: buttonVariants.join(' | ') }, + defaultValue: { summary: 'primary' }, + }, + }, + size: { + control: 'radio', + options: ['normal', 'small', 'large'], + table: { + type: { summary: 'small | normal | large' }, + defaultValue: { summary: 'normal' }, + }, + }, + icon: { + control: 'select', + options: Object.keys(icons), + mapping: icons, + }, + as: { + table: { + disable: true, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, + play: async ({ args, canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Click Me' })); + await expect(args.onClick).toHaveBeenCalled(); + }, +}; + +export const WithIcon: Story = { + args: { + children: 'Leading icon', + icon: StarIcon, + }, + play: ({ canvas }) => { + canvas.getByRole('button', { name: 'Leading icon' }); + canvas.getByRole('img', { hidden: true }); + }, +}; diff --git a/packages/gamut/src/Button/__tests__/IconButton.test.tsx b/packages/gamut/src/Button/__tests__/IconButton.test.tsx deleted file mode 100644 index 950547ce79..0000000000 --- a/packages/gamut/src/Button/__tests__/IconButton.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { StarIcon } from '@skillsoft/gamut-icons'; -import { waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { setupRtl } from 'component-test-setup'; - -import { IconButton } from '../IconButton'; -import { IconButtonFloatingMock } from './mocks'; - -const label = 'Aria gonna click this button?'; -const tip = 'Click this button'; - -const onClick = jest.fn(); -const buttonProps = { - 'aria-label': label, - icon: StarIcon, - onClick, - tip, -}; - -const renderView = setupRtl(IconButton, buttonProps); - -const renderFloatingView = setupRtl(IconButtonFloatingMock, buttonProps); - -describe('IconButton', () => { - it('renders a clickable button', async () => { - const { view } = renderView(); - - const cta = view.getByRole('button', { name: label }); - - await userEvent.click(cta); - - expect(onClick).toHaveBeenCalled(); - }); - it('renders a decorative icon', () => { - const { view } = renderView(); - - view.getByRole('img', { hidden: true }); - }); - - it('renders the tip text from the tip prop', async () => { - const { view } = renderView({}); - - view.getByRole('button', { name: label }); - - expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent(tip); - }); - - it('sets the aria-label as the tip when only the tip prop is provided', async () => { - const { view } = renderView({ 'aria-label': '' }); - - view.getByRole('button', { name: tip }); - expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent(tip); - }); - - it('sets the aria-label text from the aria-label prop when both the tip and aria-label is provided', async () => { - const { view } = renderView({ 'aria-label': '' }); - - view.getByRole('button', { name: tip }); - expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent(tip); - }); - - it('renders a floating tip', async () => { - const { view } = renderFloatingView({}); - - expect(view.queryAllByText(tip).length).toBe(1); - - const cta = view.getByRole('button', { name: label }); - - await userEvent.hover(cta); - - await waitFor(() => expect(view.queryAllByText(tip).length).toBe(2)); - }); - - describe('closeOnClick', () => { - beforeEach(() => { - onClick.mockClear(); - }); - - it('does not prevent button onClick when the inline tip click handler fires', async () => { - const { view } = renderView(); - - await userEvent.click(view.getByRole('button', { name: label })); - - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it('does not prevent button onClick when the floating tip click handler fires', async () => { - const { view } = renderFloatingView(); - - await userEvent.click(view.getByRole('button', { name: label })); - - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it('does not prevent button onClick when closeOnClick is disabled', async () => { - const { view } = renderView({ tipProps: { closeOnClick: false } }); - - await userEvent.click(view.getByRole('button', { name: label })); - - expect(onClick).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/gamut/src/Button/__tests__/TextButton.test.tsx b/packages/gamut/src/Button/__tests__/TextButton.test.tsx deleted file mode 100644 index 7b3ddf7289..0000000000 --- a/packages/gamut/src/Button/__tests__/TextButton.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { StarIcon } from '@skillsoft/gamut-icons'; -import userEvent from '@testing-library/user-event'; -import { setupRtl } from 'component-test-setup'; - -import { TextButton } from '../TextButton'; - -const onClick = jest.fn(); - -const renderView = setupRtl(TextButton, { - children: 'Click me!', - onClick, -}); - -describe('TextButton', () => { - it('renders a clickable button', async () => { - const { view } = renderView(); - - const cta = view.getByRole('button', { name: 'Click me!' }); - - await userEvent.click(cta); - - expect(onClick).toHaveBeenCalled(); - }); - it('renders an leading decorative icon when an icon is provided', () => { - const { view } = renderView({ icon: StarIcon }); - - view.getByRole('img', { hidden: true }); - }); -}); diff --git a/packages/gamut/src/Button/__tests__/mocks.tsx b/packages/gamut/src/Button/__tests__/mocks.tsx deleted file mode 100644 index 849ad5bcd4..0000000000 --- a/packages/gamut/src/Button/__tests__/mocks.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ColorMode } from '@skillsoft/gamut-styles'; -import { MockGamutProvider } from '@skillsoft/gamut-tests'; -import React, { ComponentProps } from 'react'; - -import { Box } from '../../Box'; -import { IconButton } from '..'; - -export const IconButtonFloatingMock: React.FC< - ComponentProps -> = (props) => { - return ( - - - - - - - - ); -}; diff --git a/packages/styleguide/src/lib/Atoms/Buttons/About.mdx b/packages/styleguide/src/lib/Atoms/Buttons/About.mdx deleted file mode 100644 index 3c93605e4e..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/About.mdx +++ /dev/null @@ -1,35 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; - -import { - AboutHeader, - addParentPath, - TableOfContents, -} from '~styleguide/blocks'; - -import { parameters as buttonParameters } from './Button/Button.mdx'; -import { parameters as ctaButtonParameters } from './CTAButton/CTAButton.mdx'; -import { parameters as fillButtonParameters } from './FillButton/FillButton.mdx'; -import { parameters as iconButtonParameters } from './IconButton/IconButton.mdx'; -import { parameters as strokeButtonParameters } from './StrokeButton/StrokeButton.mdx'; -import { parameters as textButtonParameters } from './TextButton/TextButton.mdx'; - -export const parameters = { - id: 'Atoms/Buttons', - title: 'Buttons', - subtitle: 'Buttons that provide an element for interaction on a page.', -}; - - - - - - diff --git a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx b/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx deleted file mode 100644 index b8fadb6924..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx +++ /dev/null @@ -1,109 +0,0 @@ -import { Canvas, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, ImageWrapper, LinkTo } from '~styleguide/blocks'; - -import * as ButtonStories from './Button.stories'; - -export const parameters = { - title: 'Button', - subtitle: `Button atoms for specfic use cases.`, - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1106%3A0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Button/index.ts', - }, -}; - - - - - -## Usage - -Use a button to trigger an action. We have several versions of buttons that you can use. -Each supports _light_ and _dark_ mode. - -See the [Figma designs](https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1106%3A0) for a nice grid view of all the variants. -All but CTA button support a size of either `normal` (default) or `small`. - -### Best practices: - -- A tooltip is required to serve as a label for icon-only buttons -- Include a leading or trailing icon to reinforce the meaning of the action or to further distinguish the button from other bold text -- Span the entire width of the parent container when the viewport or container is `<768px` wide. This approach ensures optimal usability and accessibility, especially on mobile devices and narrow layouts. - -### When NOT to use: - -- **Navigation** - for actions that are purely navigational (e.g., linking to another page or section), use an Anchor link instead. - -## Anatomy - - - -1. **Leading icon** _(optional)_ - -- Use to reinforce the meaning of the action - -2. **Label** - -- Start with an actionable verb -- Use sentence case -- Aim for 3 words maximum - -3. **Trailing icon** _(optional)_ - -- Use to reinforce forward movement or navigation to a new page - -4. **Tooltip** - -- For labeled buttons, tooltip information should be supplementary to the button label, which will have the primary information -- For icon-only buttons, start with an actionable verb to specify what the button does -- Can include a CTA, shortcut, or quick tip -- Triggered on hover and focus - -## Variants - -The examples below use `FillButton` to showcase the `primary` and `secondary` button variants. - -### Primary - - - -### Secondary - - - -## Inline icons - -Our FillButton , StrokeButton , and TextButton all support a single inline icon. You can align the icon to the left or right of the button text using the `iconPosition` prop. These icons should be from the mini set to be legible at a smaller size. - - - -## Buttons in light and dark mode - -Below are examples of how buttons could look in their respective light and dark modes. - -### Light mode - - - -### Dark mode - - - -## UX writing checklist for button labels - -- Does the copy make it clear where the button will take learners? -- Does the button lead with an actionable verb? -- Is the microcopy 3 words or less (especially for the primary button)? -- Have you used sentence case, only capitalizing [proper nouns in the **Codecademy glossary**](https://skillsoftdev.atlassian.net/wiki/spaces/UXW/database/4447175693?savedViewId=9b4c57a9-0cfb-42b0-97c5-08a87e8a2c84)? - -For more tips and best practices, check out the full guide about writing for buttons. diff --git a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx b/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx deleted file mode 100644 index b9589f5ba9..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { - Box, - FillButton, - FlexBox, - GridBox, - IconButton, - StrokeButton, - TextButton, -} from '@skillsoft/gamut'; -import { - MiniArrowLeftIcon, - MiniArrowRightIcon, - MiniDeleteIcon, - MiniRibbonIcon, - SearchIcon, -} from '@skillsoft/gamut-icons'; -import type { Meta, StoryObj } from '@storybook/react'; - -// Using FillButton here to show the difference between primary and secondary variants -// however, this could be any Button -const meta: Meta = { - component: FillButton, - args: {}, -}; - -export default meta; -type Story = StoryObj; - -export const Primary: Story = { - args: { - variant: 'primary', - children: 'Submit', - }, -}; - -export const Secondary: Story = { - args: { - variant: 'secondary', - children: 'Cancel', - }, -}; - -export const InlineIcons: Story = { - render: () => ( - - - FillButton - - - Leading icon - - - TextButton - - - ), -}; - -const buttons = [FillButton, IconButton, StrokeButton, TextButton] as const; -const variants = ['primary', 'secondary'] as const; -const sizes = ['normal', 'small', 'large'] as const; - -const ButtonScale = ({ mode }: { mode: 'dark' | 'light' }) => { - const grid = buttons.map(({ displayName }) => ( - {displayName} - )); - variants.forEach((variant: (typeof variants)[number]) => { - sizes.forEach((size) => { - buttons.forEach((Component) => { - const props = { - key: `${Component.displayName}-${mode}-${variant}-${size}`, - mode, - variant, - size, - icon: MiniDeleteIcon, - tip: 'n/a', - }; - if (Component.displayName === 'IconButton') { - return grid.push( - - ); - } - grid.push({mode}); - }); - }); - }); - - return ( - - {grid} - - ); -}; - -export const ButtonsLightMode: Story = { - render: () => , -}; - -export const ButtonsDarkMode: Story = { - render: () => , -}; diff --git a/packages/styleguide/src/lib/Atoms/Buttons/CTAButton/CTAButton.mdx b/packages/styleguide/src/lib/Atoms/Buttons/CTAButton/CTAButton.mdx deleted file mode 100644 index 03fafd7d28..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/CTAButton/CTAButton.mdx +++ /dev/null @@ -1,36 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { Callout, ComponentHeader } from '~styleguide/blocks'; - -import * as CTAButtonStories from './CTAButton.stories'; - -export const parameters = { - title: 'CTAButton', - subtitle: `A "Call to Action" button that is used to prompt users to take a specific action.`, - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1106%3A0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Button/CTAButton.tsx', - }, -}; - - - - - - - -## Usage - -Use this for high-visibility marketing actions. Include a leading or trailing icons from our mini icon set to clarify an action. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Buttons/FillButton/FillButton.mdx b/packages/styleguide/src/lib/Atoms/Buttons/FillButton/FillButton.mdx deleted file mode 100644 index 433dde0956..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/FillButton/FillButton.mdx +++ /dev/null @@ -1,36 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { Callout, ComponentHeader } from '~styleguide/blocks'; - -import * as FillButtonStories from './FillButton.stories'; - -export const parameters = { - title: 'FillButton', - subtitle: `A button that has a solid background color and is used for primary actions.`, - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1106%3A0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Button/FillButton.tsx', - }, -}; - - - - - - - -## Usage - -Use this for primary actions. Include a leading or trailing icons from our mini icon set to clarify an action. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx b/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx deleted file mode 100644 index 5f53eac199..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx +++ /dev/null @@ -1,44 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { Callout, ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as IconButtonStories from './IconButton.stories'; - -export const parameters = { - title: 'IconButton', - subtitle: `A button that displays a clickable icon.`, - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1106%3A0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Button/IconButton.tsx', - }, -}; - - - - - - - -## Usage - -Use for secondary or space-constrained actions with recognizable icons. Use regular icons for normal buttons and mini icons for small buttons. Icon-only buttons must include a `tip` prop, which will also appear in the tooltip on hover and focus. - -For more detailed information on `IconButton` tooltips, take a look at the ToolTip story. - -## Close on Click - -By default, `IconButton` tooltips close immediately on click and reappear only after the cursor leaves and re-enters. To persist the tooltip after a click, pass `tipProps={{ closeOnClick: false }}`. The first two buttons below close on click; the last two stay open. - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Buttons/StrokeButton/StrokeButton.mdx b/packages/styleguide/src/lib/Atoms/Buttons/StrokeButton/StrokeButton.mdx deleted file mode 100644 index 4407ae961e..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/StrokeButton/StrokeButton.mdx +++ /dev/null @@ -1,36 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { Callout, ComponentHeader } from '~styleguide/blocks'; - -import * as StrokeButtonStories from './StrokeButton.stories'; - -export const parameters = { - title: 'StrokeButton', - subtitle: `A button that has a outline around its text and is used for secondary actions.`, - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1106%3A0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Button/StrokeButton.tsx', - }, -}; - - - - - - - -## Usage - -Use this for secondary actions. Include a leading or trailing icons from our mini icon set to clarify an action. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.mdx b/packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.mdx deleted file mode 100644 index d944de7c55..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.mdx +++ /dev/null @@ -1,42 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { Callout, ComponentHeader } from '~styleguide/blocks'; - -import * as TextButtonStories from './TextButton.stories'; - -export const parameters = { - title: 'TextButton', - subtitle: `A button that usually only has text and is used for tertiary actions.`, - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1106%3A0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Button/TextButton.tsx', - }, -}; - - - - - - - -## Usage - -Use this for tertiary actions. Include a leading or trailing icons from our mini icon set to clarify an action or to distinguish the button from other bold text. - -### Best practices: - -Our text buttons use bold formatting to differentiate themselves from normal text, as color alone does not meet the 3:1 contrast ratio required for accessibility. However, bold formatting may not be sufficient in all contexts, such as when placed alongside a bold heading. - -- In these cases, we recommend including a leading or trailing icon to further distinguish the button from other bold text. This additional visual cue ensures that the text button is easily identifiable and accessible to all users. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.stories.tsx b/packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.stories.tsx deleted file mode 100644 index 486eebd75d..0000000000 --- a/packages/styleguide/src/lib/Atoms/Buttons/TextButton/TextButton.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { TextButton } from '@skillsoft/gamut'; -import * as icons from '@skillsoft/gamut-icons'; -import type { Meta, StoryObj } from '@storybook/react'; - -const meta: Meta = { - component: TextButton, - args: { - children: 'Click Me', - disabled: false, - size: 'normal', - }, - argTypes: { - href: { - description: 'If defined, component will use an anchor tag', - type: 'string', - }, - size: { - control: 'radio', - options: ['normal', 'small', 'large'], - }, - icon: { - control: 'select', - options: Object.keys(icons), - mapping: icons, - }, - as: { - table: { - disable: true, - }, - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: {}, -}; From 3f70b0882b940b3a844b6904fc453e31b64bc939 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 15 Oct 2026 11:37:39 -0400 Subject: [PATCH 03/13] Migrate Status components into gamut Storybook Co-locate Badge, ProgressBar, RadialProgress, Shimmer, Spinner, and FeatureShimmer as stories under the Status category, with behavior/a11y assertions in play functions. Keep node tests only where a browser play can't reach: ProgressBar's useLogicalProperties width matrix and FeatureShimmer's reduced-motion hook mock (both co-located). Remove the old styleguide folders (Badge, ProgressBar, RadialProgress, FeatureShimmer, Loaders) and the Button-style __tests__ dirs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src}/Badge/Badge.stories.tsx | 26 +++- .../gamut/src/Badge/__tests__/Badge.test.tsx | 14 -- .../FeatureShimmer/FeatureShimmer.stories.tsx | 12 +- .../{__tests__ => }/FeatureShimmer.test.tsx | 13 +- .../src/Loading}/Shimmer.stories.tsx | 4 +- .../src/Loading}/Spinner.stories.tsx | 7 +- .../src}/ProgressBar/ProgressBar.stories.tsx | 28 +++- .../{__tests__ => }/ProgressBar.test.tsx | 40 +---- .../RadialProgress/RadialProgress.stories.tsx | 23 ++- .../styleguide/src/lib/Atoms/Badge/Badge.mdx | 141 ------------------ .../src/lib/Atoms/Badge/examples.tsx | 23 --- .../Atoms/FeatureShimmer/FeatureShimmer.mdx | 49 ------ .../src/lib/Atoms/Loaders/About.mdx | 24 --- .../src/lib/Atoms/Loaders/Shimmer/Shimmer.mdx | 35 ----- .../src/lib/Atoms/Loaders/Spinner/Spinner.mdx | 34 ----- .../src/lib/Atoms/ProgressBar/ProgressBar.mdx | 102 ------------- .../Atoms/RadialProgress/RadialProgress.mdx | 45 ------ 17 files changed, 93 insertions(+), 527 deletions(-) rename packages/{styleguide/src/lib/Atoms => gamut/src}/Badge/Badge.stories.tsx (73%) delete mode 100644 packages/gamut/src/Badge/__tests__/Badge.test.tsx rename packages/{styleguide/src/lib/Atoms => gamut/src}/FeatureShimmer/FeatureShimmer.stories.tsx (52%) rename packages/gamut/src/FeatureShimmer/{__tests__ => }/FeatureShimmer.test.tsx (80%) rename packages/{styleguide/src/lib/Atoms/Loaders/Shimmer => gamut/src/Loading}/Shimmer.stories.tsx (80%) rename packages/{styleguide/src/lib/Atoms/Loaders/Spinner => gamut/src/Loading}/Spinner.stories.tsx (66%) rename packages/{styleguide/src/lib/Atoms => gamut/src}/ProgressBar/ProgressBar.stories.tsx (70%) rename packages/gamut/src/ProgressBar/{__tests__ => }/ProgressBar.test.tsx (53%) rename packages/{styleguide/src/lib/Atoms => gamut/src}/RadialProgress/RadialProgress.stories.tsx (53%) delete mode 100644 packages/styleguide/src/lib/Atoms/Badge/Badge.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Badge/examples.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/FeatureShimmer/FeatureShimmer.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Loaders/About.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Loaders/Shimmer/Shimmer.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Loaders/Spinner/Spinner.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/ProgressBar/ProgressBar.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/RadialProgress/RadialProgress.mdx diff --git a/packages/styleguide/src/lib/Atoms/Badge/Badge.stories.tsx b/packages/gamut/src/Badge/Badge.stories.tsx similarity index 73% rename from packages/styleguide/src/lib/Atoms/Badge/Badge.stories.tsx rename to packages/gamut/src/Badge/Badge.stories.tsx index e35f6ec040..daf69ed7a0 100644 --- a/packages/styleguide/src/lib/Atoms/Badge/Badge.stories.tsx +++ b/packages/gamut/src/Badge/Badge.stories.tsx @@ -1,10 +1,14 @@ -import { Badge, FlexBox } from '@skillsoft/gamut'; +import styled from '@emotion/styled'; import * as icons from '@skillsoft/gamut-icons'; +import { MiniStarIcon } from '@skillsoft/gamut-icons'; +import { css } from '@skillsoft/gamut-styles'; import type { Meta, StoryObj } from '@storybook/react'; -import { TertiaryFillExample } from './examples'; +import { Box, FlexBox } from '../Box'; +import { Badge } from '.'; const meta: Meta = { + title: 'Status/Badge', component: Badge, args: { children: 'Badge' }, argTypes: { @@ -21,6 +25,9 @@ type Story = StoryObj; export const Default: Story = { args: {}, + play: ({ canvas }) => { + canvas.getByText('Badge'); + }, }; export const Secondary: Story = { @@ -35,10 +42,19 @@ export const Tertiary: Story = { }, }; +const AbsoluteBadge = styled(Badge)( + css({ position: 'absolute', top: 12, left: 12 }) +); + export const TertiaryFill: Story = { - render: () => { - return ; - }, + render: () => ( + + + Badge + + + + ), }; export const Accent: Story = { diff --git a/packages/gamut/src/Badge/__tests__/Badge.test.tsx b/packages/gamut/src/Badge/__tests__/Badge.test.tsx deleted file mode 100644 index 507daecb96..0000000000 --- a/packages/gamut/src/Badge/__tests__/Badge.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { Badge } from '..'; - -const badgeText = 'I am a badge'; -const renderView = setupRtl(Badge, { children: badgeText }); - -describe('Badge', () => { - it('renders badge text', () => { - const { view } = renderView(); - - view.getByText(badgeText); - }); -}); diff --git a/packages/styleguide/src/lib/Atoms/FeatureShimmer/FeatureShimmer.stories.tsx b/packages/gamut/src/FeatureShimmer/FeatureShimmer.stories.tsx similarity index 52% rename from packages/styleguide/src/lib/Atoms/FeatureShimmer/FeatureShimmer.stories.tsx rename to packages/gamut/src/FeatureShimmer/FeatureShimmer.stories.tsx index dd3f135f5a..661ef7c674 100644 --- a/packages/styleguide/src/lib/Atoms/FeatureShimmer/FeatureShimmer.stories.tsx +++ b/packages/gamut/src/FeatureShimmer/FeatureShimmer.stories.tsx @@ -1,7 +1,10 @@ -import { FeatureShimmer, Text } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { Text } from '../Typography'; +import { FeatureShimmer } from '.'; + const meta: Meta = { + title: 'Status/FeatureShimmer', component: FeatureShimmer, args: { children: This is a test, @@ -12,4 +15,9 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + play: ({ canvas }) => { + canvas.getByText('This is a test'); + canvas.getByTestId('feature-shimmer'); + }, +}; diff --git a/packages/gamut/src/FeatureShimmer/__tests__/FeatureShimmer.test.tsx b/packages/gamut/src/FeatureShimmer/FeatureShimmer.test.tsx similarity index 80% rename from packages/gamut/src/FeatureShimmer/__tests__/FeatureShimmer.test.tsx rename to packages/gamut/src/FeatureShimmer/FeatureShimmer.test.tsx index b3540a9b67..acc49d8f59 100644 --- a/packages/gamut/src/FeatureShimmer/__tests__/FeatureShimmer.test.tsx +++ b/packages/gamut/src/FeatureShimmer/FeatureShimmer.test.tsx @@ -1,6 +1,6 @@ import { setupRtl } from '@skillsoft/gamut-tests'; -import { FeatureShimmer } from '..'; +import { FeatureShimmer } from '.'; const mockIntersectionObserver = jest.fn(); const mockUseReducedMotion = jest.fn(); @@ -19,10 +19,10 @@ describe('FeatureShimmer', () => { observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), - unobserve: jest.fn(), }); window.IntersectionObserver = mockIntersectionObserver; }); + it('renders shimmer when reduced motion is false', () => { mockUseReducedMotion.mockReturnValue(false); @@ -40,13 +40,4 @@ describe('FeatureShimmer', () => { view.getByText('Testing'); expect(view.queryByTestId('feature-shimmer')).toBeNull(); }); - - it('renders shimmer', () => { - mockUseReducedMotion.mockReturnValue(false); - - const { view } = renderView(); - - view.getByText('Testing'); - view.getByTestId('feature-shimmer'); - }); }); diff --git a/packages/styleguide/src/lib/Atoms/Loaders/Shimmer/Shimmer.stories.tsx b/packages/gamut/src/Loading/Shimmer.stories.tsx similarity index 80% rename from packages/styleguide/src/lib/Atoms/Loaders/Shimmer/Shimmer.stories.tsx rename to packages/gamut/src/Loading/Shimmer.stories.tsx index b0856aa51b..41dcf51300 100644 --- a/packages/styleguide/src/lib/Atoms/Loaders/Shimmer/Shimmer.stories.tsx +++ b/packages/gamut/src/Loading/Shimmer.stories.tsx @@ -1,7 +1,9 @@ -import { Shimmer } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { Shimmer } from './Shimmer'; + const meta: Meta = { + title: 'Status/Shimmer', component: Shimmer, args: {}, }; diff --git a/packages/styleguide/src/lib/Atoms/Loaders/Spinner/Spinner.stories.tsx b/packages/gamut/src/Loading/Spinner.stories.tsx similarity index 66% rename from packages/styleguide/src/lib/Atoms/Loaders/Spinner/Spinner.stories.tsx rename to packages/gamut/src/Loading/Spinner.stories.tsx index 00fbe3d22d..07738f416c 100644 --- a/packages/styleguide/src/lib/Atoms/Loaders/Spinner/Spinner.stories.tsx +++ b/packages/gamut/src/Loading/Spinner.stories.tsx @@ -1,7 +1,9 @@ -import { Spinner } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { Spinner } from './Spinner'; + const meta: Meta = { + title: 'Status/Spinner', component: Spinner, args: {}, }; @@ -13,4 +15,7 @@ export const Default: Story = { args: { size: 60, }, + play: ({ canvas }) => { + canvas.getByTitle('Spinner'); + }, }; diff --git a/packages/styleguide/src/lib/Atoms/ProgressBar/ProgressBar.stories.tsx b/packages/gamut/src/ProgressBar/ProgressBar.stories.tsx similarity index 70% rename from packages/styleguide/src/lib/Atoms/ProgressBar/ProgressBar.stories.tsx rename to packages/gamut/src/ProgressBar/ProgressBar.stories.tsx index dbc6015500..ad7a169f17 100644 --- a/packages/styleguide/src/lib/Atoms/ProgressBar/ProgressBar.stories.tsx +++ b/packages/gamut/src/ProgressBar/ProgressBar.stories.tsx @@ -1,8 +1,11 @@ -import { ProgressBar } from '@skillsoft/gamut'; import * as patterns from '@skillsoft/gamut-patterns'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; + +import { ProgressBar } from '.'; const meta: Meta = { + title: 'Status/ProgressBar', component: ProgressBar, args: { percent: 50, @@ -13,6 +16,13 @@ const meta: Meta = { options: Object.keys(patterns), mapping: patterns, }, + variant: { + control: 'radio', + options: ['default', 'blue', 'yellow'], + table: { + defaultValue: { summary: 'default' }, + }, + }, }, }; @@ -23,6 +33,11 @@ export const Default: Story = { args: { variant: 'default', }, + play: async ({ canvas }) => { + canvas.getByRole('figure'); + canvas.getByText('Progress: 50%'); + await expect(canvas.queryByRole('img', { hidden: true })).toBeNull(); + }, }; export const Blue: Story = { @@ -58,6 +73,13 @@ export const Large: Story = { percent: 75, variant: 'blue', }, + play: async ({ canvas }) => { + canvas.getByText('Progress: 75%'); + await expect(canvas.getByText('75%')).toHaveAttribute( + 'aria-hidden', + 'true' + ); + }, }; export const XL: Story = { @@ -91,6 +113,10 @@ export const PatternBackgroundStripesDense: Story = { pattern: patterns.DiagonalADense, percent: 33, }, + play: ({ canvas }) => { + canvas.getByRole('img', { hidden: true }); + canvas.getByTitle('Diagonal A Dense'); + }, }; export const FlatTop: Story = { diff --git a/packages/gamut/src/ProgressBar/__tests__/ProgressBar.test.tsx b/packages/gamut/src/ProgressBar/ProgressBar.test.tsx similarity index 53% rename from packages/gamut/src/ProgressBar/__tests__/ProgressBar.test.tsx rename to packages/gamut/src/ProgressBar/ProgressBar.test.tsx index 95a58ec683..f3ceacc384 100644 --- a/packages/gamut/src/ProgressBar/__tests__/ProgressBar.test.tsx +++ b/packages/gamut/src/ProgressBar/ProgressBar.test.tsx @@ -1,13 +1,7 @@ -import { DiagonalADense } from '@skillsoft/gamut-patterns'; -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; +import { MockGamutProvider } from '@skillsoft/gamut-tests'; import { render, screen } from '@testing-library/react'; -import { ProgressBar, ProgressBarProps } from '..'; - -const renderView = setupRtl(ProgressBar, { - percent: 50, - variant: 'blue', -}); +import { ProgressBar, ProgressBarProps } from '.'; describe('ProgressBar', () => { describe.each([ @@ -45,34 +39,4 @@ describe('ProgressBar', () => { }); } ); - - it('does not include percentage visually when size is small', () => { - const { view } = renderView({ size: 'small' }); - /** RTL doesn't differentiate between visible and invisible - test so this tests that only the screen reader text renders */ - - view.getByText('Progress: 50%'); - }); - - it('includes percentage visually when size is large', () => { - const { view } = renderView({ size: 'large' }); - /** RTL doesn't differentiate between visible and invisible - test so this tests that only the screen reader text renders */ - - view.getByText('Progress: 50%'); - expect(view.getByText('50%')).toHaveAttribute('aria-hidden', 'true'); - }); - - it('uses an svg when given a pattern', () => { - const { view } = renderView({ pattern: DiagonalADense }); - - view.getByRole('img', { hidden: true }); - view.getByTitle('Diagonal A Dense'); - }); - - it('does not use an svg when not given a pattern', () => { - const { view } = renderView(); - - expect(view.queryByRole('img', { hidden: true })).toBeNull(); - }); }); diff --git a/packages/styleguide/src/lib/Atoms/RadialProgress/RadialProgress.stories.tsx b/packages/gamut/src/RadialProgress/RadialProgress.stories.tsx similarity index 53% rename from packages/styleguide/src/lib/Atoms/RadialProgress/RadialProgress.stories.tsx rename to packages/gamut/src/RadialProgress/RadialProgress.stories.tsx index 436560513b..569733fb0a 100644 --- a/packages/styleguide/src/lib/Atoms/RadialProgress/RadialProgress.stories.tsx +++ b/packages/gamut/src/RadialProgress/RadialProgress.stories.tsx @@ -1,7 +1,9 @@ -import { RadialProgress } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { RadialProgress } from '.'; + const meta: Meta = { + title: 'Status/RadialProgress', component: RadialProgress, args: { strokeLinecap: 'round', @@ -10,6 +12,15 @@ const meta: Meta = { size: 120, children: '', }, + argTypes: { + strokeLinecap: { + control: 'radio', + options: ['round', 'butt', 'square'], + table: { + defaultValue: { summary: 'round' }, + }, + }, + }, }; export default meta; @@ -17,6 +28,9 @@ type Story = StoryObj; export const Default: Story = { args: {}, + play: ({ canvas }) => { + canvas.getByText('30% progress'); + }, }; export const Children: Story = { @@ -25,6 +39,10 @@ export const Children: Story = { size: 60, value: 75, }, + play: ({ canvas }) => { + canvas.getByText('75%'); + canvas.getByText('75% progress'); + }, }; export const Animating: Story = { @@ -32,4 +50,7 @@ export const Animating: Story = { value: [0, 100], duration: 5000, }, + play: ({ canvas }) => { + canvas.getByText('100% progress'); + }, }; diff --git a/packages/styleguide/src/lib/Atoms/Badge/Badge.mdx b/packages/styleguide/src/lib/Atoms/Badge/Badge.mdx deleted file mode 100644 index a016adf071..0000000000 --- a/packages/styleguide/src/lib/Atoms/Badge/Badge.mdx +++ /dev/null @@ -1,141 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { - Callout, - ComponentHeader, - ImageWrapper, - LinkTo, -} from '~styleguide/blocks'; - -import * as BadgeStories from './Badge.stories'; - -export const parameters = { - title: 'Badge', - subtitle: - 'Badges are generally used as a standard way to highlight a short piece of text, likely a single word.', - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?type=design&node-id=29959-39721&mode=design&t=27y0ZBTRsAgBmZ9z-0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Badge', - }, -}; - - - - - -## Usage - -Use `Badge`s to display read-only information like statuses, attributes, and other empathsized information. - -### Best practices: - -- Use badges sparingly, as too many can dilute their effectiveness. -- Custom colors should be used sparingly, consistently across similar contexts, and with sufficient contrast for accessibility. - -### When NOT to use: - -- **Interactivity**- when text is meant to be clickable, use the Button or Anchor components instead. -- **Categorization**- for labeling or catgorizing content, use the Tag component instead. - -## Anatomy - - - -1. **Leading icon** _(optional)_ - -- Use to reinforce the badge's message and improve scannability - -2. **Badge label** - -- Limit 1-2 words -- Display read-only information like statuses, attributes, and other emphasized information - -## Variants - -### Primary - -Use the primary variant to highlight important information or statuses that need to be noticed. - - - -### Secondary - -Use the secondary variant to display supporting information or less critical statuses. - - - -### Tertiary - -Use the tertiary variant to indicate background or supplementary information with minimal emphasis. - - - -### Tertiary-fill - -Use the tertiary-fill variant when a tertiary `Badge` needs to sit above other content. This variant includes a bg-color that can overlap color modes and unpredictable content. - - - -### Accent - -Use the accent variant to draw attention to important details or statuses with a noticeable but less dominant emphasis than the primary `Badge`. - - - The accent variant does not respond to{' '} - ColorMode. - - } -/> - - - -### Custom - -Use the custom variant to create a `Badge` with a unique color scheme that fits your design needs. This variant allows for more flexibility in styling, but keep in mind best practices for color usage and accessibility. - - - -## Sizes - -We have two `size` options, which should be used according to their context. - -### Default size - -Use `primary` when the `Badge` is a standalone entitity. - - - -### Small size - -Use `sm` when the `Badge` is inline with other components such as Text. - - - -## Leading icon - -Include the `icon` property when the icon reinforces the badge's message and improves scannability. Since badges are inherently small, it's recommended to use Gamut's mini icons. Below are examples of using icons with the different `Badge` sizes. - -### Default size with icon - - - -### Small size with icon - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Badge/examples.tsx b/packages/styleguide/src/lib/Atoms/Badge/examples.tsx deleted file mode 100644 index 3c2c13f269..0000000000 --- a/packages/styleguide/src/lib/Atoms/Badge/examples.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import styled from '@emotion/styled'; -import { Badge, BadgeProps, Box } from '@skillsoft/gamut'; -import { MiniStarIcon } from '@skillsoft/gamut-icons'; -import { css } from '@skillsoft/gamut-styles'; - -export const BadgeTemplate: React.FC = (args) => ( - {args.children ? args.children : args.variant} -); - -const AbsoluteBadge = styled(Badge)( - css({ position: 'absolute', top: 12, left: 12 }) -); - -export const TertiaryFillExample = () => { - return ( - - - Badge - - - - ); -}; diff --git a/packages/styleguide/src/lib/Atoms/FeatureShimmer/FeatureShimmer.mdx b/packages/styleguide/src/lib/Atoms/FeatureShimmer/FeatureShimmer.mdx deleted file mode 100644 index 3315167150..0000000000 --- a/packages/styleguide/src/lib/Atoms/FeatureShimmer/FeatureShimmer.mdx +++ /dev/null @@ -1,49 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as FeatureShimmerStories from './FeatureShimmer.stories'; - -export const parameters = { - subtitle: - 'Shimmer effect for boxes — generic styles that apply to our Skeleton elements.', - design: { - type: 'figma', - url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=61818-3459&m=dev', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/FeatureShimmer', - }, -}; - - - - - -## Usage - -Use `FeatureShimmer` to subtly draw attention to secondary new features or elements that are are important for the user to notice, though not the primary focus of the page. - -### Best practices: - -- Only apply `FeatureShimmer` to a single element at a time to avoid overwhelming the user. -- To prevent unnecessary distraction, ensure that the shimmer effect is not shown indefinitely. Set a reasonable limit on how many times a user sees it in total (e.g., up to 3 times across separate sessions or page loads), or stop showing it once the user interacts with the element. - -### When NOT to use: - -- Primary Features — For main features or critical updates, use more prominent indicators like the Coachmark or Badge components. -- Critical Alerts — For errors or urgent notifications that require immediate user attention, use the Alert component. - -## Accessibility considerations - -- **Respects Reduced Motion Preferences:** The shimmer effect is automatically disabled if the user has reduced motion enabled. This ensures an accessible experience for users who prefer to minimize animations. -- **Triggers Only When in View:** The shimmer effect activates only when the component enters the viewport. This prevents unnecessary animations and improves performance, especially for screen readers and users navigating via keyboard. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Loaders/About.mdx b/packages/styleguide/src/lib/Atoms/Loaders/About.mdx deleted file mode 100644 index 2557e09429..0000000000 --- a/packages/styleguide/src/lib/Atoms/Loaders/About.mdx +++ /dev/null @@ -1,24 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; - -import { - AboutHeader, - addParentPath, - TableOfContents, -} from '~styleguide/blocks'; - -import { parameters as shimmerParameters } from './Shimmer/Shimmer.mdx'; -import { parameters as spinnerParameters } from './Spinner/Spinner.mdx'; - -export const parameters = { - id: 'Atoms/Loaders', - title: 'Loaders', - subtitle: 'Animations that indicate information is currently loading.', -}; - - - - - - diff --git a/packages/styleguide/src/lib/Atoms/Loaders/Shimmer/Shimmer.mdx b/packages/styleguide/src/lib/Atoms/Loaders/Shimmer/Shimmer.mdx deleted file mode 100644 index b2376be034..0000000000 --- a/packages/styleguide/src/lib/Atoms/Loaders/Shimmer/Shimmer.mdx +++ /dev/null @@ -1,35 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as ShimmerStories from './Shimmer.stories'; - -export const parameters = { - title: 'Shimmer', - subtitle: - 'Shimmer effect for boxes — generic styles that apply to our Skeleton elements.', - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=29975%3A39412&t=nzcwAhSF1DoNC1YO-0', - }, - status: 'updating', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Loading/Shimmer', - }, -}; - - - - - -## Usage - -Use Shimmer to indicate a loading state for a component. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Loaders/Spinner/Spinner.mdx b/packages/styleguide/src/lib/Atoms/Loaders/Spinner/Spinner.mdx deleted file mode 100644 index c1b5f9e8cb..0000000000 --- a/packages/styleguide/src/lib/Atoms/Loaders/Spinner/Spinner.mdx +++ /dev/null @@ -1,34 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as SpinnerStories from './Spinner.stories'; - -export const parameters = { - title: 'Spinner', - subtitle: 'Perpetually spinning circle, such as for a loading indicator.', - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=29975%3A39412&t=nzcwAhSF1DoNC1YO-0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Loading/Spinner', - }, -}; - - - - - -## Usage - -Spinners work best when used inside of components that are already loaded. They are small, compact, and imply elements of feedback. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/ProgressBar/ProgressBar.mdx b/packages/styleguide/src/lib/Atoms/ProgressBar/ProgressBar.mdx deleted file mode 100644 index 9cfc774900..0000000000 --- a/packages/styleguide/src/lib/Atoms/ProgressBar/ProgressBar.mdx +++ /dev/null @@ -1,102 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as ProgressBarStories from './ProgressBar.stories'; - -export const parameters = { - title: 'ProgressBar', - subtitle: 'A horizontal bar for displaying progress.', - design: { - type: 'figma', - url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=18448-19639&node-type=frame&t=zRqgxQkQhUaLzOe7-0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/ProgressBar', - }, -}; - - - - - -## Usage - -Use ProgressBar to indicate a known or somewhat-known amount of progress along a fixed course. For example, you -might show one on a quiz page indicating how many questions have been -completed. - -## Variants - -### Default - -The default variant has a transparent background and bordered look with colors based on the current color mode. - - - -### Blue - - - -### Yellow - - - -## Sizes - -Bars with the `large` and `xl` sizes are thicker and will display a -percentage label. - -### Small - - - -### Medium - - - -### Large - - - -### XL - - - -## Minimum percent - -Some bars (generally small ones) should display at least a little bit of -progress, even if the technical progress number is zero. You can use the -`minimumPercent` prop for a minimum visual width percentage. - - - -## Pattern background - -Use components from `@skillsoft/gamut-patterns` for a pattern background. -Here we are using the `DiagonalARegular` pattern in navy in the large ProgressBar and the `DiagonalADense` pattern in the smaller ProgressBar. - - - - - -## Flat - -The flat prop allows the progress bar to be on top or bottom of a Card. - -### Flat Top - - - -### Flat Bottom - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/RadialProgress/RadialProgress.mdx b/packages/styleguide/src/lib/Atoms/RadialProgress/RadialProgress.mdx deleted file mode 100644 index 6b3ee21ed7..0000000000 --- a/packages/styleguide/src/lib/Atoms/RadialProgress/RadialProgress.mdx +++ /dev/null @@ -1,45 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as RadialProgressStories from './RadialProgress.stories'; - -export const parameters = { - title: 'RadialProgress', - subtitle: - 'A circular display of progress meant to convey a percentage out of a whole.', - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/RadialProgress', - }, -}; - - - - - -## Usage - -Use RadialProgress to display progress as a percentage of a whole. - -## Children - -Optionally, children may be provided to be displayed in the center of the circle. -This is useful for conveying a textual description of progress, such as a percentage of X/Y ratio. - - - -## Animating - -Since `value` can be a `number[]`, given an initial value, end value, and duration, these will animate -smoothly upon being rendered. - - - -## Playground - - - - From fdd0a112dec37ead66c87f78deba0c46b81875db Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 16 Oct 2026 10:02:13 -0400 Subject: [PATCH 04/13] Migrate SkipToContent into gamut Storybook Co-locate the story under Navigation and move the href check into a play function. Remove the Jest test and the old styleguide story. Co-Authored-By: Grok 4.7 Co-authored-by: Cursor --- .../SkipToContent/SkipToContent.stories.tsx | 53 +++++++++++++++++++ .../__tests__/SkipToContent.test.tsx | 14 ----- .../lib/Atoms/SkipToContent/SkipToContent.mdx | 51 ------------------ .../SkipToContent/SkipToContent.stories.tsx | 14 ----- 4 files changed, 53 insertions(+), 79 deletions(-) create mode 100644 packages/gamut/src/SkipToContent/SkipToContent.stories.tsx delete mode 100644 packages/gamut/src/SkipToContent/__tests__/SkipToContent.test.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.stories.tsx diff --git a/packages/gamut/src/SkipToContent/SkipToContent.stories.tsx b/packages/gamut/src/SkipToContent/SkipToContent.stories.tsx new file mode 100644 index 0000000000..4f7e49abb1 --- /dev/null +++ b/packages/gamut/src/SkipToContent/SkipToContent.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; + +import { Anchor } from '../Anchor'; +import { FillButton } from '../Button'; +import { SkipToContent, SkipToContentTarget } from '.'; + +const meta: Meta = { + title: 'Navigation/SkipToContent', + component: SkipToContent, + args: { + contentId: 'main-content', + }, + argTypes: { + className: { + table: { + disable: true, + }, + }, + }, + render: (args) => ( + <> + + + Link 1 + + + Link 2 + + + Link 3 + + + I should have focus after skip to content + + ), +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ args, canvas }) => { + const link = canvas.getByRole('link', { name: 'Skip to Content' }); + await expect(link).toHaveAttribute('href', `#${args.contentId}`); + + await expect(canvas.getByTestId('skip-to-content-target')).toHaveAttribute( + 'tabindex', + '-1' + ); + }, +}; diff --git a/packages/gamut/src/SkipToContent/__tests__/SkipToContent.test.tsx b/packages/gamut/src/SkipToContent/__tests__/SkipToContent.test.tsx deleted file mode 100644 index 0d1c99fb36..0000000000 --- a/packages/gamut/src/SkipToContent/__tests__/SkipToContent.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { SkipToContent } from '..'; - -const renderView = setupRtl(SkipToContent, { contentId: 'main-area' }); - -describe('SkipToContent', () => { - it('has a link to the corresponding content ID', () => { - const contentId = '#main-area'; - const { view } = renderView(); - - expect(view.getByRole('link')).toHaveAttribute('href', contentId); - }); -}); diff --git a/packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.mdx b/packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.mdx deleted file mode 100644 index c742be9b30..0000000000 --- a/packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.mdx +++ /dev/null @@ -1,51 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, KeyboardKey } from '~styleguide/blocks'; - -import * as SkipToContentStories from './SkipToContent.stories'; - -export const parameters = { - title: 'SkipToContent', - subtitle: `An invisible component to make pages more keyboard accessible.`, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/SkipToContent', - }, -}; - - - - - -## Usage - -Use `` to add a hidden-by-default link that becomes visible when the user Tabs over it (gives it focus). -It takes in the ID of the element it will scroll to and focus ([dev.to article explaining why](https://dev.to/robdodson/managing-focus-64l)) on click (via mouse or keyboard). - -This type of control is necessary for pages that have a bunch of stuff keyboard users would have to Tab through to get to the page's main content (like navigations). - -For this element to work properly, there must be a `` component with the id passed into this component for the `contentId` prop. The `` component by default has a `tabIndex` of -1. -Example: - -```jsx -
-
- - Link 1 - Link 2 - Link 3 -
- - I should have focus after skip to content -
-``` - -To see it, keep pressing Tab around on this page or visit [the catalog](https://www.codecademy.com/catalog/language/javascript) and navigate via Tab to see it in action. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.stories.tsx b/packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.stories.tsx deleted file mode 100644 index 118b92b0d9..0000000000 --- a/packages/styleguide/src/lib/Atoms/SkipToContent/SkipToContent.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { SkipToContent } from '@skillsoft/gamut'; -import type { Meta, StoryObj } from '@storybook/react'; - -const meta: Meta = { - component: SkipToContent, - args: {}, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { contentId: 'main-content' }, -}; From 28b608075d656f4e57a7d04367c3d95799f9f21b Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 16 Oct 2026 14:09:37 -0400 Subject: [PATCH 05/13] Migrate FocusTrap and DelayedRenderWrapper into gamut Storybook Co-locate both under Utilities, fold their Jest coverage into play functions, and remove the old styleguide stories and node tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DelayedRenderWrapper.stories.tsx | 77 +++++++++++++++ .../__tests__/DelayedRenderWrapper.test.tsx | 36 ------- .../gamut/src/FocusTrap/FocusTrap.stories.tsx | 96 +++++++++++++++++++ .../FocusTrap/__tests__/FocusTrap.test.tsx | 71 -------------- .../src/lib/Atoms/UtilityComponents/About.mdx | 29 ------ .../DelayedRenderWrapper.mdx | 33 ------- .../DelayedRenderWrapper.stories.tsx | 52 ---------- .../UtilityComponents/FocusTrap/FocusTrap.mdx | 31 ------ .../FocusTrap/FocusTrap.stories.tsx | 61 ------------ 9 files changed, 173 insertions(+), 313 deletions(-) create mode 100644 packages/gamut/src/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx delete mode 100644 packages/gamut/src/DelayedRenderWrapper/__tests__/DelayedRenderWrapper.test.tsx create mode 100644 packages/gamut/src/FocusTrap/FocusTrap.stories.tsx delete mode 100644 packages/gamut/src/FocusTrap/__tests__/FocusTrap.test.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/UtilityComponents/About.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.stories.tsx diff --git a/packages/gamut/src/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx b/packages/gamut/src/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx new file mode 100644 index 0000000000..34b742fab6 --- /dev/null +++ b/packages/gamut/src/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { expect, waitFor } from 'storybook/test'; + +import { Box, FlexBox } from '../Box'; +import { FillButton } from '../Button'; +import { Text } from '../Typography'; +import { DelayedRenderWrapper } from '.'; + +const meta: Meta = { + title: 'Utilities/DelayedRenderWrapper', + component: DelayedRenderWrapper, + args: { + delay: 1000, + }, + argTypes: { + delay: { + control: { type: 'number', min: 0, step: 500 }, + description: 'Delay in milliseconds before rendering children', + table: { + type: { summary: 'number' }, + defaultValue: { summary: '1000' }, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: function DelayedRenderDemo({ delay }) { + const [showChildren, setShowChildren] = useState(false); + + return ( + + + setShowChildren(!showChildren)}> + Render! + + + {showChildren && ( + + + This text will be delayed by {delay} milliseconds before + rendering. + + + )} + + ); + }, +}; + +export const Immediate: Story = { + args: { + delay: 0, + children: 'Child Content', + }, + play: ({ canvas }) => { + canvas.getByText('Child Content'); + }, +}; + +export const Delayed: Story = { + args: { + delay: 300, + children: 'Child Content', + }, + play: async ({ canvas }) => { + await expect(canvas.queryByText('Child Content')).toBeNull(); + await waitFor(() => { + canvas.getByText('Child Content'); + }); + }, +}; diff --git a/packages/gamut/src/DelayedRenderWrapper/__tests__/DelayedRenderWrapper.test.tsx b/packages/gamut/src/DelayedRenderWrapper/__tests__/DelayedRenderWrapper.test.tsx deleted file mode 100644 index 1e36a64650..0000000000 --- a/packages/gamut/src/DelayedRenderWrapper/__tests__/DelayedRenderWrapper.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { act } from '@testing-library/react'; - -import { DelayedRenderWrapper } from '../DelayedRenderWrapper'; - -const renderView = setupRtl(DelayedRenderWrapper, { - children:
Child Content
, -}); - -describe('DelayedRenderWrapper', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - it('should not render children immediately', () => { - const { view } = renderView({ delay: 1000 }); - - expect(view.queryByTestId('wrapper-child')).toBeNull(); - }); - - it('should render children immediately if delay is 0', () => { - const { view } = renderView({ delay: 0 }); - - expect(view.queryByTestId('wrapper-child')).toBeTruthy(); - }); - - it('should render children after the delay', () => { - const { view } = renderView({ delay: 1000 }); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(view.queryByTestId('wrapper-child')).toBeTruthy(); - }); -}); diff --git a/packages/gamut/src/FocusTrap/FocusTrap.stories.tsx b/packages/gamut/src/FocusTrap/FocusTrap.stories.tsx new file mode 100644 index 0000000000..0914f510e8 --- /dev/null +++ b/packages/gamut/src/FocusTrap/FocusTrap.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect, useState } from 'react'; +import { expect, fireEvent, fn, waitFor } from 'storybook/test'; + +import { Box } from '../Box'; +import { FillButton } from '../Button'; +import { GridForm } from '../GridForm'; +import { FocusTrap } from '.'; + +const meta: Meta = { + title: 'Utilities/FocusTrap', + component: FocusTrap, + args: { + active: false, + onClickOutside: fn(), + onEscapeKey: fn(), + }, + render: function FocusTrapDemo(args) { + const [trapActive, setActive] = useState(args.active); + useEffect(() => { + setActive(args.active); + }, [args.active]); + + return ( + + setActive(true)}> + Enable Focus Trap + + { + setActive(false); + args.onClickOutside?.(); + }} + > + + { + setActive(false); + }} + /> + + + + ); + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ args, canvas, userEvent }) => { + const enable = canvas.getByRole('button', { name: 'Enable Focus Trap' }); + const emailInput = canvas.getByRole('textbox'); + canvas.getByRole('button', { name: 'Disable Focus Trap' }); + + await userEvent.click(enable); + await waitFor(() => expect(emailInput).toHaveFocus()); + + await userEvent.keyboard('{Escape}'); + await expect(args.onEscapeKey).toHaveBeenCalledTimes(1); + + await userEvent.click(emailInput); + await expect(args.onClickOutside).not.toHaveBeenCalled(); + + await fireEvent.mouseDown(enable); + await waitFor(() => expect(args.onClickOutside).toHaveBeenCalledTimes(1)); + }, +}; diff --git a/packages/gamut/src/FocusTrap/__tests__/FocusTrap.test.tsx b/packages/gamut/src/FocusTrap/__tests__/FocusTrap.test.tsx deleted file mode 100644 index 07044fcb9a..0000000000 --- a/packages/gamut/src/FocusTrap/__tests__/FocusTrap.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent, screen } from '@testing-library/react'; -import * as React from 'react'; - -import { FocusTrap, FocusTrapProps } from '..'; - -const FocusTrapContainer: React.FC> = (props) => { - return ( -
-
- - - ); -}; - -const renderFocusTrap = setupRtl(FocusTrapContainer); - -const focusTrapIsRendered = () => { - return Boolean(screen.queryByTestId('focus-trap-content')); -}; - -describe('FocusTrap', () => { - it('renders children', () => { - renderFocusTrap(); - expect(focusTrapIsRendered()).toBeTruthy(); - }); - - it('auto focuses on children', () => { - renderFocusTrap(); - const expectedFocusedButton = screen.queryByTestId('button-inside'); - expect(document.activeElement).toEqual(expectedFocusedButton); - }); - - it('triggers onEscapeKey callback when escape key is triggered', () => { - const onEscapeKey = jest.fn(); - const { view } = renderFocusTrap({ - onEscapeKey, - }); - fireEvent.keyDown(view.baseElement, { key: 'Escape', code: 'Escape' }); - expect(onEscapeKey.mock.calls.length).toBe(1); - }); - - it('triggers onRequestClose callback when clicking outside the container', () => { - const onClickOutside = jest.fn(); - renderFocusTrap({ - onClickOutside, - }); - - // focus-on listens to mouseDown, not click - fireEvent.mouseDown(screen.getByTestId('focus-trap-outside')); - expect(onClickOutside.mock.calls.length).toBe(1); - }); - - it('does not trigger onClickOutside callback when clicking inside', () => { - const onClickOutside = jest.fn(); - renderFocusTrap({ - onClickOutside, - }); - fireEvent.mouseDown(screen.getByTestId('focus-trap-content')); - expect(onClickOutside.mock.calls.length).toBe(0); - }); -}); diff --git a/packages/styleguide/src/lib/Atoms/UtilityComponents/About.mdx b/packages/styleguide/src/lib/Atoms/UtilityComponents/About.mdx deleted file mode 100644 index 25f019b95f..0000000000 --- a/packages/styleguide/src/lib/Atoms/UtilityComponents/About.mdx +++ /dev/null @@ -1,29 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; - -import { - AboutHeader, - addParentPath, - TableOfContents, -} from '~styleguide/blocks'; - -import { parameters as delayedRenderWrapperParameters } from './DelayedRenderWrapper/DelayedRenderWrapper.mdx'; -import { parameters as focusTrapParameters } from './FocusTrap/FocusTrap.mdx'; - -export const parameters = { - id: 'Atoms/UtilityComponents', - title: 'UtilityComponents', - subtitle: - 'A collection of utility components that provide functionality to other components.', - status: 'current', -}; - - - - - - diff --git a/packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.mdx b/packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.mdx deleted file mode 100644 index b24c2d3e30..0000000000 --- a/packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.mdx +++ /dev/null @@ -1,33 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as DelayedRenderWrapperStories from './DelayedRenderWrapper.stories'; - -export const parameters = { - title: 'DelayedRenderWrapper', - subtitle: - 'A utility component that delays rendering of its children until after a specified delay.', - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DelayedRenderWrapper/DelayedRenderWrapper.tsx', - }, -}; - - - - - -## Usage - -Use `DelayedRenderWrapper` to wrap around a component that you want to delay rendering for a specified amount of time. - -The `delay` prop accepts a number in milliseconds. If a `delay` value greater than 0 is provided, `DelayedRenderWrapper` will use a `setTimeout()` to determine when to render its children. If `delay` is 0 or not provided, it will render its children immediately. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx b/packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx deleted file mode 100644 index 6f2990040b..0000000000 --- a/packages/styleguide/src/lib/Atoms/UtilityComponents/DelayedRenderWrapper/DelayedRenderWrapper.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - Box, - DelayedRenderWrapper, - FillButton, - FlexBox, - Text, -} from '@skillsoft/gamut'; -import type { Meta } from '@storybook/react'; -import { ComponentProps, useState } from 'react'; - -const meta: Meta = { - component: DelayedRenderWrapper, - args: { delay: 1000 }, - argTypes: { - delay: { - control: { - type: 'number', - min: 0, - step: 500, - }, - description: 'Delay in milliseconds before rendering children', - defaultValue: 1000, - }, - }, -}; - -export default meta; - -export const Default: React.FC> = ({ - delay, -}) => { - const [showChildren, setShowChildren] = useState(false); - return ( - <> - - - setShowChildren(!showChildren)}> - Render! - - - {showChildren && ( - - - This text will be delayed by {delay} milliseconds before - rendering. - - - )} - - - ); -}; diff --git a/packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.mdx b/packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.mdx deleted file mode 100644 index 4f5c5c2110..0000000000 --- a/packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.mdx +++ /dev/null @@ -1,31 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as FocusTrapStories from './FocusTrap.stories'; - -export const parameters = { - title: 'FocusTrap', - subtitle: - "Used for modal and dialog experiences that use `Overlay`. This component controls focus management behavior when we need to limit focus to a single area of the page for some amount of time. It doesn't need to be a modal or overlay, and can instead be controlled with the `active` prop.", - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/FocusTrap', - }, -}; - - - - - -## Usage - -FocusTrap is a wrapper around the [react-focus-on](https://github.com/theKashey/react-focus-on) library, so any necessary functionality that provides should be exposed through this component. If necessary, you can pass props directly to `react-focus-on` through the `focusOnProps` prop. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.stories.tsx b/packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.stories.tsx deleted file mode 100644 index 0d7fccf331..0000000000 --- a/packages/styleguide/src/lib/Atoms/UtilityComponents/FocusTrap/FocusTrap.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Box, FillButton, FocusTrap, GridForm } from '@skillsoft/gamut'; -import type { Meta } from '@storybook/react'; -import { ComponentProps, useEffect, useState } from 'react'; - -const meta: Meta = { - component: FocusTrap, - args: { - active: false, - }, -}; - -export default meta; - -export const Default: React.FC> = (args) => { - const [trapActive, setActive] = useState(args.active); - useEffect(() => { - setActive(args.active); - }, [args.active]); - return ( - <> - - setActive(true)}> - Enable Focus Trap - - - - { - setActive(false); - }} - /> - - - - - ); -}; From 77f4f1a7fc3ce5bafabd7060dc594ba4da99df4d Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 16 Oct 2026 14:45:59 -0400 Subject: [PATCH 06/13] Migrate Card and Drawer into gamut Storybook Co-locate both under Containers. Fold the Card render tests and the Drawer expand/collapse tests into play functions, keep only the node-only useCardElevation logic, and add variant/shadow/borderRadius argTypes to Card. Remove the old styleguide stories. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Atoms => gamut/src}/Card/Card.stories.tsx | 49 +++++++++- .../gamut/src/Card/__tests__/Card.test.tsx | 28 +----- packages/gamut/src/Drawer/Drawer.stories.tsx | 69 +++++++++++++ .../src/Drawer/__tests__/Drawer.test.tsx | 31 ------ .../styleguide/src/lib/Atoms/Card/Card.mdx | 97 ------------------- .../src/lib/Atoms/Drawer/Drawer.mdx | 64 ------------ .../src/lib/Atoms/Drawer/Drawer.stories.tsx | 37 ------- 7 files changed, 118 insertions(+), 257 deletions(-) rename packages/{styleguide/src/lib/Atoms => gamut/src}/Card/Card.stories.tsx (86%) create mode 100644 packages/gamut/src/Drawer/Drawer.stories.tsx delete mode 100644 packages/gamut/src/Drawer/__tests__/Drawer.test.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/Card/Card.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Drawer/Drawer.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Drawer/Drawer.stories.tsx diff --git a/packages/styleguide/src/lib/Atoms/Card/Card.stories.tsx b/packages/gamut/src/Card/Card.stories.tsx similarity index 86% rename from packages/styleguide/src/lib/Atoms/Card/Card.stories.tsx rename to packages/gamut/src/Card/Card.stories.tsx index 6adeefd532..3f421c6326 100644 --- a/packages/styleguide/src/lib/Atoms/Card/Card.stories.tsx +++ b/packages/gamut/src/Card/Card.stories.tsx @@ -1,12 +1,47 @@ -import { Anchor, Box, Card, FlexBox } from '@skillsoft/gamut'; import { Background } from '@skillsoft/gamut-styles'; import type { Meta, StoryObj } from '@storybook/react'; +import { Anchor } from '../Anchor'; +import { Box, FlexBox } from '../Box'; +import { Card } from '.'; + const meta: Meta = { + title: 'Containers/Card', component: Card, args: { children: 'Hello world I am a Card', }, + argTypes: { + variant: { + control: 'select', + options: ['default', 'white', 'yellow', 'beige', 'navy', 'hyper'], + table: { + type: { + summary: + "'default' | 'white' | 'yellow' | 'beige' | 'navy' | 'hyper'", + }, + defaultValue: { summary: 'default' }, + }, + }, + shadow: { + control: 'radio', + options: ['none', 'patternLeft', 'patternRight', 'outline'], + table: { + type: { + summary: "'none' | 'patternLeft' | 'patternRight' | 'outline'", + }, + defaultValue: { summary: 'none' }, + }, + }, + borderRadius: { + control: 'select', + options: ['none', 'sm', 'md', 'lg', 'xl', 'full'], + table: { + type: { summary: "'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'" }, + defaultValue: { summary: "isInteractive ? 'md' : 'none'" }, + }, + }, + }, }; export default meta; @@ -19,6 +54,9 @@ export const Default: Story = { isInteractive: true, borderRadius: 'md', }, + play: ({ canvas }) => { + canvas.getByText('Hello world I am a Card'); + }, }; export const StaticLight: Story = { @@ -37,6 +75,11 @@ export const StaticLight: Story = { ), + play: ({ canvas }) => { + canvas.getByText('White variant'); + canvas.getByText('Yellow variant'); + canvas.getByText('Beige variant'); + }, }; export const StaticDark: Story = { @@ -281,6 +324,10 @@ export const IsInteractive: Story = { ), + play: ({ canvas }) => { + canvas.getByText('Default, patternRight, and isInteractive'); + canvas.getByText('Beige, outline, and isInteractive'); + }, }; export const Dynamic: Story = { diff --git a/packages/gamut/src/Card/__tests__/Card.test.tsx b/packages/gamut/src/Card/__tests__/Card.test.tsx index 1c0e89628e..23b65cdc41 100644 --- a/packages/gamut/src/Card/__tests__/Card.test.tsx +++ b/packages/gamut/src/Card/__tests__/Card.test.tsx @@ -1,40 +1,14 @@ import { theme } from '@skillsoft/gamut-styles'; -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; +import { MockGamutProvider } from '@skillsoft/gamut-tests'; import { renderHook } from '@testing-library/react'; -import { Card } from '..'; import { useCardElevation } from '../styles'; -const renderView = setupRtl(Card, { children: 'Card content' }); - const renderElevation = (...args: Parameters) => renderHook(() => useCardElevation(...args), { wrapper: MockGamutProvider, }).result.current; -describe('Card', () => { - it('renders its children', () => { - const { view } = renderView(); - - expect(view.getByText('Card content')).toBeInTheDocument(); - }); - - it.each(['patternLeft', 'patternRight', 'outline'] as const)( - 'renders children when interactive with the %s shadow', - (shadow) => { - const { view } = renderView({ isInteractive: true, shadow }); - - expect(view.getByText('Card content')).toBeInTheDocument(); - } - ); - - it('renders children for non-default variants', () => { - const { view } = renderView({ variant: 'white' }); - - expect(view.getByText('Card content')).toBeInTheDocument(); - }); -}); - describe('useCardElevation', () => { it('maps the theme elevation tokens onto the motion variants', () => { const { animate, initial } = renderElevation('none'); diff --git a/packages/gamut/src/Drawer/Drawer.stories.tsx b/packages/gamut/src/Drawer/Drawer.stories.tsx new file mode 100644 index 0000000000..8f6632140b --- /dev/null +++ b/packages/gamut/src/Drawer/Drawer.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect, useState } from 'react'; +import { expect, waitFor } from 'storybook/test'; + +import { FlexBox } from '../Box'; +import { StrokeButton } from '../Button'; +import { Drawer } from '.'; + +const meta: Meta = { + title: 'Containers/Drawer', + component: Drawer, + args: { + expanded: true, + alignContentContainer: 'left', + }, + argTypes: { + alignContentContainer: { + control: { + type: 'radio', + options: ['left', 'right'], + }, + }, + }, + render: function DrawerDemo(args) { + const [expanded, setExpanded] = useState(args.expanded); + useEffect(() => { + setExpanded(args.expanded); + }, [args.expanded]); + + return ( + + + Drawer content in here! + + setExpanded((previousExpanded) => !previousExpanded)} + > + Toggle Drawer + + + ); + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas, userEvent }) => { + canvas.getByText('Drawer content in here!'); + + await userEvent.click( + canvas.getByRole('button', { name: 'Toggle Drawer' }) + ); + await waitFor(() => + expect(canvas.queryByText('Drawer content in here!')).toBeNull() + ); + + await userEvent.click( + canvas.getByRole('button', { name: 'Toggle Drawer' }) + ); + await waitFor(() => canvas.getByText('Drawer content in here!')); + }, +}; diff --git a/packages/gamut/src/Drawer/__tests__/Drawer.test.tsx b/packages/gamut/src/Drawer/__tests__/Drawer.test.tsx deleted file mode 100644 index db1b54f388..0000000000 --- a/packages/gamut/src/Drawer/__tests__/Drawer.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { RenderResult, waitFor } from '@testing-library/react'; - -import { Drawer } from '..'; - -jest.mock('react-use', () => ({ - ...jest.requireActual<{}>('react-use'), - useMedia: () => false, -})); - -const renderView = setupRtl(Drawer, { - children:
Howdy!
, -}); - -describe('Drawer', () => { - const expectDrawerOpen = (view: RenderResult) => - waitFor(() => view.getByTestId('drawer-content')); - - const expectDrawerClosed = (view: RenderResult) => - waitFor(() => expect(view.queryByTestId('drawer-content')).toBeNull()); - - it('renders content when "expanded" is true', async () => { - const { view } = renderView({ expanded: true }); - await expectDrawerOpen(view); - }); - - it('does not render content when "expanded" is false', async () => { - const { view } = renderView({ expanded: false }); - await expectDrawerClosed(view); - }); -}); diff --git a/packages/styleguide/src/lib/Atoms/Card/Card.mdx b/packages/styleguide/src/lib/Atoms/Card/Card.mdx deleted file mode 100644 index f0a7bdb163..0000000000 --- a/packages/styleguide/src/lib/Atoms/Card/Card.mdx +++ /dev/null @@ -1,97 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as CardStories from './Card.stories'; - -export const parameters = { - title: 'Card', - subtitle: - 'A card with multiple background combinations for presenting different types of information.', - design: { - type: 'figma', - url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=29975-39392&node-type=frame&t=SgphNxlCwEFrcv0X-0', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Card', - }, -}; - - - - - -## Usage - -Use `Card` to present different types of information in a visually appealing way. - -## Variants - -### Backgrounds - -#### Default - -When a `Card` is missing a `variant` prop, or it's set to `'default'` it will use the `'background'` color token — this color token is ColorMode responsive. - - - -#### White, Yellow, and Biege - -The `'white'`, `'yellow'`, and `'beige'` variants are used to apply static background colors to the `Card`. These colors are intended to work when the `Card` is used inside a "light mode" background. - - - -### Navy and Hyper - -The `'navy'` and `'hyper'` variants are used to apply static background colors to the `Card`. These colors are intended to work when the `Card` is used inside a "dark mode" background. - - - -### Shadow - -A `Card` can also take on a `shadow` variant that applies an additional styling to the bottom and left/right sides of the card. - -#### None - -By default, or when setting `shadow = "none"`, there is no shadow/pattern applied to the `Card`. - - - -#### Outline - -Setting `shadow = "outline"` will apply a shadow to the bottom and left sides of the `Card` that takes on the color and its border color. - - - -#### PatternLeft - -Setting `shadow = "patternLeft"` will apply a pattern bottom and left sides of the `Card`. By default, the pattern used is `CheckerDense`, but you can pass in a different pattern using the `pattern` prop. - - - -#### PatternRight - -Setting `shadow = "patternRight"` works like `"patternLeft"` but applies the pattern to the bottom and right sides of the `Card`. You may also change the `pattern` used. - - - -### IsInteractive - -A `Card` can be used as a link by wrapping it in an Anchor component. When doing so, add the `isInteractive` prop to style the `Card` to gain a `'md'` border-radius and apply a hover effect that adds a shadow — a `Card` with a pattern will lose their pattern while on hover. - - - -### Border Radius - -By default, a `Card` has a `borderRadius` of `'none'` — which denotes that the `Card` is not clickable. When given the `isInteractive` prop, the `Card` takes on a `borderRadius` of `'md'`. However, the `borderRadius` prop can also be manually overridden to match the required design. The outline or pattern of a `Card` will match the provided `borderRadius`. - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Drawer/Drawer.mdx b/packages/styleguide/src/lib/Atoms/Drawer/Drawer.mdx deleted file mode 100644 index 9b6b57f567..0000000000 --- a/packages/styleguide/src/lib/Atoms/Drawer/Drawer.mdx +++ /dev/null @@ -1,64 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as DrawerStories from './Drawer.stories'; - -export const parameters = { - title: 'Drawer', - subtitle: - 'Animated container that expands horizontally to show its contents.', - status: 'current', - design: { - type: 'figma', - url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=30420-41014&node-type=frame&t=SgphNxlCwEFrcv0X-0', - }, - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Drawer', - }, -}; - - - - - -## Usage - -Drawers animate between having `0` width and having a responsive width: - -- Mobile devices (<`sm`): `75vw` -- Tablet and larger (>=`sm`): `30rem` - -An example of a molecule that uses Drawer is the Flyout. - -Our Drawers are [controlled components](https://reactjs.org/docs/forms.html#controlled-components), so their checked value must be controlled by an external state passed in as `expanded`. - -The component that toggles the `Drawer` should include the `aria-expanded` attribute. The `aria-expanded` attribute should reflect the value of the `expanded` prop passed to the `Drawer`. In the example below, the `StrokeButton` controls the expansion of the `Drawer`, so the button has the `aria-expanded` attribute. - -```tsx -const ExampleDrawer: React.FC = () => { - const [expanded, setExpanded] = useState(false); - - return ( - - - Example Drawer - - setExpanded((previousExpanded) => !previousExpanded)} - > - Set the proper aria-labels! - - - ); -}; -``` - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Drawer/Drawer.stories.tsx b/packages/styleguide/src/lib/Atoms/Drawer/Drawer.stories.tsx deleted file mode 100644 index 752c96adf7..0000000000 --- a/packages/styleguide/src/lib/Atoms/Drawer/Drawer.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Drawer, FlexBox, StrokeButton } from '@skillsoft/gamut'; -import type { Meta } from '@storybook/react'; -import { ComponentProps, useEffect, useState } from 'react'; - -const meta: Meta = { - component: Drawer, - args: { - expanded: true, - alignContentContainer: 'left', - }, -}; - -export default meta; - -export const Default: React.FC> = (args) => { - const [expanded, setExpanded] = useState(args.expanded); - useEffect(() => { - setExpanded(args.expanded); - }, [args.expanded]); - - return ( - - - Drawer content in here! - - setExpanded((previousExpanded) => !previousExpanded)} - > - Toggle Drawer - - - ); -}; From efd3fca0e813bcb8274937b1ed955a851e0f6e50 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 16 Oct 2026 15:58:56 -0400 Subject: [PATCH 07/13] Migrate Toggle and Tag into gamut Storybook Co-locate both under Actions and fold their Jest coverage into play functions (roles, callbacks, disabled states, aria-label), then remove the node tests and old styleguide stories. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Atoms => gamut/src}/Tag/Tag.stories.tsx | 40 +++- packages/gamut/src/Tag/__tests__/Tag.test.tsx | 120 ------------ packages/gamut/src/Toggle/Toggle.stories.tsx | 179 ++++++++++++++++++ .../src/Toggle/__tests__/Toggle.test.tsx | 124 ------------ packages/styleguide/src/lib/Atoms/Tag/Tag.mdx | 108 ----------- .../src/lib/Atoms/Toggle/Toggle.mdx | 88 --------- .../src/lib/Atoms/Toggle/Toggle.stories.tsx | 124 ------------ 7 files changed, 210 insertions(+), 573 deletions(-) rename packages/{styleguide/src/lib/Atoms => gamut/src}/Tag/Tag.stories.tsx (78%) delete mode 100644 packages/gamut/src/Tag/__tests__/Tag.test.tsx create mode 100644 packages/gamut/src/Toggle/Toggle.stories.tsx delete mode 100644 packages/gamut/src/Toggle/__tests__/Toggle.test.tsx delete mode 100644 packages/styleguide/src/lib/Atoms/Tag/Tag.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Toggle/Toggle.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/Toggle/Toggle.stories.tsx diff --git a/packages/styleguide/src/lib/Atoms/Tag/Tag.stories.tsx b/packages/gamut/src/Tag/Tag.stories.tsx similarity index 78% rename from packages/styleguide/src/lib/Atoms/Tag/Tag.stories.tsx rename to packages/gamut/src/Tag/Tag.stories.tsx index bb2b5396d8..e602ffee75 100644 --- a/packages/styleguide/src/lib/Atoms/Tag/Tag.stories.tsx +++ b/packages/gamut/src/Tag/Tag.stories.tsx @@ -1,8 +1,12 @@ -import { Box, FlexBox, Tag } from '@skillsoft/gamut'; import * as icons from '@skillsoft/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn } from 'storybook/test'; + +import { Box, FlexBox } from '../Box'; +import { Tag } from '.'; const meta: Meta = { + title: 'Actions/Tag', component: Tag, args: { children: 'Tag', @@ -19,12 +23,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - children: 'Read-only', - variant: 'readOnly', - }, -}; +export const Default: Story = {}; export const ReadOnly: Story = { args: { @@ -42,12 +41,17 @@ export const ReadOnly: Story = {
), + play: async ({ canvas }) => { + canvas.getByText('Read-only'); + canvas.getByText('Large read-only'); + await expect(canvas.queryByRole('button')).toBeNull(); + }, }; export const Selection: Story = { args: { variant: 'selection', - onDismiss: () => {}, + onDismiss: fn(), }, render: (args) => ( @@ -71,6 +75,12 @@ export const Selection: Story = {
), + play: async ({ args, canvas, userEvent }) => { + const buttons = canvas.getAllByRole('button'); + await userEvent.click(buttons[0]); + await expect(args.onDismiss).toHaveBeenCalledTimes(1); + await expect(buttons[buttons.length - 1]).toBeDisabled(); + }, }; export const Navigation: Story = { @@ -100,12 +110,17 @@ export const Navigation: Story = {
), + play: async ({ canvas }) => { + const link = canvas.getByRole('link', { name: 'Navigation' }); + await expect(link).toHaveAttribute('href', '/'); + await expect(canvas.getAllByRole('button')[0]).toBeDisabled(); + }, }; export const Suggestion: Story = { args: { variant: 'suggestion', - onClick: () => {}, + onClick: fn(), }, render: (args) => ( @@ -129,6 +144,12 @@ export const Suggestion: Story = { ), + play: async ({ args, canvas, userEvent }) => { + const suggestion = canvas.getByRole('button', { name: 'Suggestion' }); + await userEvent.click(suggestion); + await expect(args.onClick).toHaveBeenCalledTimes(1); + await expect(canvas.getAllByRole('button').at(-1)).toBeDisabled(); + }, }; export const WithIcon: Story = { @@ -209,6 +230,7 @@ export const WithIcon: Story = { export const Overflow: Story = { args: { variant: 'selection', + onDismiss: () => null, }, render: (args) => ( diff --git a/packages/gamut/src/Tag/__tests__/Tag.test.tsx b/packages/gamut/src/Tag/__tests__/Tag.test.tsx deleted file mode 100644 index da50e07fb7..0000000000 --- a/packages/gamut/src/Tag/__tests__/Tag.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; - -import { Tag } from '..'; - -const tagText = 'Tag Text'; -const renderView = setupRtl(Tag, { children: <>{tagText} }); - -describe('Tag', () => { - it('renders correct text for the `readOnly` variant', () => { - const { view } = renderView({ variant: 'readOnly' }); - - view.getByText(tagText); - }); - - it('excludes any buttons for the `readOnly` variant', () => { - const { view } = renderView({ variant: 'readOnly' }); - - expect(view.queryByRole('button')).toBeNull(); - }); - - it('renders correct text for the `selection` variant', () => { - const { view } = renderView({ variant: 'selection' }); - - view.getByText(tagText); - }); - - it('includes a button for the `selection` variant', () => { - const { view } = renderView({ variant: 'selection' }); - - view.getByRole('button'); - }); - - it('calls onDismiss when DeleteButton is clicked for the `selection` variant', () => { - const onDismiss = jest.fn(); - const { view } = renderView({ variant: 'selection', onDismiss }); - - const deleteButton = view.getByRole('button'); - - fireEvent.click(deleteButton); - - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it('is in a disabled state when provided a `disabled: true` prop for the `selection` variant', () => { - const { view } = renderView({ variant: 'selection', disabled: true }); - - expect(view.getByRole('button')).toBeDisabled(); - }); - - it('renders correct text for the `navigation` variant', () => { - const { view } = renderView({ variant: 'navigation' }); - - view.getByText(tagText); - }); - - it('renders the text in an anchor element for the `navigation` variant', () => { - const { view } = renderView({ - variant: 'navigation', - href: 'www.tagteam.com', - }); - - view.getByRole('link', { name: tagText }); - }); - - it('contains the correct href for the `navigation` variant', () => { - const { view } = renderView({ - variant: 'navigation', - href: 'www.tagteam.com', - }); - - expect(view.getByRole('link')).toHaveAttribute('href', 'www.tagteam.com'); - }); - - it('is in a disabled state when provided a `disabled: true` prop for the `navigation` variant', () => { - const { view } = renderView({ - variant: 'navigation', - href: 'www.tagteam.com', - disabled: true, - }); - - expect(view.getByRole('button')).toBeDisabled(); - }); - - it('renders correct text for the `suggestion` variant', () => { - const onClick = jest.fn(); - const { view } = renderView({ variant: 'suggestion', onClick }); - - view.getByText(tagText); - }); - - it('renders the text in a button for the `suggestion` variant', () => { - const onClick = jest.fn(); - const { view } = renderView({ variant: 'suggestion', onClick }); - - view.getByRole('button', { name: tagText }); - }); - - it('executes the onClick function passed in as a prop for the `suggestion` variant', () => { - const onClick = jest.fn(); - const { view } = renderView({ variant: 'suggestion', onClick }); - - const suggestionTag = view.getByRole('button'); - - fireEvent.click(suggestionTag); - - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it('is in a disabled state when provided a `disabled: true` prop for the `suggestion` variant', () => { - const onClick = jest.fn(); - const { view } = renderView({ - variant: 'suggestion', - onClick, - disabled: true, - }); - - expect(view.getByRole('button')).toBeDisabled(); - }); -}); diff --git a/packages/gamut/src/Toggle/Toggle.stories.tsx b/packages/gamut/src/Toggle/Toggle.stories.tsx new file mode 100644 index 0000000000..9c863d6edc --- /dev/null +++ b/packages/gamut/src/Toggle/Toggle.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { expect, fn } from 'storybook/test'; + +import { Text } from '../Typography'; +import { Toggle } from '.'; + +const meta: Meta = { + title: 'Actions/Toggle', + component: Toggle, + args: { + label: 'Toggle me', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, + play: ({ canvas }) => { + canvas.getByRole('checkbox', { name: 'Toggle me' }); + }, +}; + +export const Medium: Story = { + args: { + size: 'medium', + label: undefined, + ariaLabel: 'Accessible toggle', + }, + play: ({ canvas }) => { + canvas.getByLabelText('Accessible toggle'); + }, +}; + +export const Small: Story = { + args: { + size: 'small', + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + onChange: fn(), + }, + play: async ({ args, canvas, userEvent }) => { + const toggle = canvas.getByRole('checkbox'); + await expect(toggle).toBeDisabled(); + + await userEvent.click(canvas.getByText('Toggle me')); + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + +export const DisabledChecked: Story = { + args: { + disabled: true, + checked: true, + }, +}; + +export const LabelLeft: Story = { + args: { + label: 'My label is to the left', + labelSide: 'left', + }, +}; + +export const LabelRight: Story = { + args: { + label: 'My label is to the right', + labelSide: 'right', + }, +}; + +export const ReactComponentLabel: Story = { + args: { + checked: false, + label: I am a Text component, + ariaLabel: 'Hello there', + }, + play: ({ canvas }) => { + canvas.getByText('I am a Text component'); + canvas.getByLabelText('Hello there'); + }, +}; + +export const InputElement: Story = { + args: { + label: 'input toggle', + onChange: () => null, + }, + play: ({ canvas }) => { + canvas.getByRole('checkbox', { name: 'input toggle' }); + }, +}; + +export const ButtonElement: Story = { + args: { + as: 'button', + checked: true, + label: 'button toggle', + onChange: undefined, + onClick: fn(), + }, + play: async ({ args, canvas, userEvent }) => { + const toggle = canvas.getByRole('switch', { name: 'button toggle' }); + + await userEvent.click(toggle); + await expect(args.onClick).toHaveBeenCalledTimes(1); + }, +}; + +export const DisabledButton: Story = { + args: { + as: 'button', + checked: false, + disabled: true, + label: 'disabled button toggle', + onChange: undefined, + onClick: fn(), + }, + play: async ({ args, canvas, userEvent }) => { + const toggle = canvas.getByRole('switch'); + await expect(toggle).toBeDisabled(); + + await userEvent.click(canvas.getByText('disabled button toggle')); + await expect(args.onClick).not.toHaveBeenCalled(); + }, +}; + +export const InteractiveInputToggle: Story = { + render: function InteractiveInputToggleStory() { + const [checked, setChecked] = useState(false); + return ( + setChecked((value) => !value)} + /> + ); + }, + play: async ({ canvas, userEvent }) => { + const toggle = canvas.getByRole('checkbox'); + await expect(toggle).not.toBeChecked(); + + await userEvent.click(toggle); + await expect(toggle).toBeChecked(); + + await userEvent.click(toggle); + await expect(toggle).not.toBeChecked(); + }, +}; + +export const InteractiveButtonToggle: Story = { + render: function InteractiveButtonToggleStory() { + const [checked, setChecked] = useState(false); + return ( + setChecked((value) => !value)} + /> + ); + }, + play: async ({ canvas, userEvent }) => { + const toggle = canvas.getByRole('switch'); + await expect(toggle).not.toBeChecked(); + + await userEvent.click(toggle); + await expect(toggle).toBeChecked(); + + await userEvent.click(toggle); + await expect(toggle).not.toBeChecked(); + }, +}; diff --git a/packages/gamut/src/Toggle/__tests__/Toggle.test.tsx b/packages/gamut/src/Toggle/__tests__/Toggle.test.tsx deleted file mode 100644 index 3ee2715f9d..0000000000 --- a/packages/gamut/src/Toggle/__tests__/Toggle.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { Toggle } from '..'; - -const action = jest.fn(); - -const renderView = setupRtl(Toggle, { - label: 'Toggle Text', - checked: true, - onChange: action, -}); - -describe('Toggle', () => { - describe('when the toggle is an input', () => { - it('defaults to a checkbox input', () => { - const { view } = renderView(); - - view.getByRole('checkbox'); - }); - - it('calls onChange when clicked', () => { - const { view } = renderView(); - - view.getByLabelText('Toggle Text').click(); - - expect(action).toHaveBeenCalled(); - }); - - it('renders label node', () => { - const { view } = renderView({ label:

hello

}); - - view.getByText('hello'); - }); - - it('applies an aria-label appropriately when provided with no label', () => { - const { view } = renderView({ - label: undefined, - ariaLabel: 'Toggle Text', - }); - - view.getByLabelText('Toggle Text'); - }); - - it('applies an aria-label appropriately when provided with non string label', () => { - const { view } = renderView({ - label:

Hello

, - ariaLabel: 'Toggle Text', - }); - - view.getByLabelText('Toggle Text'); - }); - - it('is disabled when disabled is true', () => { - const { view } = renderView({ - disabled: true, - }); - - view.getByLabelText('Toggle Text').click(); - - expect(action).not.toHaveBeenCalled(); - }); - }); - - describe('when the toggle is a button', () => { - it('defaults to a switch button', () => { - const { view } = renderView({ - as: 'button', - onChange: undefined, - onClick: action, - }); - - view.getByRole('switch'); - }); - - it('calls onClick when clicked', () => { - const { view } = renderView({ - as: 'button', - onChange: undefined, - onClick: action, - }); - - view.getByLabelText('Toggle Text').click(); - - expect(action).toHaveBeenCalled(); - }); - - it('applies an aria-label appropriately when provided with no label', () => { - const { view } = renderView({ - as: 'button', - label: undefined, - ariaLabel: 'Toggle Text', - onChange: undefined, - onClick: action, - }); - - view.getByLabelText('Toggle Text'); - }); - - it('applies an aria-label appropriately when provided with a non string label', () => { - const { view } = renderView({ - as: 'button', - label:

hello

, - ariaLabel: 'Toggle Text', - onChange: undefined, - onClick: action, - }); - - view.getByLabelText('Toggle Text'); - }); - - it('is disabled when disabled is true', () => { - const { view } = renderView({ - as: 'button', - disabled: true, - onChange: undefined, - onClick: action, - }); - - view.getByLabelText('Toggle Text').click(); - - expect(action).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/styleguide/src/lib/Atoms/Tag/Tag.mdx b/packages/styleguide/src/lib/Atoms/Tag/Tag.mdx deleted file mode 100644 index f0f21ee9bc..0000000000 --- a/packages/styleguide/src/lib/Atoms/Tag/Tag.mdx +++ /dev/null @@ -1,108 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, ImageWrapper, LinkTo } from '~styleguide/blocks'; - -import * as TagStories from './Tag.stories'; - -export const parameters = { - title: 'Tag', - subtitle: - 'Compact, dynamic elements that support user inputs, selections, and actions.', - status: 'current', - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=46539-40954', - }, - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Tag', - }, -}; - - - - - -## Usage - -Use a tag to categorize content, represent user inputs, or guide users to related actions. Tags can either be dismissable individually, or read-only when paired with other controls. - -### Best practices: - -- Choose the tag variant that aligns most with your use case. -- Allow tags to wrap to a second line when they exceed the available space, rather than truncating them. -- When displaying multiple tags, arrange them in a row and allow overflow to wrap to the next line. - -### When NOT to use: - -- Primary actions - for performing critical or primary actions, use the Button component instead. -- Prominent navigation - for standalone, high-priority navigation (e.g., links to key pages or sections), use an Anchor link instead. -- Emphasizing statuses or attributes - for highlighting statuses or attributes, use the Badge component instead. - -## Anatomy - - - -1. **Leading icon** _(optional)_ - -- Use to further clarify the tag meaning - -2. **Badge label** - -- Limit to 1–2 keywords that help to label, categorize, or organize -- _Exception_: For the Suggestion variant, can be a short phrase -- Do not truncate text to keep the information easily scannable and understandable - -3. **Remove button** _(selection tags only)_ - -- Allows users to remove a selection - -## Variants - -Tags have different usage variants (`readOnly`, `selection`, `navigation`, and `suggestion`) that also come in different size variants (`default` and `large`). Aside from `readOnly`, all other Tag variants can be disabled. - -### Read-only - -Use the Read-only tag to categorize or label content in a static, non-interactive way. This tag is ideal for simple categorization that doesn’t require user interaction. - - - -### Selection - -Use the Selection tag to represent specific pieces of information entered by a user. This tag is dismissible, allowing users to manage or remove their entries as needed. - - - -### Navigation - -Use the Navigation tag to guide users to more information by linking to related content on the tagged item. This tag is interactive and intended to provide users with quick access to additional details. - - - -### Suggestion - -Use the Suggestion tag to help narrow user intent by offering dynamic suggestions, such as possible responses or actions. This tag adapts based on user interactions, supporting decision-making by presenting context-relevant options. - - - -## Adding an icon - -You can emphasize the Tag by appending an icon to the left. - - - -## Wrapping - -When a Tag's content exceeds the width of its container, the overflowing content is wrapped onto the next line(s). - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Toggle/Toggle.mdx b/packages/styleguide/src/lib/Atoms/Toggle/Toggle.mdx deleted file mode 100644 index 86b36d4fc6..0000000000 --- a/packages/styleguide/src/lib/Atoms/Toggle/Toggle.mdx +++ /dev/null @@ -1,88 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as ToggleStories from './Toggle.stories'; - -export const parameters = { - title: 'Toggle', - subtitle: - 'A completely controlled boolean Toggle component that triggers an immediate on/off action.', - status: 'current', - design: { - type: 'figma', - url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=30067-40001&node-type=frame&t=1xdU7wY1nMmPMtuG-0', - }, - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Toggle', - }, -}; - - - - - -## Usage - -- Toggles are functionally similar to Checkboxes, as they both display whether they are checked or not. -- Also like our Checkboxes, our Toggles are [controlled components](https://reactjs.org/docs/forms.html#controlled-components), so their checked value must be controlled by an external state and onChange. -- Like other Form Elements, make sure to include a label with your Toggle. - - Labels can be on either side of the Toggle — choose the direction that aligns best with the other elements on the page. -- Toggles are most effective when they trigger an immediate action with related components (like changing copy or showing a section when toggled) - -## Size - -### Medium - -Medium is the default and preferred size, especially if it is part of a form. - - - -### Small - -Use the small size for UI toggles that are outside of a form context and need to fit into smaller areas. - - - -## Disabled - -Disabling the input will change the opacity and cursor but still denote the current value. - - - - -## Labels - -Disabling the input will change the opacity and cursor but still denote the current value. - - - - -Labels can also be React components. - - - -## HTML element - -Our Toggles default to being `input` elements, but can also be the `button` element. - -- An `input` toggle should be provided an `onChange` and should have a secondary action (such as a submit button) that does something with its value. It is also usually wrapped in a form. -- A `button` toggle should be provided an `onClick` and have an action that happens immediately (i.e, without a secondary input.) The toggle also gets the `role` of `switch`. - -If you use voiceover on either of the below, you will hear the difference this makes for accessibility. - - - - -## Interactive examples - - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/Toggle/Toggle.stories.tsx b/packages/styleguide/src/lib/Atoms/Toggle/Toggle.stories.tsx deleted file mode 100644 index e373812a9c..0000000000 --- a/packages/styleguide/src/lib/Atoms/Toggle/Toggle.stories.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Text, Toggle } from '@skillsoft/gamut'; -import type { Meta, StoryObj } from '@storybook/react'; -import { useState } from 'react'; -import { expect } from 'storybook/test'; - -const meta: Meta = { - component: Toggle, - args: { - label: 'Toggle me', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: {}, -}; - -export const Medium: Story = { - args: { - size: 'medium', - }, -}; - -export const Small: Story = { - args: { - size: 'small', - }, -}; - -export const Disabled: Story = { - args: { - disabled: true, - }, -}; - -export const DisabledChecked: Story = { - args: { - disabled: true, - checked: true, - }, -}; - -export const LabelLeft: Story = { - args: { - label: 'My label is to the left', - labelSide: 'left', - }, -}; - -export const LabelRight: Story = { - args: { - label: 'My label is to the right', - labelSide: 'right', - }, -}; - -export const ReactComponentLabel: Story = { - args: { - checked: false, - label: I am a Text component, - ariaLabel: 'Hello there', - }, -}; - -export const InputElement: Story = { - args: { - label: 'input toggle', - onChange: () => null, - }, -}; - -export const ButtonElement: Story = { - args: { - as: 'button', - checked: true, - label: 'button toggle', - onChange: undefined, - onClick: () => null, - }, -}; - -export const InteractiveInputToggle: Story = { - render: function InteractiveInputToggleStory() { - const [checked, setChecked] = useState(false); - return ( - setChecked((value) => !value)} - /> - ); - }, - play: async ({ canvas, userEvent }) => { - const toggle = canvas.getByRole('checkbox'); - // initial state assertion - await expect(toggle).not.toBeChecked(); - - // click and assert its new state - await userEvent.click(toggle); - await expect(toggle).toBeChecked(); - - // restore to initial state - await userEvent.click(toggle); - await expect(toggle).not.toBeChecked(); - }, -}; - -export const InteractiveButtonToggle: React.FC = () => { - const [checked, setChecked] = useState(false); - const changeHandler = () => { - setChecked(!checked); - }; - - return ( - - ); -}; From 969fe4b72d52385a9e65db996cdafafd424df4d0 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 19 Oct 2026 14:34:14 -0400 Subject: [PATCH 08/13] Migrate PopoverContainer into gamut Storybook Co-locate the story under Overlays and move the node tests out of __tests__ (PopoverContainer.test.tsx, hooks.test.tsx, testUtils.ts), excluding the test helper from the lib build. Disable FocusTrap autofocus in the catalog stories so the docs page no longer hijacks scroll, and remove the old styleguide story. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PopoverContainer.stories.tsx | 298 ++++++++++++++++++ .../{__tests__ => }/PopoverContainer.test.tsx | 8 +- .../{__tests__ => }/hooks.test.tsx | 4 +- .../{__tests__/utils.ts => testUtils.ts} | 0 packages/gamut/tsconfig.lib.json | 4 +- .../PopoverContainer/PopoverContainer.mdx | 121 ------- .../PopoverContainer.stories.tsx | 275 ---------------- 7 files changed, 307 insertions(+), 403 deletions(-) create mode 100644 packages/gamut/src/PopoverContainer/PopoverContainer.stories.tsx rename packages/gamut/src/PopoverContainer/{__tests__ => }/PopoverContainer.test.tsx (99%) rename packages/gamut/src/PopoverContainer/{__tests__ => }/hooks.test.tsx (95%) rename packages/gamut/src/PopoverContainer/{__tests__/utils.ts => testUtils.ts} (100%) delete mode 100644 packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.mdx delete mode 100644 packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.stories.tsx diff --git a/packages/gamut/src/PopoverContainer/PopoverContainer.stories.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.stories.tsx new file mode 100644 index 0000000000..c05ff06483 --- /dev/null +++ b/packages/gamut/src/PopoverContainer/PopoverContainer.stories.tsx @@ -0,0 +1,298 @@ +import { Background } from '@skillsoft/gamut-styles'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef } from 'react'; + +import { FlexBox } from '../Box'; +import { Text } from '../Typography'; +import { PopoverContainer } from '.'; + +const SINGLE_DIRECTIONS = ['top', 'bottom', 'left', 'right'] as const; + +const MULTI_DIRECTIONS = [ + 'top-left', + 'top-right', + 'bottom-right', + 'bottom-left', +] as const; + +const meta: Meta = { + title: 'Overlays/PopoverContainer', + component: PopoverContainer, + args: { + alignment: 'top-left', + allowPageInteraction: true, + isOpen: true, + inline: true, + x: 0, + y: 0, + offset: 20, + }, + argTypes: { + targetRef: { + control: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, + alignment: { + control: 'select', + options: [...SINGLE_DIRECTIONS, ...MULTI_DIRECTIONS], + }, + }, + render: function PopoverDemo(args) { + const target = useRef(null); + + return ( + + + + + Pop Tart + + + + This balanced breakfast + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: ({ canvas }) => { + canvas.getByText('Pop Tart'); + }, +}; + +export const Alignment: Story = { + render: function AlignmentDemo(args) { + const target = useRef(null); + + return ( + + + {[...SINGLE_DIRECTIONS, ...MULTI_DIRECTIONS].map((alignment) => { + return ( + + + {alignment.split('-').map((val) => ( + + {val} + + ))} + + + ); + })} + + Transform Axis + + + + ); + }, +}; + +export const InvertAxis: Story = { + render: function InvertAxisDemo(args) { + const target = useRef(null); + + return ( + + + {(['x', 'y'] as const).map((axis) => { + return MULTI_DIRECTIONS.map((alignment) => { + return ( + + + + {alignment} + + + {axis} - axis + + + + ); + }); + })} + + Transform Axis + + + + ); + }, +}; + +export const Offset: Story = { + render: function OffsetDemo(args) { + const target = useRef(null); + + return ( + + + {[...SINGLE_DIRECTIONS, ...MULTI_DIRECTIONS].map((alignment) => { + return ( + + + + {alignment} + + + 20px + + + + ); + })} + + Transform Axis + + + + ); + }, +}; + +export const XAndY: Story = { + render: function XAndYDemo(args) { + const target = useRef(null); + + return ( + + + {[...SINGLE_DIRECTIONS, ...MULTI_DIRECTIONS].map((alignment) => { + return ( + + + + {alignment} + + + 0px / 0px + + + + ); + })} + + Transform Axis + + + + ); + }, +}; diff --git a/packages/gamut/src/PopoverContainer/__tests__/PopoverContainer.test.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.test.tsx similarity index 99% rename from packages/gamut/src/PopoverContainer/__tests__/PopoverContainer.test.tsx rename to packages/gamut/src/PopoverContainer/PopoverContainer.test.tsx index 9b20843083..dbea326617 100644 --- a/packages/gamut/src/PopoverContainer/__tests__/PopoverContainer.test.tsx +++ b/packages/gamut/src/PopoverContainer/PopoverContainer.test.tsx @@ -2,15 +2,15 @@ import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { PopoverContainer } from '..'; -import { PopoverContainerProps } from '../types'; -import * as utils from '../utils'; +import { PopoverContainer } from '.'; import { createMockDOMRect, createNestedScrollableParents, createScrollableParent, setupWindowDimensions, -} from './utils'; +} from './testUtils'; +import { PopoverContainerProps } from './types'; +import * as utils from './utils'; const defaultBounding = { bottom: 350, diff --git a/packages/gamut/src/PopoverContainer/__tests__/hooks.test.tsx b/packages/gamut/src/PopoverContainer/hooks.test.tsx similarity index 95% rename from packages/gamut/src/PopoverContainer/__tests__/hooks.test.tsx rename to packages/gamut/src/PopoverContainer/hooks.test.tsx index eb56581b96..93679decc1 100644 --- a/packages/gamut/src/PopoverContainer/__tests__/hooks.test.tsx +++ b/packages/gamut/src/PopoverContainer/hooks.test.tsx @@ -1,8 +1,8 @@ import { cleanup, render, screen } from '@testing-library/react'; import { useLayoutEffect, useRef, useState } from 'react'; -import { Box } from '../../Box'; -import { useScrollingParents } from '../hooks'; +import { Box } from '../Box'; +import { useScrollingParents } from './hooks'; /** * Reacts attach after paint; a render that mounts the ref target still sees diff --git a/packages/gamut/src/PopoverContainer/__tests__/utils.ts b/packages/gamut/src/PopoverContainer/testUtils.ts similarity index 100% rename from packages/gamut/src/PopoverContainer/__tests__/utils.ts rename to packages/gamut/src/PopoverContainer/testUtils.ts diff --git a/packages/gamut/tsconfig.lib.json b/packages/gamut/tsconfig.lib.json index 11623dfcac..9c94e0f86e 100644 --- a/packages/gamut/tsconfig.lib.json +++ b/packages/gamut/tsconfig.lib.json @@ -24,7 +24,9 @@ "**/*.stories.ts", "**/*.stories.tsx", "**/*.stories.js", - "**/*.stories.jsx" + "**/*.stories.jsx", + "**/testUtils.ts", + "**/testUtils.tsx" ], "include": [ "src/**/*.ts", diff --git a/packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.mdx b/packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.mdx deleted file mode 100644 index a857f0f434..0000000000 --- a/packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.mdx +++ /dev/null @@ -1,121 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as PopoverContainerStories from './PopoverContainer.stories'; - -export const parameters = { - title: 'PopoverContainer', - subtitle: - 'Utility component for positioning content relative to a target element.', - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/PopoverContainer', - }, -}; - - - - - -## Usage - -- PopoverContainer is only responsible for positioning its content and does not include any styles for its content. -- Use the `inline` prop to ensure that scrolling and resizing do not result in unnecessary rerenders / jumpy popovers. The popover renders with absolute position relative to the current stacking context. -- Do not use `inline` when the current stacking context is too small or the container is wrapped with an element that specifies `overflow: hidden`. - -```tsx -const MyComponent = () => { - const target = useRef(null); - - return ( - - - - - I render relative to my nearest parent! - - - - I render relative to the window! - - - ); -}; -``` - -## Positioning - -PopoverContainer uses `useElementDir` from `@skillsoft/gamut-styles` with `targetRef` (`useElementDir(targetRef) === 'rtl'`) so alignment and placement respect right-to-left when the anchor lives in an RTL subtree. Non-React code can use the same resolution rules via the imperative `elementDir(el)` export from that package. See Logical and physical CSS properties for full documentation on `useElementDir`, `elementDir`, and logical vs physical CSS in Gamut. - -There are multiple props that can be used to position the popover. Progressively getting more specific: - -1. `alignment`: Positions the popover along the edge of the targets dimensions. -2. `invertAxis`: Mirror the content 100% of its dimensions along its inside border on either axis (can only affect multi-directional alignments like `top-left`) -3. `offset`: Adds addition offset in the direction of the `alignment` (e.g. 30 and `top-left` will move the popover 30px to the top and left. -4. `x` + `y`: Offset each axis individually if needed for more surgical control. - -### `alignment` - -Position the popover at the exact edge of these coordinates. - - - -### `invertAxis` - -Flip the popover content along either the x or y axis relative to the alignment. top-left and x would render the popover on the inside left edge rather than outside. - - - -### `offset` - -Addition pixel offset from center in the direction of the alignment (e.g. top-left and offset 20 results in 20 pixels to the left/top). - - - -### Individual axis offsets `x` and `y` - -Absolute offset values irrespective of the relative position. (e.g. `x={-4}` will be 4 pixels to the left in every position unlike offset). - - - -### `allowPageInteraction` - -When `allowPageInteraction` is set to `true`, the page interaction is allowed. This is useful when the parent component is handling focus management and accessibility. - -### `closeOnViewportExit` - -By default, `closeOnViewportExit` is set to `false`, which means the popover will remain open even if the target element moves out of the viewport. This is the recommended default for interactive menus (like filter menus or action menus) where users need to interact with the popover content. - -If you set `closeOnViewportExit` to `true`, the popover will automatically close when the target element moves completely out of viewport. This can be useful for: - -- Informational popovers that should disappear when the target is no longer visible -- Preventing orphaned popovers when content scrolls away -- Notifications or temporary UI elements that should clean up automatically - -```tsx -// Default behavior - popover stays open even if target scrolls out of view - - - Filter Option 1 - Filter Option 2 - - - -// Explicitly close when target exits viewport - - Some helpful information - -``` - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.stories.tsx b/packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.stories.tsx deleted file mode 100644 index 09158a8e3c..0000000000 --- a/packages/styleguide/src/lib/Atoms/PopoverContainer/PopoverContainer.stories.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { FlexBox, PopoverContainer, Text } from '@skillsoft/gamut'; -import { Background } from '@skillsoft/gamut-styles'; -import type { Meta } from '@storybook/react'; -import { ComponentProps, useRef } from 'react'; - -const meta: Meta = { - component: PopoverContainer, - args: { - alignment: 'top-left', - allowPageInteraction: true, - isOpen: true, - inline: true, - x: 0, - y: 0, - offset: 20, - }, - argTypes: { - targetRef: { - control: { - disable: true, - }, - }, - }, -}; - -export default meta; - -const SINGLE_DIRECTIONS = ['top', 'bottom', 'left', 'right'] as const; - -const MULTI_DIRECTIONS = [ - 'top-left', - 'top-right', - 'bottom-right', - 'bottom-left', -] as const; - -export const Default: React.FC> = ( - args -) => { - const target = useRef(null); - - return ( - - - - - Pop Tart - - - - This balanced breakfast - - - - ); -}; - -export const Alignment: React.FC> = ( - args -) => { - const target = useRef(null); - - return ( - - - {[...SINGLE_DIRECTIONS, ...MULTI_DIRECTIONS].map((alignment) => { - return ( - - - {alignment.split('-').map((val) => ( - - {val} - - ))} - - - ); - })} - - Transform Axis - - - - ); -}; - -export const InvertAxis: React.FC> = ( - args -) => { - const target = useRef(null); - - return ( - - - {(['x', 'y'] as const).map((axis) => { - return MULTI_DIRECTIONS.map((alignment) => { - return ( - - - - {alignment} - - - {axis} - axis - - - - ); - }); - })} - - - Transform Axis - - - - ); -}; - -export const Offset: React.FC> = ( - args -) => { - const target = useRef(null); - - return ( - - - {[...SINGLE_DIRECTIONS, ...MULTI_DIRECTIONS].map((alignment) => { - return ( - - - - {alignment} - - - 20px - - - - ); - })} - - - Transform Axis - - - - ); -}; - -export const XAndY: React.FC> = ( - args -) => { - const target = useRef(null); - - return ( - - - {[...SINGLE_DIRECTIONS, ...MULTI_DIRECTIONS].map((alignment) => { - return ( - - - - {alignment} - - - 0px / 0px - - - - ); - })} - - - Transform Axis - - - - ); -}; From b715e4fe17069db15377b2703cfef8254bec78c9 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 19 Oct 2026 15:07:03 -0400 Subject: [PATCH 09/13] Co-locate Card test out of __tests__ Move the useCardElevation test to Card/Card.test.tsx to match the co-located convention used by the other migrated components. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/gamut/src/Card/{__tests__ => }/Card.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename packages/gamut/src/Card/{__tests__ => }/Card.test.tsx (97%) diff --git a/packages/gamut/src/Card/__tests__/Card.test.tsx b/packages/gamut/src/Card/Card.test.tsx similarity index 97% rename from packages/gamut/src/Card/__tests__/Card.test.tsx rename to packages/gamut/src/Card/Card.test.tsx index 23b65cdc41..de527188c8 100644 --- a/packages/gamut/src/Card/__tests__/Card.test.tsx +++ b/packages/gamut/src/Card/Card.test.tsx @@ -2,7 +2,7 @@ import { theme } from '@skillsoft/gamut-styles'; import { MockGamutProvider } from '@skillsoft/gamut-tests'; import { renderHook } from '@testing-library/react'; -import { useCardElevation } from '../styles'; +import { useCardElevation } from './styles'; const renderElevation = (...args: Parameters) => renderHook(() => useCardElevation(...args), { From 1d9b22c4db263f4fe5b149721a3aebab1d390851 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 19 Oct 2026 15:08:35 -0400 Subject: [PATCH 10/13] changeset --- .changeset/nice-wings-rest.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nice-wings-rest.md diff --git a/.changeset/nice-wings-rest.md b/.changeset/nice-wings-rest.md new file mode 100644 index 0000000000..4090467b20 --- /dev/null +++ b/.changeset/nice-wings-rest.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut': minor +--- + +Migrates a batch of components out of the retiring packages/styleguide into co-located .stories.tsx files in packages/gamut/src, as stories-as-tests under @storybook/addon-vitest. Stories are regrouped from the old Atoms/Molecules taxonomy into semantic sidebar categories that match the new docs site, behavioral coverage from the old Jest tests is folded into play functions, and node-only tests are kept co-located for concerns that can't run in the browser. From 1c7601e4fe2cc0fba8be6ec222b2bfc496b169f8 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 23 Oct 2026 13:48:13 -0400 Subject: [PATCH 11/13] PR feedback --- packages/gamut/src/Badge/Badge.stories.tsx | 29 +++++++++++----------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/gamut/src/Badge/Badge.stories.tsx b/packages/gamut/src/Badge/Badge.stories.tsx index daf69ed7a0..fa35fcc68f 100644 --- a/packages/gamut/src/Badge/Badge.stories.tsx +++ b/packages/gamut/src/Badge/Badge.stories.tsx @@ -1,7 +1,4 @@ -import styled from '@emotion/styled'; import * as icons from '@skillsoft/gamut-icons'; -import { MiniStarIcon } from '@skillsoft/gamut-icons'; -import { css } from '@skillsoft/gamut-styles'; import type { Meta, StoryObj } from '@storybook/react'; import { Box, FlexBox } from '../Box'; @@ -42,19 +39,21 @@ export const Tertiary: Story = { }, }; -const AbsoluteBadge = styled(Badge)( - css({ position: 'absolute', top: 12, left: 12 }) -); - export const TertiaryFill: Story = { - render: () => ( - - - Badge - - - - ), + args: { + icon: icons.MiniStarIcon, + variant: 'tertiaryFill', + }, + decorators: [ + (Story) => ( + + + + + + + ), + ], }; export const Accent: Story = { From dccf610d593933716a86d0169c3dc8f44e83ffc4 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 23 Oct 2026 16:42:47 -0400 Subject: [PATCH 12/13] Show system props in Storybook props tables, in their own tab Docgen read the root tsconfig, where strict function types make `variance.compose(...)` fail to type-check, so composed system props silently dropped out of most props tables. Point docgen at the gamut tsconfig instead. Add an argTypes enhancer that groups system props by prop family, summarizes their types as theme tokens, and gives them select or text controls. A custom docs page puts them in a "System props" tab next to the component's own props, and stops repeating the primary story under Stories. Co-authored-by: Cursor --- .../components/Elements/DocsPage.tsx | 54 ++++++ packages/.storybook/main.ts | 4 + packages/.storybook/preview.ts | 4 + packages/.storybook/systemProps.ts | 157 ++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 packages/.storybook/components/Elements/DocsPage.tsx create mode 100644 packages/.storybook/systemProps.ts diff --git a/packages/.storybook/components/Elements/DocsPage.tsx b/packages/.storybook/components/Elements/DocsPage.tsx new file mode 100644 index 0000000000..8ba1ef8a41 --- /dev/null +++ b/packages/.storybook/components/Elements/DocsPage.tsx @@ -0,0 +1,54 @@ +import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@skillsoft/gamut'; +import { + Controls, + Description, + Primary, + Stories, + Subtitle, + Title, + useOf, +} from '@storybook/addon-docs/blocks'; + +import { isSystemPropCategory } from '../../systemProps'; + +/** + * Storybook's default autodocs page, with system props moved out of the main + * props table into their own tab, and the primary story shown only at the top + * instead of again under Stories. + */ +export const DocsPage: React.FC = () => { + const { csfFile, preparedMeta } = useOf('meta', ['meta']); + const isSingleStory = Object.keys(csfFile.stories).length === 1; + const systemProps = Object.entries(preparedMeta.argTypes) + .filter(([, argType]) => isSystemPropCategory(argType.table?.category)) + .map(([name]) => name); + + return ( + <> + + <Subtitle /> + <Description of="meta" /> + {isSingleStory && <Description of="story" />} + <Primary /> + {systemProps.length > 0 ? ( + <Tabs defaultSelectedKey="props"> + <TabList aria-label="Props"> + <Tab id="props">Props</Tab> + <Tab id="system-props">System props</Tab> + </TabList> + <TabPanels> + <TabPanel id="props"> + <Controls exclude={systemProps} /> + </TabPanel> + <TabPanel id="system-props"> + <Controls include={systemProps} /> + </TabPanel> + </TabPanels> + </Tabs> + ) : ( + <Controls /> + )} + {!isSingleStory && <Stories includePrimary={false} />} + </> + ); +}; diff --git a/packages/.storybook/main.ts b/packages/.storybook/main.ts index 7c83f43e6f..0e7f23e211 100644 --- a/packages/.storybook/main.ts +++ b/packages/.storybook/main.ts @@ -39,6 +39,10 @@ const config: StorybookConfig = { typescript: { reactDocgen: 'react-docgen-typescript', reactDocgenTypescriptOptions: { + // Needs `strictFunctionTypes: false` (unlike the root tsconfig), or + // `variance.compose(...)` fails to type-check and composed system props + // drop out of the props tables. + tsconfigPath: resolve(__dirname, '../gamut/tsconfig.json'), skipChildrenPropWithoutDoc: false, shouldRemoveUndefinedFromOptional: true, propFilter: (prop) => { diff --git a/packages/.storybook/preview.ts b/packages/.storybook/preview.ts index b771e46bbd..93da0ea3e7 100644 --- a/packages/.storybook/preview.ts +++ b/packages/.storybook/preview.ts @@ -3,9 +3,12 @@ import { Preview } from '@storybook/react'; import theme from './theming/GamutTheme'; import { withEmotion } from './theming/GamutThemeProvider'; import { DocsContainer } from './components/Elements/DocsContainer'; +import { DocsPage } from './components/Elements/DocsPage'; import { breakpoints } from '@skillsoft/gamut-styles'; +import { enhanceSystemProps } from './systemProps'; const preview: Preview = { + argTypesEnhancers: [enhanceSystemProps], parameters: { a11y: { test: 'error', @@ -21,6 +24,7 @@ const preview: Preview = { }, docs: { container: DocsContainer, + page: DocsPage, theme, }, viewport: { diff --git a/packages/.storybook/systemProps.ts b/packages/.storybook/systemProps.ts new file mode 100644 index 0000000000..1630a9e3a2 --- /dev/null +++ b/packages/.storybook/systemProps.ts @@ -0,0 +1,157 @@ +import { coreTheme, system } from '@skillsoft/gamut-styles'; +import type { + ArgTypesEnhancer, + StrictInputType, +} from 'storybook/internal/types'; + +interface SystemPropConfig { + property?: unknown; + properties?: unknown; + scale?: unknown; +} + +interface SystemPropGroup { + propNames: readonly string[]; + config: Record<string, SystemPropConfig>; +} + +// The groups overlap (`space` contains `margin`/`padding`; flex and grid item +// props are also in `layout`), so the first group listing a prop wins. +const categoryGroups: [string, SystemPropGroup[]][] = [ + ['Spacing', [system.space]], + ['Flex & Grid', [system.flex, system.grid]], + ['Layout', [system.layout]], + ['Positioning', [system.positioning]], + ['Typography', [system.typography]], + ['Color', [system.color]], + ['Background', [system.background]], + ['Border', [system.border]], + ['Shadow', [system.shadow]], + ['List', [system.list]], +]; + +const systemProps = new Map< + string, + { category: string; config: SystemPropConfig } +>(); +categoryGroups.forEach(([category, groups]) => { + groups.forEach(({ propNames, config }) => { + propNames.forEach((prop) => { + if (!systemProps.has(prop)) { + systemProps.set(prop, { category, config: config[prop] }); + } + }); + }); +}); + +const categoryOrder = categoryGroups.map(([category]) => category); +const categoryRank = (category: string | undefined) => + category === undefined ? -1 : categoryOrder.indexOf(category); + +export const isSystemPropCategory = (category: string | undefined) => + categoryRank(category) >= 0; + +const themeScales = coreTheme as unknown as Record< + string, + Record<string, unknown> | undefined +>; + +// Token names come from the Core theme; other themes may define more or fewer. +const getScaleTokens = ({ scale }: SystemPropConfig) => + typeof scale === 'string' ? Object.keys(themeScales[scale] ?? {}) : []; + +const toKebabCase = (name: string) => + name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); + +type PropertyMode = 'physical' | 'logical'; + +// Property names, lists, and list items can each be a `{ physical, logical }` pair. +const resolveMode = (value: unknown, mode: PropertyMode): unknown => + typeof value === 'object' && value !== null && 'physical' in value + ? (value as Record<PropertyMode, unknown>)[mode] + : value; + +const getCssProperties = ( + { property, properties }: SystemPropConfig, + mode: PropertyMode +) => { + const resolved = resolveMode(properties ?? property, mode); + return (Array.isArray(resolved) ? resolved : [resolved]) + .map((name) => resolveMode(name, mode)) + .filter((name): name is string => typeof name === 'string') + .map(toKebabCase); +}; + +const describeCssProperties = (config: SystemPropConfig) => { + const format = (names: string[]) => + names.map((name) => `\`${name}\``).join(', '); + const physical = getCssProperties(config, 'physical'); + const logical = getCssProperties(config, 'logical'); + return physical.join() === logical.join() + ? `CSS: ${format(physical)}` + : `CSS: ${format(physical)} (logical: ${format(logical)})`; +}; + +const MAX_INLINE_TOKENS = 12; + +const getTypeSummary = (config: SystemPropConfig, tokens: string[]) => { + if (tokens.length === 0) return { summary: 'CSS value' }; + if (tokens.length <= MAX_INLINE_TOKENS) + return { summary: tokens.join(' | ') }; + return { summary: `theme.${config.scale}`, detail: tokens.join(' | ') }; +}; + +// Components can declare their own props with system prop names, like a numeric +// `zIndex`. Docgen types real system props as `Scale<...>`, or, where two prop +// groups both define a prop, as csstype's open-ended `(string & {})` union. +const hasSystemPropType = ({ table }: StrictInputType) => { + const docgenType = table?.type?.summary; + return ( + docgenType !== undefined && + (docgenType.startsWith('Scale<') || docgenType.includes('(string & {})')) + ); +}; + +const enhanceSystemProp = ( + argType: StrictInputType, + { category, config }: { category: string; config: SystemPropConfig } +): StrictInputType => { + const tokens = getScaleTokens(config); + return { + ...argType, + description: argType.description || describeCssProperties(config), + control: argType.control ?? { type: tokens.length > 0 ? 'select' : 'text' }, + ...(tokens.length > 0 && !argType.options && { options: tokens }), + table: { + ...argType.table, + category: argType.table?.category ?? category, + type: getTypeSummary(config, tokens), + }, + }; +}; + +/** + * Makes system props readable in the props table: groups them by prop family + * below the component's own props, summarizes their types as the theme tokens + * they accept, and gives them select or text controls. Anything set in a + * story's `argTypes` takes precedence. The table renders categories in + * first-seen order, so entries are sorted to keep the sections in the same + * order on every component. + */ +export const enhanceSystemProps: ArgTypesEnhancer = ({ argTypes }) => + Object.fromEntries( + Object.entries(argTypes) + .map(([name, argType]): [string, typeof argType] => { + const systemProp = systemProps.get(name); + return [ + name, + systemProp && hasSystemPropType(argType) + ? enhanceSystemProp(argType, systemProp) + : argType, + ]; + }) + .sort( + ([, a], [, b]) => + categoryRank(a.table?.category) - categoryRank(b.table?.category) + ) + ); From cbe443a91665f06c3db7aeb06ed04e98c552026a Mon Sep 17 00:00:00 2001 From: Amy Resnik <aresnik11@gmail.com> Date: Fri, 23 Oct 2026 17:27:46 -0400 Subject: [PATCH 13/13] Use block comments for multi-line comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/.storybook/main.ts | 8 +++++--- packages/.storybook/systemProps.ts | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/.storybook/main.ts b/packages/.storybook/main.ts index 0e7f23e211..70ddb7fba1 100644 --- a/packages/.storybook/main.ts +++ b/packages/.storybook/main.ts @@ -39,9 +39,11 @@ const config: StorybookConfig = { typescript: { reactDocgen: 'react-docgen-typescript', reactDocgenTypescriptOptions: { - // Needs `strictFunctionTypes: false` (unlike the root tsconfig), or - // `variance.compose(...)` fails to type-check and composed system props - // drop out of the props tables. + /* + * Needs `strictFunctionTypes: false` (unlike the root tsconfig), or + * `variance.compose(...)` fails to type-check and composed system props + * drop out of the props tables. + */ tsconfigPath: resolve(__dirname, '../gamut/tsconfig.json'), skipChildrenPropWithoutDoc: false, shouldRemoveUndefinedFromOptional: true, diff --git a/packages/.storybook/systemProps.ts b/packages/.storybook/systemProps.ts index 1630a9e3a2..3ae6f55f5c 100644 --- a/packages/.storybook/systemProps.ts +++ b/packages/.storybook/systemProps.ts @@ -101,9 +101,11 @@ const getTypeSummary = (config: SystemPropConfig, tokens: string[]) => { return { summary: `theme.${config.scale}`, detail: tokens.join(' | ') }; }; -// Components can declare their own props with system prop names, like a numeric -// `zIndex`. Docgen types real system props as `Scale<...>`, or, where two prop -// groups both define a prop, as csstype's open-ended `(string & {})` union. +/* + * Components can declare their own props with system prop names, like a numeric + * `zIndex`. Docgen types real system props as `Scale<...>`, or, where two prop + * groups both define a prop, as csstype's open-ended `(string & {})` union. + */ const hasSystemPropType = ({ table }: StrictInputType) => { const docgenType = table?.type?.summary; return ( @@ -136,7 +138,9 @@ const enhanceSystemProp = ( * they accept, and gives them select or text controls. Anything set in a * story's `argTypes` takes precedence. The table renders categories in * first-seen order, so entries are sorted to keep the sections in the same - * order on every component. + * order on every component. Storybook moves props with story args to the front + * after this runs, so a story that sets a system prop arg shows that section + * first. */ export const enhanceSystemProps: ArgTypesEnhancer = ({ argTypes }) => Object.fromEntries(