diff --git a/packages/.storybook/main.ts b/packages/.storybook/main.ts index 20fffce153..7c83f43e6f 100644 --- a/packages/.storybook/main.ts +++ b/packages/.storybook/main.ts @@ -16,6 +16,10 @@ const config: StorybookConfig = { directory: '../styleguide/src/lib', files: '**/*.stories.@(js|jsx|ts|tsx)', }, + { + directory: '../gamut/src', + files: '**/*.stories.@(js|jsx|ts|tsx)', + }, ], staticDirs: ['../styleguide/src/static'], addons: [ @@ -91,6 +95,14 @@ const config: StorybookConfig = { find: '~styleguide/argTypes', replacement: resolve(__dirname, './argTypes'), }, + { + find: '~storybook/blocks', + replacement: resolve(__dirname, './components'), + }, + { + find: '~storybook/argTypes', + replacement: resolve(__dirname, './argTypes'), + }, { find: /^@skillsoft\/gamut-styles$/, replacement: resolve(__dirname, '../gamut-styles/src'), diff --git a/packages/gamut/src/Modals/Dialog.stories.tsx b/packages/gamut/src/Modals/Dialog.stories.tsx new file mode 100644 index 0000000000..235aaf5457 --- /dev/null +++ b/packages/gamut/src/Modals/Dialog.stories.tsx @@ -0,0 +1,272 @@ +import { ColorMode } from '@skillsoft/gamut-styles'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useState } from 'react'; +import { expect, fn, screen } from 'storybook/test'; +import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; + +import { closeButtonPropsArgTypes } from '~storybook/argTypes'; + +import { Box, FlexBox } from '../Box'; +import { FillButton, StrokeButton } from '../Button'; +import { Text } from '../Typography'; +import { Dialog } from './Dialog'; + +type Story = StoryObj; + +const openDialog: NonNullable = async ({ + canvas, + userEvent, +}) => { + await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' })); +}; + +const meta: TypeWithDeepControls> = { + title: 'Molecules/Modals/Dialog', + component: Dialog, + args: { + title: 'Depeche Modal', + children: 'All I ever wanted, all I ever needed is here in my', + onRequestClose: fn(), + confirmCta: { children: 'Arms!', onClick: fn() }, + cancelCta: { children: 'Heart?', onClick: fn() }, + }, + argTypes: { + variant: { + control: 'radio', + options: ['primary', 'danger'], + }, + ...closeButtonPropsArgTypes({ + defaultTipText: 'Close dialog', + defaultTipAlignment: 'top-center', + }), + }, + render: function DialogWithTrigger(args) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + setIsOpen(true)}>Open dialog + { + setIsOpen(false); + args.onRequestClose(); + }} + /> + + ); + }, + play: openDialog, +}; + +export default meta; + +export const Default: Story = {}; + +export const Danger: Story = { + args: { variant: 'danger' }, +}; + +export const DarkMode: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +export const CustomClose: Story = { + args: { + title: 'Custom Close', + closeButtonProps: { hidden: true }, + }, + render: function CustomCloseDialog(args) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + setIsOpen(true)}>Open dialog + { + setIsOpen(false); + args.onRequestClose(); + }} + > + + + Missing a close button? + + + setIsOpen(false)}> + No problem, click me! + + + + + + ); + }, + play: async (context) => { + await openDialog(context); + + await context.userEvent.click( + screen.getByRole('button', { name: 'No problem, click me!' }) + ); + await expect(screen.queryByRole('dialog')).toBeNull(); + }, +}; + +export const CloseButtonCustomization: Story = { + args: { + size: 'medium', + title: 'Close Button Customization Demo', + }, + render: function CloseButtonCustomizationDialog(args) { + const [isOpen, setIsOpen] = useState(false); + const [isDisabled, setIsDisabled] = useState(false); + const closeButtonRef = useRef(null); + + return ( + <> + setIsOpen(true)}>Open dialog + { + setIsOpen(false); + args.onRequestClose(); + }} + > + + + This dialog has a customized close button with a ref for + programmatic focus management, a custom tooltip, and a disabled + state. + + closeButtonRef.current?.focus()} + > + Focus Close Button + + setIsDisabled(!isDisabled)}> + {isDisabled ? 'Enable' : 'Disable'} Focus Close Button + + + + + ); + }, + play: async (context) => { + await openDialog(context); + const { userEvent } = context; + + const closeButton = screen.getByRole('button', { + name: 'Close this very important Dialog', + }); + await expect(closeButton).not.toBeDisabled(); + + await userEvent.click( + screen.getByRole('button', { name: 'Focus Close Button' }) + ); + await expect(closeButton).toHaveFocus(); + + await userEvent.click( + screen.getByRole('button', { name: 'Disable Focus Close Button' }) + ); + await expect(closeButton).toBeDisabled(); + }, +}; + +export const FocusManagement: Story = { + args: { + size: 'medium', + title: 'Focus Management Demo', + }, + render: function FocusManagementDialog(args) { + const [isOpen, setIsOpen] = useState(false); + const containerFocusRef = useRef(null); + + return ( + <> + setIsOpen(true)}>Open dialog + { + setIsOpen(false); + args.onRequestClose(); + }} + > + + + This dialog container has a ref that you can interact with + programmatically. + + + containerFocusRef.current?.focus()}> + Focus Dialog Container + + + + Try tabbing through the page - the dialog container will maintain + focus when you click the "Focus Dialog Container" + button. + + + + + ); + }, + play: async (context) => { + await openDialog(context); + + await context.userEvent.click( + screen.getByRole('button', { name: 'Focus Dialog Container' }) + ); + await expect(screen.getByRole('dialog')).toHaveFocus(); + }, +}; + +export const Dismissal: Story = { + play: async (context) => { + const { args, userEvent } = context; + + await openDialog(context); + await userEvent.click(screen.getByRole('button', { name: 'Arms!' })); + await expect(args.confirmCta.onClick).toHaveBeenCalledTimes(1); + await expect(args.onRequestClose).toHaveBeenCalledTimes(1); + await expect(screen.queryByRole('dialog')).toBeNull(); + + await openDialog(context); + await userEvent.click(screen.getByRole('button', { name: 'Heart?' })); + await expect(args.cancelCta?.onClick).toHaveBeenCalledTimes(1); + await expect(args.onRequestClose).toHaveBeenCalledTimes(2); + await expect(screen.queryByRole('dialog')).toBeNull(); + + await openDialog(context); + await userEvent.click(screen.getByRole('button', { name: 'Close dialog' })); + await expect(args.onRequestClose).toHaveBeenCalledTimes(3); + await expect(screen.queryByRole('dialog')).toBeNull(); + + await openDialog(context); + await userEvent.click(screen.getByRole('dialog')); + await expect(args.onRequestClose).toHaveBeenCalledTimes(3); + screen.getByRole('dialog'); + + await userEvent.keyboard('{Escape}'); + await expect(args.onRequestClose).toHaveBeenCalledTimes(4); + await expect(screen.queryByRole('dialog')).toBeNull(); + }, +}; diff --git a/packages/gamut/src/Modals/Dialog.test.tsx b/packages/gamut/src/Modals/Dialog.test.tsx new file mode 100644 index 0000000000..b9e61a2a3e --- /dev/null +++ b/packages/gamut/src/Modals/Dialog.test.tsx @@ -0,0 +1,32 @@ +import { setupRtl } from '@skillsoft/gamut-tests'; +import * as React from 'react'; + +import { Dialog } from './Dialog'; + +const defaultProps = { + isOpen: true, + title: 'Hello world', + children: 'Close me please', + onRequestClose: jest.fn(), + confirmCta: { children: 'Confirm' }, + cancelCta: { children: 'Cancel' }, +}; + +const renderView = setupRtl(Dialog, defaultProps); + +describe('Dialog', () => { + it('forwards closeButtonProps.ref to the close button', () => { + const closeButtonRef = React.createRef(); + const { view } = renderView({ closeButtonProps: { ref: closeButtonRef } }); + + const closeButton = view.getByRole('button', { name: 'Close dialog' }); + expect(closeButtonRef.current).toBe(closeButton); + }); + + it('forwards containerFocusRef to the dialog container', () => { + const containerFocusRef = React.createRef(); + const { view } = renderView({ containerFocusRef }); + + expect(containerFocusRef.current).toBe(view.getByRole('dialog')); + }); +}); diff --git a/packages/gamut/src/Modals/__tests__/Dialog.test.tsx b/packages/gamut/src/Modals/__tests__/Dialog.test.tsx deleted file mode 100644 index 49cc0f6a85..0000000000 --- a/packages/gamut/src/Modals/__tests__/Dialog.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent, screen } from '@testing-library/dom'; -import * as React from 'react'; - -import { Dialog } from '../Dialog'; - -const onRequestClose = jest.fn(); -const onConfirm = jest.fn(); -const onCancel = jest.fn(); - -const defaultProps = { - isOpen: true, - title: 'Hello world', - children: 'Close me please', - onRequestClose, - confirmCta: { - children: 'Confirm', - onClick: onConfirm, - }, - cancelCta: { - children: 'Cancel', - onClick: onCancel, - }, -}; - -const renderView = setupRtl(Dialog, defaultProps); - -describe('Dialog', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('renders children structured content when isOpen is true', () => { - const { view } = renderView(); - - view.getByText(defaultProps.title); - view.getByText(defaultProps.children); - }); - - it('does not render when isOpen is false', () => { - const { view } = renderView({ isOpen: false }); - expect(view.queryByRole('dialog')).toBe(null); - }); - - it('requests closing the dialog when the close button is clicked', () => { - const { view } = renderView(); - const ariaLabel = 'Close dialog'; - - fireEvent.click(view.getByLabelText(ariaLabel)); - expect(onRequestClose.mock.calls.length).toBe(1); - }); - - it('triggers onRequestClose callback when a confirm CTA is clicked', () => { - const { view } = renderView(); - - fireEvent.click(view.getByText(defaultProps.confirmCta.children)); - expect(onRequestClose.mock.calls.length).toBe(1); - expect(onConfirm.mock.calls.length).toBe(1); - }); - - it('triggers onRequestClose callback when a cancel CTA is clicked is clicked', () => { - const { view } = renderView(); - - fireEvent.click(view.getByText(defaultProps.cancelCta.children)); - expect(onRequestClose.mock.calls.length).toBe(1); - expect(onCancel.mock.calls.length).toBe(1); - }); - - it('does not trigger onRequestClose callback when clicking the dialog', () => { - renderView({ - isOpen: true, - onRequestClose, - }); - - fireEvent.mouseDown(screen.getByRole('dialog')); - expect(onRequestClose.mock.calls.length).toBe(0); - }); - - describe('closeButtonProps functionality', () => { - it('uses default tooltip text when closeButtonProps.tip is not provided', () => { - const { view } = renderView(); - - view.getByRole('button', { name: 'Close dialog' }); - }); - - it('applies a ref to the close button when closeButtonProps.ref is provided', () => { - const closeButtonRef = React.createRef(); - const { view } = renderView({ - closeButtonProps: { ref: closeButtonRef }, - }); - - const closeButton = view.getByRole('button', { name: 'Close dialog' }); - expect(closeButtonRef.current).toBe(closeButton); - }); - - it('uses custom tooltip text when closeButtonProps.tip is provided', () => { - const customTip = 'Custom close tooltip'; - const { view } = renderView({ - closeButtonProps: { tip: customTip }, - }); - - const closeButton = view.getByRole('button', { name: customTip }); - expect(closeButton).toHaveAttribute('aria-label', customTip); - }); - - it('disables the close button when closeButtonProps.disabled is true', () => { - const { view } = renderView({ - closeButtonProps: { disabled: true }, - }); - - const closeButton = view.getByRole('button', { name: 'Close dialog' }); - expect(closeButton).toBeDisabled(); - }); - - it('enables the close button when closeButtonProps.disabled is false', () => { - const { view } = renderView({ - closeButtonProps: { disabled: false }, - }); - - const closeButton = view.getByRole('button', { name: 'Close dialog' }); - expect(closeButton).not.toBeDisabled(); - }); - - it('enables the close button by default when closeButtonProps.disabled is not provided', () => { - const { view } = renderView(); - - const closeButton = view.getByRole('button', { name: 'Close dialog' }); - expect(closeButton).not.toBeDisabled(); - }); - }); - describe('containerFocusRef functionality', () => { - it('applies a ref to the dialog container when containerFocusRef is provided', () => { - const containerFocusRef = React.createRef(); - const { view } = renderView({ - containerFocusRef, - }); - - const dialogContainer = view.getByRole('dialog'); - expect(containerFocusRef.current).toBe(dialogContainer); - }); - }); -}); diff --git a/packages/gamut/tsconfig.json b/packages/gamut/tsconfig.json index eaab8c8485..053bf0e956 100644 --- a/packages/gamut/tsconfig.json +++ b/packages/gamut/tsconfig.json @@ -14,7 +14,11 @@ "strictFunctionTypes": false, "strictPropertyInitialization": false, "noUnusedParameters": false, - "noUnusedLocals": true + "noUnusedLocals": true, + "paths": { + "~storybook/blocks": ["../.storybook/components/"], + "~storybook/argTypes": ["../.storybook/argTypes/"] + } }, "files": [], "include": [], @@ -24,6 +28,9 @@ }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.storybook.json" } ] } diff --git a/packages/gamut/tsconfig.lib.json b/packages/gamut/tsconfig.lib.json index 03b140a47b..11623dfcac 100644 --- a/packages/gamut/tsconfig.lib.json +++ b/packages/gamut/tsconfig.lib.json @@ -20,7 +20,11 @@ "**/*.spec.js", "**/*.test.js", "**/*.spec.jsx", - "**/*.test.jsx" + "**/*.test.jsx", + "**/*.stories.ts", + "**/*.stories.tsx", + "**/*.stories.js", + "**/*.stories.jsx" ], "include": [ "src/**/*.ts", diff --git a/packages/gamut/tsconfig.storybook.json b/packages/gamut/tsconfig.storybook.json new file mode 100644 index 0000000000..1008e69ad6 --- /dev/null +++ b/packages/gamut/tsconfig.storybook.json @@ -0,0 +1,32 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "emitDecoratorMetadata": true, + "outDir": "", + "moduleResolution": "bundler", + "lib": ["dom", "dom.iterable", "ES2021", "ES2021.String"] + }, + "files": [ + "../../node_modules/@nx/react/typings/styled-jsx.d.ts", + "../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../node_modules/@nx/react/typings/image.d.ts" + ], + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.jsx", + "src/**/*.test.jsx", + "src/**/*.spec.js", + "src/**/*.test.js" + ], + "include": [ + "src/**/*.stories.ts", + "src/**/*.stories.js", + "src/**/*.stories.jsx", + "src/**/*.stories.tsx", + "../../packages/gamut-styles/dist/typings/theme.d.ts" + ] +} diff --git a/packages/styleguide/src/lib/Molecules/Modals/Dialog/Dialog.mdx b/packages/styleguide/src/lib/Molecules/Modals/Dialog/Dialog.mdx deleted file mode 100644 index 14cadf5614..0000000000 --- a/packages/styleguide/src/lib/Molecules/Modals/Dialog/Dialog.mdx +++ /dev/null @@ -1,114 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as DialogStories from './Dialog.stories'; - -export const parameters = { - title: 'Dialog', - subtitle: `Structured dialog modals with binary options.`, - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=2449%3A3770', - }, - status: 'updating', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Modals/Dialog.tsx', - }, -}; - - - - - -## Usage - -- Dialogs have a width of 400px in the `small` size, and 540px in `medium`. - - Small is the default style, and works best with 1-2 lines of text. - - The Medium size is suitable for Dialogs that require more content or explanation. - - The `fluid` option fits to the maximum size of the components inside of it. This is useful when you want to incorporate elements beyond standard typography inside of your Dialog. -- Dialog height changes depending on the body-text length. -- Dialog has light and dark theme variants that respond to the appropriate Color Mode. - -### Best practices: - -- Dialogs should be centered inside of the window. -- Dialogs appear unprompted and require confirmation before the user can continue. -- Use the modal component sparingly. This is the most dispruptive tool in our arsenal. We should only have a small handful of these in our system at any given time. -- Dialogs should always be displayed over an Overlay. Use the Overlay component in Gamut (and Figma) to capture the appropriate background opacity. - - The overlay in light mode should be white at 95% opacity, the overlay in dark mode should be black at 75% opacity. - -## Variants - -### Primary - -Use a default Dialog to have users accept a specific state of the system or force a user to see something. - - - -### Danger - -Use the `danger` variant to create warning Dialogs to warn the user of the consequences of the action they may take. This is helpful for cases like "Delete Account" or "Reset Progress" to make sure users confirm those types of actions. - -- Warning Dialogs should use the Danger action colors, and will always be paired with a Cancel button. - - - -## Color modes - -Dialog components respond to the current Color Mode they are used in. Each component inside of dialog will correctly display for the current color mode without any extra configuration. -Check it out: - -### Light - - - -### Dark - - - -## Close behavior - -### Close button customization - -Similar to `Modal`, the `Dialog` component's close button can be customized through the `closeButtonProps` object: - -- `hidden?: boolean` - Whether to hide the default close button and pass your own through children to close the modal -- `ref?: Ref` - An optional ref to be passed to the close button for programmatic access -- `tip?: string` - The close button tooltip text (defaults to "Close dialog") -- `disabled?: boolean` - Whether to disable the default close button - -This is useful when you need to programmatically interact with the close button, such as blurring focus or checking its state. - - - -If the close button is hidden, you will need to create a custom way to close the Dialog. You can then use the `isOpen` prop to create other controls that toggle the Dialog on and off. - - - -## Focus management - -The `containerFocusRef` prop allows you to programmatically control focus on the Dialog container. This is useful for advanced focus management scenarios where you need to override the default focus behavior (the Dialog has `data-autofocus` by default). - - - -## Playground - - - - - -## UX writing - -Simplify the language, prioritize the message, and make sure the implication of what learners are saying “Yes” (or “No”) to is crystal clear. Refer to the checklist below for guidance. For more tips and best practices, check out the full guide about writing for confirmation dialogs. - -- Keep the language consistent between buttons, headlines, and explanations. -- Make sure the action is clear from the headline. -- Frame the headline as a question, if possible. -- Provide all relevant details and consequences. -- Keep explanation text to 1-2 lines, if possible. -- Ensure that buttons are clear and distinct, with context to reaffirm the action. -- Keep copy at a reading level of grade 7 or below. Test with [Hemingway App](https://hemingwayapp.com/). -- Ask someone unrelated to the project to read the message to see if it makes sense. diff --git a/packages/styleguide/src/lib/Molecules/Modals/Dialog/Dialog.stories.tsx b/packages/styleguide/src/lib/Molecules/Modals/Dialog/Dialog.stories.tsx deleted file mode 100644 index d8df8de910..0000000000 --- a/packages/styleguide/src/lib/Molecules/Modals/Dialog/Dialog.stories.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { - Box, - Dialog, - FillButton, - FlexBox, - StrokeButton, - Text, -} from '@skillsoft/gamut'; -import { ColorMode } from '@skillsoft/gamut-styles'; -import type { Meta } from '@storybook/react'; -import { ComponentProps, useEffect, useRef, useState } from 'react'; -import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; - -import { closeButtonPropsArgTypes } from '~styleguide/argTypes'; - -const meta: TypeWithDeepControls> = { - component: Dialog, - args: { - title: 'Depeche Modal', - children: 'All I ever wanted, all I ever needed is here in my', - confirmCta: { - children: 'Arms!', - href: '#', - onClick: () => { - // eslint-disable-next-line no-console - console.log('confirm'); - }, - }, - cancelCta: { - children: 'Heart?', - href: '#', - onClick: () => { - // eslint-disable-next-line no-console - console.log('cancel'); - }, - }, - }, - argTypes: { - variant: { - control: 'radio', - options: ['primary', 'danger'], - }, - ...closeButtonPropsArgTypes({ - defaultTipText: 'Close dialog', - defaultTipAlignment: 'top-center', - }), - }, -}; - -export default meta; - -export const Default: React.FC> = (args) => { - const [isOpen, setIsOpen] = useState(args.isOpen); - - useEffect(() => { - setIsOpen(args.isOpen); - }, [args.isOpen]); - - return ( - <> - setIsOpen(true)}>Open Dialog - setIsOpen(false)} - /> - - ); -}; - -export const Danger: React.FC> = (args) => { - const [isOpen, setIsOpen] = useState(args.isOpen); - - useEffect(() => { - setIsOpen(args.isOpen); - }, [args.isOpen]); - - return ( - <> - setIsOpen(true)}>Open Dialog - setIsOpen(false)} - /> - - ); -}; - -export const DarkMode: React.FC> = (args) => { - const [isOpen, setIsOpen] = useState(args.isOpen); - - useEffect(() => { - setIsOpen(args.isOpen); - }, [args.isOpen]); - - return ( - - setIsOpen(true)}>Open Dialog - setIsOpen(false)} - /> - - ); -}; - -export const CloseButtonCustomization: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); - const [isDisabled, setIsDisabled] = useState(false); - const closeButtonRef = useRef(null); - - const handleFocusCloseButton = () => { - closeButtonRef.current?.focus(); - }; - - return ( - <> - - - setIsOpen(true)}> - Open Dialog with Custom Close Button - - - - setIsOpen(false)} - > - - - This dialog has a customized close button with a ref for - programmatic focus management, a custom tooltip, and a disabled - state. - - - Focus Close Button - - setIsDisabled(!isDisabled)}> - {isDisabled ? 'Enable' : 'Disable'} Focus Close Button - - - - - ); -}; - -export const CustomClose: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); - return ( - <> - setIsOpen(true)}>Open Dialog - setIsOpen(false)} - > - - - Missing a close button? - - - setIsOpen(false)}> - No problem, click me! - - - - - - ); -}; - -export const FocusManagement: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); - const containerFocusRef = useRef(null); - - const handleFocusDialog = () => { - containerFocusRef.current?.focus(); - }; - - return ( - <> - setIsOpen(true)}> - Open Dialog with Focus Control - - setIsOpen(false)} - > - - - This dialog container has a ref that you can interact with - programmatically. - - - - Focus Dialog Container - - - - Try tabbing through the page - the dialog container will maintain - focus when you click the "Focus Dialog Container" button. - - - - - ); -};