diff --git a/.changeset/organisms-stories-move.md b/.changeset/organisms-stories-move.md new file mode 100644 index 0000000000..4a012a27d2 --- /dev/null +++ b/.changeset/organisms-stories-move.md @@ -0,0 +1,4 @@ +--- +--- + +Migrate Organisms and Video stories into packages/gamut and move their Jest tests into story plays. No published code changes. diff --git a/packages/.storybook/components/Elements/DocsPage.tsx b/packages/.storybook/components/Elements/DocsPage.tsx index 8ba1ef8a41..7c1bc2974a 100644 --- a/packages/.storybook/components/Elements/DocsPage.tsx +++ b/packages/.storybook/components/Elements/DocsPage.tsx @@ -15,10 +15,15 @@ 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. + * + * Set `parameters.docs.propsTable: false` on a meta to leave out the props + * table, e.g. when several story files document one component and only one + * of them should show it. */ export const DocsPage: React.FC = () => { const { csfFile, preparedMeta } = useOf('meta', ['meta']); const isSingleStory = Object.keys(csfFile.stories).length === 1; + const showPropsTable = preparedMeta.parameters.docs?.propsTable !== false; const systemProps = Object.entries(preparedMeta.argTypes) .filter(([, argType]) => isSystemPropCategory(argType.table?.category)) .map(([name]) => name); @@ -30,24 +35,26 @@ export const DocsPage: React.FC = () => { {isSingleStory && } - {systemProps.length > 0 ? ( - - - Props - System props - - - - - - - - - - - ) : ( - - )} + {showPropsTable ? ( + systemProps.length > 0 ? ( + + + Props + System props + + + + + + + + + + + ) : ( + + ) + ) : null} {!isSingleStory && } ); diff --git a/packages/gamut/src/BarChart/BarChart.stories.tsx b/packages/gamut/src/BarChart/BarChart.stories.tsx new file mode 100644 index 0000000000..c78d323f66 --- /dev/null +++ b/packages/gamut/src/BarChart/BarChart.stories.tsx @@ -0,0 +1,771 @@ +import { + BookFlipPageIcon, + DataScienceIcon, + TerminalIcon, +} from '@skillsoft/gamut-icons'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { MouseEvent } from 'react'; +import { expect, fn, waitFor, within } from 'storybook/test'; + +import { Box } from '../Box'; +import type { BarProps, PartialBarChartTranslations } from '.'; +import { BarChart } from '.'; + +const onBarClick = fn(); + +const meta: Meta = { + title: 'Data display/BarChart', + component: BarChart, + args: { + description: 'Chart showing programming language experience levels', + maxScaleValue: 2000, + title: 'Skills experience chart', + unit: 'XP', + }, +}; + +export default meta; +type Story = StoryObj; + +const simpleBarData: BarProps[] = [ + { categoryLabel: 'Python', seriesOneValue: 1500 }, + { categoryLabel: 'JavaScript', seriesOneValue: 2000 }, + { categoryLabel: 'HTML/CSS', seriesOneValue: 800 }, + { categoryLabel: 'SQL', seriesOneValue: 600 }, + { categoryLabel: 'React', seriesOneValue: 450 }, +]; + +const stackedBarData: BarProps[] = [ + { categoryLabel: 'Python', seriesOneValue: 200, seriesTwoValue: 1500 }, + { + categoryLabel: 'JavaScript', + icon: TerminalIcon, + seriesOneValue: 1800, + seriesTwoValue: 2000, + }, + { categoryLabel: 'HTML/CSS', seriesOneValue: 600, seriesTwoValue: 800 }, + { categoryLabel: 'SQL', seriesOneValue: 550, seriesTwoValue: 600 }, + { categoryLabel: 'React', seriesOneValue: 300, seriesTwoValue: 450 }, +]; + +const accessibilityBarData: BarProps[] = [ + { categoryLabel: 'Python', seriesOneValue: 200, seriesTwoValue: 1500 }, + { + categoryLabel: 'JavaScript', + seriesOneValue: 1800, + seriesTwoValue: 2000, + href: '/javascript', + }, + { categoryLabel: 'HTML/CSS', seriesOneValue: 600, seriesTwoValue: 800 }, + { categoryLabel: 'SQL', seriesOneValue: 550, href: '/sql' }, + { categoryLabel: 'Rust', seriesOneValue: 400 }, + { categoryLabel: 'React', seriesOneValue: 300, seriesTwoValue: 450 }, +]; + +const accessibilityTranslations: PartialBarChartTranslations = { + accessibility: { + stackedBarSummary: (ctx) => + `${ctx.seriesOneValue} ${ctx.unit} gained — now at ${ctx.seriesTwoValue} ${ctx.unit} in ${ctx.categoryLabel}`, + singleValueBarSummary: (ctx) => + `${ctx.value} ${ctx.unit} in ${ctx.categoryLabel}`, + }, + locale: 'en', +}; + +const barDataWithIcons: BarProps[] = [ + { + categoryLabel: 'Python', + seriesOneValue: 200, + seriesTwoValue: 1500, + icon: TerminalIcon, + }, + { + categoryLabel: 'JavaScript', + seriesOneValue: 150, + seriesTwoValue: 2000, + icon: TerminalIcon, + }, + { + categoryLabel: 'Data Science', + seriesOneValue: 100, + seriesTwoValue: 800, + icon: DataScienceIcon, + }, + { + categoryLabel: 'Backend', + seriesOneValue: 50, + seriesTwoValue: 600, + icon: TerminalIcon, + }, + { + categoryLabel: 'Reading', + seriesOneValue: 75, + seriesTwoValue: 450, + icon: BookFlipPageIcon, + }, +]; + +const customSortingBarValues = [ + { + categoryLabel: 'Python', + seriesOneValue: 1500, + dateAdded: new Date('2023-01-15'), + }, + { + categoryLabel: 'JavaScript', + seriesOneValue: 2000, + dateAdded: new Date('2023-03-20'), + }, + { + categoryLabel: 'React', + seriesOneValue: 450, + dateAdded: new Date('2023-06-10'), + }, + { + categoryLabel: 'TypeScript', + seriesOneValue: 300, + dateAdded: new Date('2023-08-05'), + }, + { + categoryLabel: 'SQL', + seriesOneValue: 600, + dateAdded: new Date('2023-02-28'), + }, +]; + +type DatedBar = (typeof customSortingBarValues)[number]; + +const expectRowOrder = async (canvasElement: HTMLElement, labels: string[]) => { + const items = within(canvasElement).getAllByRole('listitem'); + await expect(items).toHaveLength(labels.length); + for (const [index, label] of labels.entries()) { + await expect(items[index]).toHaveTextContent(label); + } +}; + +export const Default: Story = { + args: { + barValues: simpleBarData, + }, + play: async ({ canvas, canvasElement, userEvent }) => { + canvas.getByRole('figure'); + await expect( + canvas.getByRole('heading', { name: 'Skills experience chart' }).tagName + ).toBe('H2'); + canvas.getByRole('list', { name: 'Skills experience chart' }); + await expect( + canvas.getByText('Chart showing programming language experience levels') + .tagName + ).toBe('FIGCAPTION'); + await expect(canvas.queryByLabelText('Order by:')).toBeNull(); + + const items = canvas.getAllByRole('listitem'); + await expect(items).toHaveLength(5); + await expect(items[0]).toHaveTextContent('1500 XP in Python'); + await expect(items[0]).toHaveTextContent('1,500 XP'); + await expect(items[2]).toHaveTextContent('800 XP in HTML/CSS'); + + await expect(canvas.getAllByTestId('background-bar')).toHaveLength(5); + await expect(canvas.queryAllByTestId('foreground-bar')).toHaveLength(0); + + const [, fullBar, htmlBar] = canvas.getAllByTestId('background-bar'); + const trackWidth = fullBar.parentElement!.getBoundingClientRect().width; + await expect( + fullBar.getBoundingClientRect().width / trackWidth + ).toBeCloseTo(1, 1); + await expect( + htmlBar.getBoundingClientRect().width / trackWidth + ).toBeCloseTo(0.4, 1); + + const barLefts = canvas + .getAllByTestId('background-bar') + .map((bar) => bar.getBoundingClientRect().left); + await expect(new Set(barLefts).size).toBe(1); + + for (const tick of ['0', '400', '800', '1.2K', '1.6K', '2K']) { + await expect(canvas.getByText(tick)).toBeVisible(); + } + const [, pythonSideLabel] = within(items[0]).getAllByText('Python'); + await expect(pythonSideLabel).toBeVisible(); + + await expectRowOrder(canvasElement, [ + 'Python', + 'JavaScript', + 'HTML/CSS', + 'SQL', + 'React', + ]); + + await userEvent.tab(); + await expect(canvasElement).not.toContainElement( + document.activeElement as HTMLElement + ); + }, +}; + +export const NarrowContainer: Story = { + args: { + barValues: simpleBarData, + title: 'Skills chart in a narrow container', + description: 'Labels and values sit above each bar below 480px', + }, + render: (args) => ( + + + + ), + play: async ({ canvas }) => { + await expect(canvas.getByText('2K')).not.toBeVisible(); + + const [python] = canvas.getAllByRole('listitem'); + const [stackedLabel, sideLabel] = within(python).getAllByText('Python'); + await expect(stackedLabel).toBeVisible(); + await expect(sideLabel).not.toBeVisible(); + await expect(within(python).getAllByText('1,500 XP')[0]).toBeVisible(); + }, +}; + +export const ValuesOutsideScale: Story = { + args: { + barValues: [ + { categoryLabel: 'Python', seriesOneValue: 2500 }, + { categoryLabel: 'SQL', seriesOneValue: 1000 }, + { categoryLabel: 'Rust', seriesOneValue: 0 }, + ], + title: 'Skills chart with out-of-range values', + description: 'Values above the scale fill the bar; zero still shows a bar', + }, + play: async ({ canvas }) => { + const bars = canvas.getAllByTestId('background-bar'); + const trackWidth = bars[0].parentElement!.getBoundingClientRect().width; + const ratios = bars.map( + (bar) => bar.getBoundingClientRect().width / trackWidth + ); + + await expect(ratios[0]).toBeCloseTo(1, 1); + await expect(ratios[1]).toBeCloseTo(0.5, 1); + await expect(ratios[2]).toBeCloseTo(0.08, 1); + await expect(canvas.getAllByRole('listitem')[0]).toHaveTextContent( + '2,500 XP' + ); + }, +}; + +export const WithoutUnit: Story = { + args: { + barValues: simpleBarData, + unit: '', + title: 'Skills chart without a unit', + description: 'Values render as plain numbers', + }, + play: async ({ canvas }) => { + const [python] = canvas.getAllByRole('listitem'); + await expect(within(python).getByText('1500 in Python').textContent).toBe( + '1500 in Python' + ); + await expect(within(python).getAllByText('1,500')[0].textContent).toBe( + '1,500' + ); + await expect(python).not.toHaveTextContent('XP'); + }, +}; + +export const Stacked: Story = { + args: { + barValues: stackedBarData, + title: 'Skills progress chart', + description: 'Progress toward total goals for each programming language', + }, + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId('background-bar')).toHaveLength(5); + await expect(canvas.getAllByTestId('foreground-bar')).toHaveLength(5); + + const [python, javascript] = canvas.getAllByRole('listitem'); + await expect(python).toHaveTextContent( + 'Starting value - 200 XP. 1300 XP gained - now at 1500 XP in Python' + ); + await expect(python).toHaveTextContent('200 XP'); + await expect(python).toHaveTextContent('1,500 XP'); + + for (const label of within(javascript).getAllByText('JavaScript')) { + await expect(label.previousElementSibling?.tagName).toBe('svg'); + } + for (const label of within(python).getAllByText('Python')) { + await expect(label.previousElementSibling).toBeNull(); + } + }, +}; + +export const WithIcons: Story = { + args: { + barValues: barDataWithIcons, + title: 'Skills progress with icons', + description: 'Skills progress with visual icons for each category', + }, +}; + +export const Animated: Story = { + args: { + barValues: stackedBarData, + animate: true, + title: 'Animated skills chart', + description: 'Animated chart showing progress with entrance animations', + }, + play: async ({ canvas }) => { + const [pythonTotal] = canvas.getAllByTestId('background-bar'); + const [pythonProgress] = canvas.getAllByTestId('foreground-bar'); + + await waitFor(() => expect(pythonTotal.style.width).toBe('75%')); + await waitFor(() => expect(pythonProgress.style.width).toBe('10%')); + }, +}; + +export const Interactive: Story = { + args: { + barValues: simpleBarData.map((bar) => ({ ...bar, onClick: onBarClick })), + title: 'Interactive skills chart', + description: 'Click on any row to view detailed course information', + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.getAllByRole('button')).toHaveLength(5); + + const python = canvas.getByRole('button', { name: '1500 XP in Python' }); + await expect(canvas.getAllByRole('listitem')[0]).not.toHaveTextContent( + '1500 XP in Python' + ); + + await userEvent.click(python); + await expect(onBarClick).toHaveBeenCalledTimes(1); + + python.focus(); + await userEvent.keyboard('{Enter}'); + await expect(onBarClick).toHaveBeenCalledTimes(2); + }, +}; + +export const WithLinks: Story = { + args: { + barValues: simpleBarData.map((bar) => ({ + ...bar, + href: `#${bar.categoryLabel.toLowerCase().replace(/\s+/g, '-')}`, + })), + sortFns: ['none', 'alphabetically'], + title: 'Skills chart with links', + description: 'Each row links to its corresponding course page', + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.getAllByRole('link')).toHaveLength(5); + await expect(canvas.queryByRole('button')).toBeNull(); + await expect( + canvas.getByRole('link', { name: '1500 XP in Python' }) + ).toHaveAttribute('href', '#python'); + await expect(canvas.getAllByRole('listitem')[0]).not.toHaveTextContent( + '1500 XP in Python' + ); + + const select = canvas.getByLabelText('Order by:'); + await userEvent.selectOptions(select, 'Label (Z-A)'); + select.focus(); + for (const name of [ + '600 XP in SQL', + '450 XP in React', + '1500 XP in Python', + '2000 XP in JavaScript', + '800 XP in HTML/CSS', + ]) { + await userEvent.tab(); + await expect(canvas.getByRole('link', { name })).toHaveFocus(); + } + }, +}; + +export const WithLinksAndClickHandlers: Story = { + args: { + barValues: simpleBarData.map((bar) => ({ + ...bar, + href: `#${bar.categoryLabel.toLowerCase()}`, + onClick: (event: MouseEvent) => { + event.preventDefault(); + onBarClick(bar.categoryLabel); + }, + })), + title: 'Skills chart with tracked links', + description: 'Each row is a link that also runs a click handler', + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.queryByRole('button')).toBeNull(); + + await userEvent.click(canvas.getByRole('link', { name: '600 XP in SQL' })); + await expect(onBarClick).toHaveBeenCalledWith('SQL'); + }, +}; + +export const WithVisualTitleAndDescription: Story = { + args: { + barValues: simpleBarData, + title: 'Programming Skills Overview', + description: + 'Experience points earned across different programming languages', + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('heading', { name: 'Programming Skills Overview' }) + ).toBeVisible(); + await expect( + canvas.getByText( + 'Experience points earned across different programming languages' + ) + ).toBeVisible(); + }, +}; + +export const WithHiddenTitleAndSorting: Story = { + args: { + barValues: simpleBarData, + hideTitle: true, + sortFns: ['numerically', 'none'], + title: 'Programming Skills Overview', + }, + play: async ({ canvas, canvasElement, userEvent }) => { + await expect( + canvas.getByText('Programming Skills Overview') + ).toHaveAttribute('hidden'); + canvas.getByRole('list', { name: 'Programming Skills Overview' }); + + const select = canvas.getByLabelText('Order by:'); + await expect(select).toBeVisible(); + await userEvent.selectOptions(select, 'Value (Low-High)'); + await expectRowOrder(canvasElement, [ + 'React', + 'SQL', + 'HTML/CSS', + 'Python', + 'JavaScript', + ]); + }, +}; + +export const WithHiddenTitleAndDescription: Story = { + args: { + barValues: simpleBarData, + description: + 'Experience points earned across different programming languages', + hideDescription: true, + hideTitle: true, + title: 'Programming Skills Overview', + }, + play: async ({ canvas }) => { + await expect( + canvas.getByText('Programming Skills Overview') + ).toHaveAttribute('hidden'); + await expect( + canvas.getByText( + 'Experience points earned across different programming languages' + ) + ).toHaveAttribute('hidden'); + canvas.getByRole('list', { name: 'Programming Skills Overview' }); + }, +}; + +export const WithTitleHeadingLevel: Story = { + args: { + barValues: simpleBarData, + title: { as: 'h3', title: 'Programming Skills Overview' }, + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('heading', { name: 'Programming Skills Overview' }) + .tagName + ).toBe('H3'); + canvas.getByRole('list', { name: 'Programming Skills Overview' }); + }, +}; + +export const WithExternalTitle: Story = { + render: () => ( + <> + + Programming Skills Overview + + + + ), + play: async ({ canvas }) => { + canvas.getByRole('list', { name: 'Programming Skills Overview' }); + await expect(canvas.getAllByRole('heading')).toHaveLength(1); + }, +}; + +export const WithSorting: Story = { + args: { + barValues: simpleBarData, + sortFns: ['alphabetically', 'numerically', 'none'], + title: 'Skills experience chart', + description: 'Use the dropdown to sort bars by different criteria', + }, + play: async ({ canvas, canvasElement, userEvent }) => { + const select = canvas.getByLabelText('Order by:'); + await expect(select).toHaveValue('none'); + await expectRowOrder(canvasElement, [ + 'Python', + 'JavaScript', + 'HTML/CSS', + 'SQL', + 'React', + ]); + + await userEvent.selectOptions(select, 'Value (Low-High)'); + await expectRowOrder(canvasElement, [ + 'React', + 'SQL', + 'HTML/CSS', + 'Python', + 'JavaScript', + ]); + + await userEvent.selectOptions(select, 'Value (High-Low)'); + await expectRowOrder(canvasElement, [ + 'JavaScript', + 'Python', + 'HTML/CSS', + 'SQL', + 'React', + ]); + + await userEvent.selectOptions(select, 'Label (A-Z)'); + await expectRowOrder(canvasElement, [ + 'HTML/CSS', + 'JavaScript', + 'Python', + 'React', + 'SQL', + ]); + + await userEvent.selectOptions(select, 'Label (Z-A)'); + await expectRowOrder(canvasElement, [ + 'SQL', + 'React', + 'Python', + 'JavaScript', + 'HTML/CSS', + ]); + + await userEvent.selectOptions(select, 'None'); + await expectRowOrder(canvasElement, [ + 'Python', + 'JavaScript', + 'HTML/CSS', + 'SQL', + 'React', + ]); + }, +}; + +export const WithSortingWithoutNone: Story = { + args: { + barValues: simpleBarData, + sortFns: ['alphabetically'], + title: 'Skills experience chart', + description: 'Sorted alphabetically from the first render', + }, + play: async ({ canvas, canvasElement }) => { + await expect(canvas.getByLabelText('Order by:')).toHaveValue('label-asc'); + await expect(canvas.queryByRole('option', { name: 'None' })).toBeNull(); + await expectRowOrder(canvasElement, [ + 'HTML/CSS', + 'JavaScript', + 'Python', + 'React', + 'SQL', + ]); + }, +}; + +export const WithCustomSorting: Story = { + args: { + barValues: customSortingBarValues, + sortFns: [ + 'none', + { + label: 'Recently Added', + value: 'recent', + sortFn: (bars: DatedBar[]) => + [...bars].sort( + (a, b) => b.dateAdded.getTime() - a.dateAdded.getTime() + ), + }, + { + label: 'Oldest First', + value: 'oldest', + sortFn: (bars: DatedBar[]) => + [...bars].sort( + (a, b) => a.dateAdded.getTime() - b.dateAdded.getTime() + ), + }, + ], + title: 'Skills chart with date sorting', + description: + 'Custom sort functions can access additional properties on BarProps, such as dates', + }, + play: async ({ canvas, canvasElement, userEvent }) => { + const select = canvas.getByLabelText('Order by:'); + + await userEvent.selectOptions(select, 'Recently Added'); + await expectRowOrder(canvasElement, [ + 'TypeScript', + 'React', + 'JavaScript', + 'SQL', + 'Python', + ]); + + await userEvent.selectOptions(select, 'Oldest First'); + await expectRowOrder(canvasElement, [ + 'Python', + 'SQL', + 'JavaScript', + 'React', + 'TypeScript', + ]); + }, +}; + +export const CustomStyles: Story = { + args: { + barValues: stackedBarData, + styleConfig: { + seriesTwoBarColor: 'text', + seriesOneBarColor: 'primary', + textColor: 'primary', + seriesOneLabel: 'feedback-error', + seriesTwoLabel: 'feedback-success', + }, + title: 'Custom styled skills chart', + description: 'Custom color scheme applied to chart elements', + }, +}; + +export const CustomScale: Story = { + args: { + barValues: simpleBarData, + maxScaleValue: 2000, + scaleInterval: 250, + title: 'Skills chart with custom scale', + description: 'Custom scale intervals for more granular value display', + }, + play: async ({ canvas }) => { + ['0', '250', '500', '750', '1K', '1.3K', '1.5K', '1.8K', '2K'].forEach( + (tick) => canvas.getByText(tick) + ); + await expect(canvas.queryByText('400')).toBeNull(); + }, +}; + +export const WithStringTranslations: Story = { + args: { + barValues: stackedBarData, + sortFns: ['alphabetically', 'numerically', 'none'], + title: 'Gráfico de habilidades', + description: 'Progreso hacia los objetivos totales por lenguaje', + unit: 'XP', + translations: { + locale: 'es', + sortLabel: 'Ordenar por:', + sortOptions: { + none: 'Ninguno', + labelAsc: 'Etiqueta (A-Z)', + labelDesc: 'Etiqueta (Z-A)', + valueAsc: 'Valor (Bajo-Alto)', + valueDesc: 'Valor (Alto-Bajo)', + }, + accessibility: { + stackedBarSummary: (ctx) => + `Valor inicial - ${ctx.seriesOneValue} ${ctx.unit}. ${ctx.gained} ${ctx.unit} ganado - ahora en ${ctx.seriesTwoValue} ${ctx.unit} en ${ctx.categoryLabel}`, + singleValueBarSummary: (ctx) => + `${ctx.value} ${ctx.unit} en ${ctx.categoryLabel}`, + }, + }, + }, + play: async ({ canvas, canvasElement, userEvent }) => { + const select = canvas.getByLabelText('Ordenar por:'); + await expect(select).toHaveValue('none'); + canvas.getByRole('option', { name: 'Ninguno' }); + canvas.getByRole('option', { name: 'Valor (Alto-Bajo)' }); + + await expect(canvas.getAllByRole('listitem')[0]).toHaveTextContent( + 'Valor inicial - 200 XP. 1300 XP ganado - ahora en 1500 XP en Python' + ); + + await userEvent.selectOptions(select, 'Etiqueta (Z-A)'); + await expect(canvas.getAllByRole('listitem')[0]).toHaveTextContent( + 'ahora en 600 XP en SQL' + ); + + await userEvent.selectOptions(select, 'Valor (Alto-Bajo)'); + await expectRowOrder(canvasElement, [ + 'JavaScript', + 'Python', + 'HTML/CSS', + 'SQL', + 'React', + ]); + }, +}; + +export const WithPartialTranslations: Story = { + args: { + barValues: simpleBarData, + sortFns: ['alphabetically', 'none'], + title: 'Skills experience chart', + description: 'Only the sort label and number locale are overridden', + translations: { locale: 'de-DE', sortLabel: 'Sort by:' }, + }, + play: async ({ canvas }) => { + canvas.getByLabelText('Sort by:'); + await expect(canvas.queryByText('Order by:')).toBeNull(); + canvas.getByRole('option', { name: 'None' }); + canvas.getByRole('option', { name: 'Label (A-Z)' }); + + const [python] = canvas.getAllByRole('listitem'); + await expect(python).toHaveTextContent('1.500 XP'); + await expect(python).toHaveTextContent('1500 XP in Python'); + }, +}; + +export const WithFunctionTranslations: Story = { + args: { + barValues: accessibilityBarData, + description: + 'Custom aria-label summaries via translation functions (stacked, link, and non-interactive bars)', + title: 'Skills experience (accessibility functions)', + translations: accessibilityTranslations, + unit: 'XP', + }, + play: async ({ canvas }) => { + canvas.getByRole('link', { + name: '1800 XP gained — now at 2000 XP in JavaScript', + }); + canvas.getByRole('link', { name: '550 XP in SQL' }); + + const items = canvas.getAllByRole('listitem'); + await expect(items[0]).toHaveTextContent( + '200 XP gained — now at 1500 XP in Python' + ); + await expect(items[4]).toHaveTextContent('400 XP in Rust'); + }, +}; diff --git a/packages/gamut/src/BarChart/__tests__/BarChart.test.tsx b/packages/gamut/src/BarChart/__tests__/BarChart.test.tsx deleted file mode 100644 index c792d0f30c..0000000000 --- a/packages/gamut/src/BarChart/__tests__/BarChart.test.tsx +++ /dev/null @@ -1,731 +0,0 @@ -import { TerminalIcon } from '@skillsoft/gamut-icons'; -import { setupRtl } from '@skillsoft/gamut-tests'; -import userEvent from '@testing-library/user-event'; -import type { HTMLProps } from 'react'; -import { act } from 'react'; - -import type { ButtonProps } from '../../Button'; -import { BarChart } from '..'; -import type { BarProps } from '../shared/types'; - -const defaultBarValues = [ - { categoryLabel: 'Python', seriesOneValue: 100 }, - { categoryLabel: 'JavaScript', seriesOneValue: 75 }, -]; - -const renderView = setupRtl(BarChart, { - barValues: defaultBarValues, - description: 'Test chart description', - maxScaleValue: 200, - title: 'Test chart', -}); - -const createInteractiveBar = ({ - categoryLabel, - seriesOneValue, - href, - onClick, -}: Pick & { - href?: HTMLProps['href']; - onClick?: ButtonProps['onClick']; -}): BarProps => { - const base = { - categoryLabel, - seriesOneValue, - }; - - if (href) { - return { ...base, href, ...(onClick && { onClick }) }; - } - if (onClick) { - return { ...base, onClick }; - } - return base; -}; - -describe('BarChart', () => { - describe('Basic Rendering', () => { - it('renders a list with the correct number of list items', () => { - const { view } = renderView(); - - view.getByRole('list'); - const items = view.getAllByRole('listitem'); - expect(items).toHaveLength(2); - }); - - it('displays y-axis labels for each bar', () => { - const { view } = renderView(); - - const listItems = view.getAllByRole('listitem'); - expect(listItems[0]).toHaveTextContent('Python'); - expect(listItems[1]).toHaveTextContent('JavaScript'); - }); - - it('displays values with unit when provided', () => { - const { view } = renderView({ unit: 'XP' }); - - const listItems = view.getAllByRole('listitem'); - expect(listItems[0]).toHaveTextContent('100 XP'); - expect(listItems[1]).toHaveTextContent('75 XP'); - }); - }); - - describe('Stacked vs Non-stacked', () => { - it('renders only background bar when seriesTwoValue is not provided', () => { - const { view } = renderView(); - - const backgroundBars = view.getAllByTestId('background-bar'); - expect(backgroundBars).toHaveLength(2); - - expect(view.queryAllByTestId('foreground-bar')).toHaveLength(0); - }); - - it('renders both foreground and background bars when seriesTwoValue is provided', () => { - const stackedBarValues: BarProps[] = [ - { categoryLabel: 'Python', seriesOneValue: 50, seriesTwoValue: 100 }, - { categoryLabel: 'JavaScript', seriesOneValue: 25, seriesTwoValue: 75 }, - ]; - - const { view } = renderView({ barValues: stackedBarValues }); - - const backgroundBars = view.getAllByTestId('background-bar'); - const foregroundBars = view.getAllByTestId('foreground-bar'); - - expect(backgroundBars).toHaveLength(2); - expect(foregroundBars).toHaveLength(2); - }); - }); - - describe('Sorting', () => { - const unsortedBarValues: BarProps[] = [ - { categoryLabel: 'Python', seriesOneValue: 50 }, - { categoryLabel: 'JavaScript', seriesOneValue: 100 }, - { categoryLabel: 'Ruby', seriesOneValue: 25 }, - ]; - - it('does not render Select when sortFns is not provided', () => { - const { view } = renderView({ - barValues: unsortedBarValues, - }); - - expect(view.queryByLabelText('Order by:')).not.toBeInTheDocument(); - }); - - it('preserves array order by default when sortFns includes none', () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: ['none'], - }); - - const items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('Python'); - expect(items[1]).toHaveTextContent('JavaScript'); - expect(items[2]).toHaveTextContent('Ruby'); - }); - - it('sorts by value ascending when numerically is included and selected', async () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: ['numerically', 'none'], - }); - - const select = view.getByLabelText('Order by:'); - await act(async () => { - await userEvent.selectOptions(select, 'value-asc'); - }); - - const items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('Ruby'); - expect(items[1]).toHaveTextContent('Python'); - expect(items[2]).toHaveTextContent('JavaScript'); - }); - - it('sorts by value descending when numerically is included and selected', async () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: ['numerically', 'none'], - }); - - const select = view.getByLabelText('Order by:'); - await act(async () => { - await userEvent.selectOptions(select, 'value-desc'); - }); - - const items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('JavaScript'); - expect(items[1]).toHaveTextContent('Python'); - expect(items[2]).toHaveTextContent('Ruby'); - }); - - it('sorts by label ascending when alphabetically is included and selected', async () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: ['alphabetically', 'none'], - }); - - const select = view.getByLabelText('Order by:'); - await act(async () => { - await userEvent.selectOptions(select, 'label-asc'); - }); - - const items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('JavaScript'); - expect(items[1]).toHaveTextContent('Python'); - expect(items[2]).toHaveTextContent('Ruby'); - }); - - it('sorts by label descending when alphabetically is included and selected', async () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: ['alphabetically', 'none'], - }); - - const select = view.getByLabelText('Order by:'); - await act(async () => { - await userEvent.selectOptions(select, 'label-desc'); - }); - - const items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('Ruby'); - expect(items[1]).toHaveTextContent('Python'); - expect(items[2]).toHaveTextContent('JavaScript'); - }); - - it('returns to original order when none is selected', async () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: ['numerically', 'none'], - }); - - const select = view.getByLabelText('Order by:'); - await act(async () => { - await userEvent.selectOptions(select, 'value-asc'); - }); - - let items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('Ruby'); - - await act(async () => { - await userEvent.selectOptions(select, 'none'); - }); - - items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('Python'); - expect(items[1]).toHaveTextContent('JavaScript'); - expect(items[2]).toHaveTextContent('Ruby'); - }); - - it('works with custom sort functions', async () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: [ - 'none', - { - label: 'Reverse Order', - value: 'reverse', - sortFn: (bars) => [...bars].reverse(), - }, - ], - }); - - const select = view.getByLabelText('Order by:'); - await act(async () => { - await userEvent.selectOptions(select, 'reverse'); - }); - - const items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('Ruby'); - expect(items[1]).toHaveTextContent('JavaScript'); - expect(items[2]).toHaveTextContent('Python'); - }); - - it('defaults to first option when none is not included', () => { - const { view } = renderView({ - barValues: unsortedBarValues, - sortFns: ['alphabetically'], - }); - - const select = view.getByLabelText('Order by:') as HTMLSelectElement; - expect(select.value).toBe('label-asc'); - - const items = view.getAllByRole('listitem'); - expect(items[0]).toHaveTextContent('JavaScript'); - expect(items[1]).toHaveTextContent('Python'); - expect(items[2]).toHaveTextContent('Ruby'); - }); - }); - - describe('Interactivity', () => { - const createPythonBar = ( - options: { - onClick?: ButtonProps['onClick']; - href?: HTMLProps['href']; - } = {} - ): BarProps[] => { - const base = { - categoryLabel: 'Python', - seriesOneValue: 100, - }; - - if (options.href) { - return [ - { - ...base, - href: options.href, - ...(options.onClick && { onClick: options.onClick }), - }, - ]; - } - if (options.onClick) { - return [{ ...base, onClick: options.onClick }]; - } - return [base]; - }; - - it('renders a button when onClick is provided', () => { - const onClick = jest.fn(); - const { view } = renderView({ - barValues: createPythonBar({ onClick }), - unit: 'XP', - }); - - view.getByRole('button', { name: '100 XP in Python' }); - }); - - it('calls onClick when button is clicked', async () => { - const onClick = jest.fn(); - const { view } = renderView({ - barValues: createPythonBar({ onClick }), - unit: 'XP', - }); - - const button = view.getByRole('button', { - name: '100 XP in Python', - }); - - await userEvent.click(button); - - expect(onClick).toHaveBeenCalled(); - }); - - it('renders an anchor when href is provided', () => { - const { view } = renderView({ - barValues: createPythonBar({ href: '/python' }), - unit: 'XP', - }); - - const anchor = view.getByRole('link', { name: '100 XP in Python' }); - expect(anchor).toHaveAttribute('href', '/python'); - }); - - it('calls onClick when href and onClick are provided', async () => { - const onClick = jest.fn((e: { preventDefault(): void }) => - e.preventDefault() - ); - const { view } = renderView({ - barValues: createPythonBar({ href: '/python', onClick }), - unit: 'XP', - }); - - const anchor = view.getByRole('link', { - name: '100 XP in Python', - }); - expect(anchor).toHaveAttribute('href', '/python'); - - await userEvent.click(anchor); - - expect(onClick).toHaveBeenCalled(); - }); - }); - - describe('Accessibility', () => { - describe('Chart-level labeling', () => { - it('has title for chart labeling', () => { - const { view } = renderView({ title: 'Skills chart' }); - - view.getByText('Skills chart'); - view.getByRole('list'); - }); - - it('has aria-labelledby on the list element when provided', () => { - const { view } = renderView({ - 'aria-labelledby': 'chart-heading', - }); - - const heading = document.createElement('h2'); - heading.id = 'chart-heading'; - heading.textContent = 'Chart Heading'; - document.body.appendChild(heading); - - view.getByRole('list', { name: 'Chart Heading' }); - }); - }); - - describe('Semantic structure', () => { - describe('Figure and figcaption', () => { - it('renders a figure element', () => { - const { view } = renderView(); - - view.getByRole('figure'); - }); - - it('renders description in figcaption element', () => { - const { view } = renderView({ - description: 'This chart shows programming language experience', - }); - - const figcaption = view.getByText( - 'This chart shows programming language experience' - ); - expect(figcaption.tagName.toLowerCase()).toBe('figcaption'); - }); - - it('hides figcaption when hideDescription is true', () => { - const { view } = renderView({ - description: 'Hidden description', - hideDescription: true, - }); - - const figcaption = view.getByText('Hidden description'); - expect(figcaption).toHaveAttribute('hidden'); - }); - }); - - describe('Title', () => { - it('renders title as string with default h2 and title-xs variant', () => { - const { view } = renderView({ - title: 'Programming Skills Chart', - }); - - const title = view.getByRole('heading', { - name: 'Programming Skills Chart', - }); - expect(title.tagName).toBe('H2'); - }); - - it('renders title as object with custom heading level', () => { - const { view } = renderView({ - title: { as: 'h1', title: 'Skills Overview' }, - }); - - const title = view.getByRole('heading', { name: 'Skills Overview' }); - expect(title.tagName).toBe('H1'); - }); - - it('hides title when hideTitle is true', () => { - const { view } = renderView({ - title: 'Hidden Title', - hideTitle: true, - }); - - const title = view.getByText('Hidden Title'); - expect(title).toHaveAttribute('hidden'); - }); - }); - }); - - describe('Row-level labeling', () => { - it('has hidden text in list items with unit for non-interactive bars', () => { - const { view } = renderView({ unit: 'XP' }); - - const listItems = view.getAllByRole('listitem'); - expect(listItems).toHaveLength(2); - - expect(listItems[0]).toHaveTextContent(/100 XP in/); - expect(listItems[1]).toHaveTextContent(/75 XP in/); - }); - - it('has aria-label on interactive bars for button/link and no hidden text in listitem', () => { - const onClick = jest.fn(); - const { view } = renderView({ - unit: 'XP', - barValues: [ - createInteractiveBar({ - categoryLabel: 'Python', - seriesOneValue: 100, - onClick, - }), - ], - }); - - view.getByRole('button', { - name: '100 XP in Python', - }); - - const listItem = view.getByRole('listitem'); - const hiddenText = listItem.querySelector( - '[class*="screenreader"], [style*="position: absolute"]' - ); - expect(hiddenText).toBeNull(); - }); - }); - }); - - describe('Translations', () => { - it('uses default translations when not provided', () => { - const { view } = renderView({ - sortFns: ['alphabetically', 'none'], - }); - - expect(view.getByText('Order by:')).toBeInTheDocument(); - expect(view.getByText('None')).toBeInTheDocument(); - expect(view.getByText('Label (A-Z)')).toBeInTheDocument(); - }); - - it('uses custom translations when provided', () => { - const { view } = renderView({ - sortFns: ['alphabetically', 'none'], - translations: { - sortLabel: 'Ordenar por:', - sortOptions: { - none: 'Ninguno', - labelAsc: 'Etiqueta (A-Z)', - labelDesc: 'Etiqueta (Z-A)', - valueAsc: 'Valor (Bajo-Alto)', - valueDesc: 'Valor (Alto-Bajo)', - }, - }, - }); - - expect(view.getByText('Ordenar por:')).toBeInTheDocument(); - expect(view.getByText('Ninguno')).toBeInTheDocument(); - expect(view.getByText('Etiqueta (A-Z)')).toBeInTheDocument(); - }); - - it('merges partial translations with defaults', () => { - const { view } = renderView({ - sortFns: ['alphabetically', 'none'], - translations: { - sortLabel: 'Sort by:', - }, - }); - - expect(view.getByText('Sort by:')).toBeInTheDocument(); - expect(view.getByText('None')).toBeInTheDocument(); - expect(view.getByText('Label (A-Z)')).toBeInTheDocument(); - }); - - it('uses custom locale for number formatting', () => { - const { view } = renderView({ - barValues: [ - { categoryLabel: 'Python', seriesOneValue: 1000 }, - { categoryLabel: 'JavaScript', seriesOneValue: 2000 }, - ], - translations: { - locale: 'de-DE', - }, - }); - - const listItems = view.getAllByRole('listitem'); - expect(listItems[0]).toHaveTextContent('Python'); - expect(listItems[1]).toHaveTextContent('JavaScript'); - }); - - it('uses custom accessibility translations', () => { - const { view } = renderView({ - barValues: [ - { categoryLabel: 'Python', seriesOneValue: 100, seriesTwoValue: 200 }, - ], - unit: 'XP', - translations: { - accessibility: { - stackedBarSummary: ({ - seriesOneValue, - unit, - gained, - seriesTwoValue, - categoryLabel, - }) => - `Valor inicial - ${seriesOneValue} ${unit}. ${gained} ${unit} ganado - ahora en ${seriesTwoValue} ${unit} en ${categoryLabel}`, - singleValueBarSummary: ({ value, unit, categoryLabel }) => - `${value} ${unit} en ${categoryLabel}`, - }, - }, - }); - - const listItem = view.getAllByRole('listitem')[0]; - expect(listItem).toHaveTextContent(/Valor inicial/); - expect(listItem).toHaveTextContent(/ganado - ahora en/); - }); - - it('uses function-based singleValueBarSummary for single bar accessibility summary', () => { - const { view } = renderView({ - barValues: [ - { - categoryLabel: 'JavaScript', - seriesOneValue: 50, - onClick: () => undefined, - }, - ], - unit: 'XP', - translations: { - accessibility: { - singleValueBarSummary: ({ value, unit, categoryLabel }) => - `${value} ${unit} en ${categoryLabel}`.trim(), - }, - }, - }); - - const button = view.getByRole('button', { name: /50 XP en JavaScript/ }); - expect(button).toBeInTheDocument(); - }); - - it('uses function-based stackedBarSummary for stacked bar accessibility summary', () => { - const { view } = renderView({ - barValues: [ - { - categoryLabel: 'Python', - seriesOneValue: 100, - seriesTwoValue: 200, - }, - ], - unit: 'XP', - translations: { - accessibility: { - stackedBarSummary: ({ - seriesOneValue, - unit, - gained, - seriesTwoValue, - categoryLabel, - }) => - `${seriesOneValue} ${unit}, +${gained} ${unit} - now at ${seriesTwoValue} ${unit} in ${categoryLabel}`, - }, - }, - }); - - const listItem = view.getAllByRole('listitem')[0]; - expect(listItem).toHaveTextContent( - /100 XP, \+100 XP - now at 200 XP in Python/ - ); - }); - - it('uses function-based singleValueBarSummary for non-interactive single bar accessibility summary', () => { - const { view } = renderView({ - barValues: [{ categoryLabel: 'Rust', seriesOneValue: 75 }], - unit: 'pts', - translations: { - accessibility: { - singleValueBarSummary: ({ value, unit }) => - `${value} ${unit}`.trim(), - }, - }, - }); - - const listItem = view.getAllByRole('listitem')[0]; - expect(listItem).toHaveTextContent(/75 pts/); - }); - }); - - describe('Icons', () => { - it('renders icons when provided in bar data', () => { - const barValuesWithIcons: BarProps[] = [ - { categoryLabel: 'Python', seriesOneValue: 100, icon: TerminalIcon }, - { categoryLabel: 'JavaScript', seriesOneValue: 75 }, - ]; - - const { view } = renderView({ barValues: barValuesWithIcons }); - - const listItems = view.getAllByRole('listitem'); - expect(listItems).toHaveLength(2); - - const pythonItem = listItems[0]; - const iconInPython = pythonItem.querySelector('svg[role="img"]'); - expect(iconInPython).toBeInTheDocument(); - - const javascriptItem = listItems[1]; - const iconInJavascript = javascriptItem.querySelector('svg[role="img"]'); - expect(iconInJavascript).not.toBeInTheDocument(); - }); - }); - - describe('Custom Styling', () => { - it('applies custom textColor to labels', () => { - const { view } = renderView({ - styleConfig: { - textColor: 'primary', - }, - }); - - const listItems = view.getAllByRole('listitem'); - expect(listItems).toHaveLength(2); - }); - - it('applies custom seriesOneBarColor and seriesTwoBarColor', () => { - const { view } = renderView({ - styleConfig: { - seriesOneBarColor: 'primary', - seriesTwoBarColor: 'text', - }, - }); - - const backgroundBars = view.getAllByTestId('background-bar'); - expect(backgroundBars).toHaveLength(2); - }); - - it('applies custom seriesOneLabel and seriesTwoLabel colors', () => { - const stackedBarValues: BarProps[] = [ - { categoryLabel: 'Python', seriesOneValue: 50, seriesTwoValue: 100 }, - { categoryLabel: 'JavaScript', seriesOneValue: 25, seriesTwoValue: 75 }, - ]; - - const { view } = renderView({ - barValues: stackedBarValues, - styleConfig: { - seriesOneLabel: 'feedback-error', - seriesTwoLabel: 'feedback-success', - }, - }); - - const listItems = view.getAllByRole('listitem'); - expect(listItems).toHaveLength(2); - }); - - it('uses default styleConfig values when not provided', () => { - const { view } = renderView(); - - const listItems = view.getAllByRole('listitem'); - expect(listItems).toHaveLength(2); - const backgroundBars = view.getAllByTestId('background-bar'); - expect(backgroundBars).toHaveLength(2); - }); - }); - - describe('Title Variant', () => { - it('applies custom variant when provided in title object', () => { - const { view } = renderView({ - title: { - as: 'h1', - title: 'Custom Title', - variant: 'title-lg', - }, - }); - - const title = view.getByRole('heading', { name: 'Custom Title' }); - expect(title.tagName).toBe('H1'); - }); - - it('uses default variant when variant is not provided in title object', () => { - const { view } = renderView({ - title: { - as: 'h3', - title: 'Default Variant Title', - }, - }); - - const title = view.getByRole('heading', { - name: 'Default Variant Title', - }); - expect(title.tagName).toBe('H3'); - }); - - it('uses default variant when title is a string', () => { - const { view } = renderView({ - title: 'String Title', - }); - - const title = view.getByRole('heading', { name: 'String Title' }); - expect(title.tagName).toBe('H2'); - }); - }); -}); diff --git a/packages/gamut/src/BarChart/utils/__tests__/index.test.tsx b/packages/gamut/src/BarChart/utils/index.test.tsx similarity index 99% rename from packages/gamut/src/BarChart/utils/__tests__/index.test.tsx rename to packages/gamut/src/BarChart/utils/index.test.tsx index 1bcecc0990..2f464fe96f 100644 --- a/packages/gamut/src/BarChart/utils/__tests__/index.test.tsx +++ b/packages/gamut/src/BarChart/utils/index.test.tsx @@ -3,7 +3,7 @@ import { calculatePercent, calculatePositionPercent, getLabel, -} from '../index'; +} from '.'; describe('BarChart Utils', () => { describe('calculatePercent', () => { diff --git a/packages/gamut/src/ConnectedForm/ConnectedForm.stories.tsx b/packages/gamut/src/ConnectedForm/ConnectedForm.stories.tsx new file mode 100644 index 0000000000..f23207c8fa --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedForm.stories.tsx @@ -0,0 +1,822 @@ +import { MiniArrowRightIcon, TerminalIcon } from '@skillsoft/gamut-icons'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { StrokeButton } from '../Button'; +import { FormRequiredText } from '../Form'; +import { Text } from '../Typography'; +import { + ConnectedCheckbox, + ConnectedForm, + ConnectedFormGroup, + ConnectedInput, + ConnectedNestedCheckboxes, + ConnectedRadioGroupInput, + ConnectedSelect, + ConnectedTextArea, + SubmitButton, + useConnectedForm, +} from '.'; + +const onSubmit = fn(); + +const meta: Meta = { + title: 'Inputs & forms/ConnectedForm/ConnectedForm', + component: ConnectedForm, + args: { onSubmit }, +}; + +export default meta; +type Story = StoryObj; +const onError = fn(); + +type Canvas = Parameters>[0]['canvas']; + +const PlainConnectedFields: React.FC<{ onChangeValidation?: boolean }> = ({ + onChangeValidation, +}) => ( + <> + + !isValid : undefined} + m={32} + variant="secondary" + > + submit this form. + + + + + +); + +const plainValidationRules = { + checkbox: { required: 'you must check it' }, + select: { required: 'you must select something' }, + input: { required: 'you must type something' }, + radiogroup: { required: 'you must radio something' }, +}; + +const emptyValues = { + checkbox: false, + select: '', + input: '', + radiogroup: undefined, +}; + +const filledValues = { + checkbox: true, + select: 'two', + input: 'Hooray!', + radiogroup: 'two', +}; + +const getPlainFields = (canvas: Canvas) => ({ + checkbox: canvas.getByRole('checkbox', { name: 'cool-checkbox' }), + select: canvas.getByRole('combobox', { name: /cool-select/ }), + input: canvas.getByRole('textbox', { name: /cool-input/ }), + radioGroup: canvas.getByRole('radiogroup'), + radio: canvas.getByRole('radio', { name: 'two' }), +}); + +const fillPlainFields: NonNullable = async ({ + canvas, + userEvent, +}) => { + const { checkbox, select, input, radio } = getPlainFields(canvas); + await userEvent.click(checkbox); + await userEvent.click(radio); + await userEvent.selectOptions(select, 'two'); + await userEvent.type(input, 'Hooray!'); +}; + +const submitButton = (canvas: Canvas) => + canvas.getByRole('button', { name: /submit/i }); + +export const Default: Story = { + parameters: { docs: { source: { type: 'code' } } }, + render: function ConnectedFormStory(args) { + const { ConnectedFormGroup, ConnectedForm, connectedFormProps } = + useConnectedForm({ + defaultValues: { + checkboxField: false, + selectField: 'zero', + inputField: '', + radioGroupField: undefined, + textAreaField: '', + nestedCheckboxesField: ['angular', 'typescript', 'backend'], + }, + validationRules: { + checkboxField: { required: 'you need to check this.' }, + selectField: { + pattern: { + value: /^(?:(?!zero).)*$/, + message: 'literally anything but zero', + }, + }, + inputField: { required: 'we need this info, bud' }, + radioGroupField: { required: 'we need this info too, bud' }, + textAreaField: { + required: 'you just have to fill out the whole thing, okay?', + }, + }, + }); + + return ( + + submit this form + + check it ouuut, + 'aria-label': 'aria label', + }} + label="checkbox field" + name="checkboxField" + /> + + + ({ + label: ( + <> + {value} + + ), + value, + })), + }} + label="radio group field" + name="radioGroupField" + /> + + + + ); + }, +}; + +export const WatchedFields: Story = { + render: function WatchedFieldsStory() { + const [showRadio, setShowRadio] = useState(false); + const [lastFormValues, setLastFormValues] = useState({ + checkbox: 'not submitted', + radioGroup: 'not submitted', + }); + + const handleWatch = (values: string[]) => { + const [checkbox] = values; + return checkbox ? setShowRadio(true) : setShowRadio(false); + }; + + const { ConnectedFormGroup, ConnectedForm, connectedFormProps } = + useConnectedForm({ + defaultValues: { + checkbox: false, + radioGroup: undefined, + }, + validationRules: { + radioGroup: { + required: 'please fill this out.', + }, + }, + watchedFields: { + fields: ['checkbox', 'radioGroup'], + watchHandler: handleWatch, + }, + }); + + return ( + errors} + onSubmit={({ checkbox, radioGroup }) => { + setLastFormValues({ + checkbox: `${checkbox}`, + radioGroup: `${radioGroup}`, + }); + }} + {...connectedFormProps} + > + + your last answers were {lastFormValues.checkbox} and{' '} + {lastFormValues.radioGroup}! + + + {showRadio && ( + + )} + submit the form, please + + ); + }, + play: async ({ canvas, userEvent }) => { + canvas.getByText('your last answers were not submitted and not submitted!'); + await expect(canvas.queryByRole('radiogroup')).toBeNull(); + + const checkbox = canvas.getByRole('checkbox', { + name: 'check me to view the radio', + }); + await userEvent.click(checkbox); + canvas.getByRole('radiogroup'); + + await userEvent.click(submitButton(canvas)); + await canvas.findByText('please fill this out.'); + + await userEvent.click(canvas.getByRole('radio', { name: 'two' })); + await userEvent.click(submitButton(canvas)); + + await canvas.findByText('your last answers were true and two!'); + await waitFor(() => expect(checkbox).not.toBeChecked()); + await expect(canvas.queryByRole('radiogroup')).toBeNull(); + }, +}; + +const numbers = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; + +export const CustomFieldValidations: Story = { + render: function CustomFieldValidationsStory() { + const [numberType, setNumberType] = useState('even'); + + const { ConnectedFormGroup, ConnectedForm, connectedFormProps } = + useConnectedForm({ + defaultValues: { numberType: 'even', number: '' }, + validationRules: { + numberType: { required: 'Please select a number type' }, + number: { required: 'Please select a number' }, + }, + watchedFields: { + fields: ['numberType'], + watchHandler: ([type]: string[]) => setNumberType(type), + }, + }); + + return ( + + + { + if (!value) return true; + const num = parseInt(value, 10); + if (numberType === 'even') { + return ( + num % 2 === 0 || `${value} is odd — pick an even number` + ); + } + return num % 2 !== 0 || `${value} is even — pick an odd number`; + }, + }, + }} + label={`Pick an ${numberType} number`} + name="number" + /> + Submit + + ); + }, + play: async ({ canvas, userEvent }) => { + await userEvent.selectOptions( + canvas.getByRole('combobox', { name: /Pick an even number/ }), + '3' + ); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('3 is odd — pick an even number'); + await expect(onSubmit).not.toHaveBeenCalled(); + + await userEvent.selectOptions( + canvas.getByRole('combobox', { name: /Number type/ }), + 'odd' + ); + canvas.getByRole('combobox', { name: /Pick an odd number/ }); + + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { numberType: 'odd', number: '3' }, + expect.anything() + ) + ); + }, +}; + +export const SubmitsFieldValues: Story = { + render: () => ( + + + + ), + play: async (context) => { + const { canvas, userEvent } = context; + await fillPlainFields(context); + await userEvent.click(submitButton(canvas)); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith(filledValues, expect.anything()) + ); + }, +}; + +export const ValidationErrors: Story = { + render: () => ( + + + + ), + play: async ({ canvas, userEvent }) => { + const { checkbox, select, input, radioGroup } = getPlainFields(canvas); + await Promise.all( + [checkbox, select, input, radioGroup].map((field) => + expect(field).toHaveAttribute('aria-required', 'true') + ) + ); + await expect(submitButton(canvas)).toBeEnabled(); + + await userEvent.click(submitButton(canvas)); + + await waitFor(() => expect(canvas.getAllByRole('status')).toHaveLength(3)); + await expect(canvas.getAllByRole('alert')).toHaveLength(1); + await expect(onError).toHaveBeenCalled(); + await expect(onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const OnChangeValidation: Story = { + render: () => ( + + + + ), + play: async (context) => { + const { canvas, userEvent } = context; + await waitFor(() => expect(submitButton(canvas)).toBeDisabled()); + + await fillPlainFields(context); + await waitFor(() => expect(submitButton(canvas)).toBeEnabled()); + + await userEvent.clear(getPlainFields(canvas).input); + await waitFor(() => expect(submitButton(canvas)).toBeDisabled()); + }, +}; + +export const DisableFieldsOnSubmit: Story = { + render: () => ( + + + + ), + play: async ({ canvas, userEvent }) => { + const { checkbox, select, input, radio } = getPlainFields(canvas); + + await userEvent.click(submitButton(canvas)); + await canvas.findByText('you must type something'); + await Promise.all( + [checkbox, select, input, radio].map((field) => + expect(field).toBeEnabled() + ) + ); + + await userEvent.type(input, 'Hooray!'); + await userEvent.click(submitButton(canvas)); + + await waitFor(() => expect(input).toBeDisabled()); + await Promise.all( + [checkbox, select, radio].map((field) => expect(field).toBeDisabled()) + ); + }, +}; + +export const ResetOnSubmit: Story = { + render: () => ( + + + + ), + play: async (context) => { + const { canvas, userEvent } = context; + const { checkbox, select, input, radio } = getPlainFields(canvas); + + await userEvent.click(checkbox); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('you must type something'); + await expect(checkbox).toBeChecked(); + + await userEvent.click(checkbox); + await fillPlainFields(context); + await userEvent.click(submitButton(canvas)); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith(filledValues, expect.anything()) + ); + await waitFor(() => expect(checkbox).not.toBeChecked()); + await expect(select).toHaveValue(''); + await expect(input).toHaveValue(''); + await expect(radio).not.toBeChecked(); + }, +}; + +const emailPattern = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; + +export const FieldLevelValidations: Story = { + render: () => ( + + + Submit + + ), + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: /Email/ }); + await expect(input).toHaveAttribute('aria-required', 'true'); + + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Email is required'); + + await userEvent.type(input, 'invalid-email'); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Please enter a valid email address'); + await expect(onSubmit).not.toHaveBeenCalled(); + + await userEvent.clear(input); + await userEvent.type(input, 'test@example.com'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { email: 'test@example.com' }, + expect.anything() + ) + ); + }, +}; + +export const FieldValidationsOverrideFormRules: Story = { + render: () => ( + + + Submit + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.click(submitButton(canvas)); + + await canvas.findByText('Email is required'); + await expect( + canvas.queryByText('This field is required from form level') + ).toBeNull(); + }, +}; + +export const FieldValidationsMergeWithFormRules: Story = { + render: () => ( + + + Submit + + ), + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: /Email/ }); + + await userEvent.click(submitButton(canvas)); + await canvas.findByText('This field is required from form level'); + + await userEvent.type(input, 'abc'); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Email must be at least 5 characters'); + + await userEvent.type(input, 'def'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { email: 'abcdef' }, + expect.anything() + ) + ); + }, +}; + +const DynamicValidationsFields: React.FC = () => { + const [strict, setStrict] = useState(false); + + return ( + <> + + Submit + setStrict(true)}> + Enable strict + + + ); +}; + +export const DynamicFieldValidations: Story = { + render: () => ( + + + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.type( + canvas.getByRole('textbox', { name: /Username/ }), + 'short' + ); + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + await expect( + canvas.queryByText('Must be at least 10 characters') + ).toBeNull(); + + await userEvent.click( + canvas.getByRole('button', { name: 'Enable strict' }) + ); + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + + await canvas.findByText('Must be at least 10 characters'); + await expect(onSubmit).toHaveBeenCalledTimes(1); + }, +}; + +export const WasSubmitSuccessfulOverride: Story = { + render: () => ( + + + + ), + play: async (context) => { + const { canvas, userEvent } = context; + const { checkbox, select, input, radio } = getPlainFields(canvas); + + await fillPlainFields(context); + await userEvent.click(submitButton(canvas)); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith(filledValues, expect.anything()) + ); + await Promise.all( + [checkbox, select, input, radio].map((field) => + expect(field).toBeEnabled() + ) + ); + await expect(checkbox).toBeChecked(); + await expect(select).toHaveValue('two'); + await expect(input).toHaveValue('Hooray!'); + await expect(radio).toBeChecked(); + }, +}; + +export const FocusesFirstInvalidField: Story = { + render: () => ( + + + + + Submit + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(canvas.getByRole('textbox', { name: /Last name/ })).toHaveFocus() + ); + await expect(canvas.getByRole('alert')).toHaveTextContent( + 'Last name is required' + ); + }, +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.stories.tsx b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.stories.tsx new file mode 100644 index 0000000000..39193bfa7f --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.stories.tsx @@ -0,0 +1,447 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor, within } from 'storybook/test'; +import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; + +import { infotipNestedArgTypes } from '~storybook/argTypes'; + +import { Text } from '../Typography'; +import type { ConnectedFormGroupProps } from '.'; +import { + ConnectedCheckbox, + ConnectedForm, + ConnectedFormGroup, + ConnectedInput, + ConnectedRadioGroupInput, + ConnectedSelect, + ConnectedTextArea, + SubmitButton, + useConnectedForm, +} from '.'; + +const onSubmit = fn(); + +const radioOptions = [ + { label: 'one', value: 'one' }, + { label: 'two', value: 'two' }, + { label: 'zero', value: 'zero' }, +]; + +const meta: TypeWithDeepControls> = { + title: 'Inputs & forms/ConnectedForm/ConnectedFormGroup', + component: ConnectedFormGroup, + parameters: { deepControls: { enabled: true } }, + args: { + field: { + component: ConnectedRadioGroupInput, + options: radioOptions, + } as ConnectedFormGroupProps['field'], + label: 'i am a label', + name: 'radioGroupExample', + }, + argTypes: { + loading: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + ...infotipNestedArgTypes, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + ), +}; + +export const States: Story = { + render: function StatesStory() { + const { ConnectedFormGroup, ConnectedForm, connectedFormProps } = + useConnectedForm({ + defaultValues: { + radioGroup: undefined, + radioGroupError: undefined, + radioGroupDisabled: undefined, + }, + validationRules: { + radioGroup: { + required: 'please fill this out.', + }, + }, + }); + + return ( + + + + + + ); + }, + play: async ({ canvas }) => { + const [defaultGroup, errorGroup, disabledGroup] = + canvas.getAllByRole('radiogroup'); + + await expect(defaultGroup).toHaveAttribute('aria-required', 'true'); + await Promise.all( + within(defaultGroup) + .getAllByRole('radio') + .map((radio) => expect(radio).toBeEnabled()) + ); + + await expect(await canvas.findByRole('alert')).toHaveTextContent( + 'custom error' + ); + await Promise.all( + within(errorGroup) + .getAllByRole('radio') + .map((radio) => expect(radio).toBeEnabled()) + ); + + await Promise.all( + within(disabledGroup) + .getAllByRole('radio') + .map((radio) => expect(radio).toBeDisabled()) + ); + }, +}; + +export const InfoTipAutoLabelling: Story = { + render: () => ( + + + + ), + play: ({ canvas }) => { + canvas.getByRole('button', { name: 'Email address (optional)' }); + }, +}; + +export const InfoTipWithReactNodeLabel: Story = { + render: () => ( + + + API Configuration + + + Username (automatic label) + + } + name="username" + /> + + Password (ariaLabel) + + } + name="password" + /> + + API Key (ariaLabelledby) + + } + name="apiKey" + /> + + ), + play: async ({ canvas }) => { + canvas.getByRole('button', { + name: 'Username (automatic label) (optional)', + }); + canvas.getByRole('button', { name: 'Password requirements' }); + await expect( + canvas.queryByRole('button', { name: /Password \(ariaLabel\)/ }) + ).toBeNull(); + canvas.getByRole('button', { name: 'API Configuration' }); + }, +}; + +export const RadioInfoTips: Story = { + render: () => ( + + + + + + ), + play: async ({ canvas }) => { + canvas.getByRole('button', { name: 'Radio option' }); + canvas.getByRole('button', { name: 'Custom InfoTip label' }); + await expect( + canvas.queryByRole('button', { name: 'Labelled radio option' }) + ).toBeNull(); + canvas.getByRole('button', { name: 'Group InfoTip (optional)' }); + }, +}; + +export const ErrorWiring: Story = { + render: () => ( + + + Submit + + ), + play: async ({ canvas, canvasElement, userEvent }) => { + const input = canvas.getByRole('textbox', { name: /Email/ }); + await expect(input).toHaveAttribute('aria-invalid', 'false'); + await expect(input).not.toHaveAttribute('aria-describedby'); + + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + + const alert = await canvas.findByRole('alert'); + await expect(alert).toHaveTextContent('Email is required'); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + await expect(input).toHaveAttribute('aria-describedby', 'email_error'); + await expect(canvasElement.querySelector('#email_error')).toBe(alert); + await expect(input).toHaveAccessibleDescription('Email is required'); + + await userEvent.type(input, 'ada@example.com'); + await waitFor(() => expect(canvas.queryByRole('alert')).toBeNull()); + await expect(input).toHaveAttribute('aria-invalid', 'false'); + await expect(input).not.toHaveAttribute('aria-describedby'); + }, +}; + +export const HiddenLabel: Story = { + render: () => ( + + + Submit + + ), + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: /Search/ }); + + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(input).toHaveFocus()); + await expect(onSubmit).not.toHaveBeenCalled(); + await expect(canvas.queryByText('Enter a search term')).toBeNull(); + await expect(canvas.queryByRole('alert')).toBeNull(); + await expect(input).toHaveAttribute('aria-invalid', 'false'); + }, +}; + +export const ErrorMessageLinks: Story = { + render: () => ( + + + + ), + play: async ({ canvas }) => { + const alert = await canvas.findByRole('alert'); + await expect(alert).toHaveTextContent( + 'That username is taken. Sign in instead' + ); + await expect( + within(alert).getByRole('link', { name: 'Sign in instead' }) + ).toHaveAttribute('href', '/login'); + }, +}; + +export const LabelIndicators: Story = { + render: () => ( + + + + + + ), + play: ({ canvas }) => { + canvas.getByLabelText('Required field*'); + canvas.getByLabelText('Optional field (optional)'); + canvas.getByLabelText('Solo field'); + }, +}; + +export const DisabledFields: Story = { + render: () => ( + + + + + + + ), + play: async ({ canvas }) => { + await expect( + canvas.getByRole('textbox', { name: /^Input/ }) + ).toBeDisabled(); + await expect( + canvas.getByRole('combobox', { name: /Select/ }) + ).toBeDisabled(); + await expect( + canvas.getByRole('checkbox', { name: 'Checkbox' }) + ).toBeDisabled(); + await expect( + canvas.getByRole('textbox', { name: /Text area/ }) + ).toBeDisabled(); + }, +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedFormInputs.stories.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedFormInputs.stories.tsx new file mode 100644 index 0000000000..67e538b5a4 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedFormInputs.stories.tsx @@ -0,0 +1,284 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { StrokeButton } from '../../Button'; +import { Checkbox, FormGroup, Input } from '../../Form'; +import { Text } from '../../Typography'; +import { ConnectedForm } from '../ConnectedForm'; +import { ConnectedFormGroup } from '../ConnectedFormGroup'; +import { SubmitButton } from '../SubmitButton'; +import { useDebouncedField, useFormState } from '../utils'; +import { + ConnectedCheckbox, + ConnectedInput, + ConnectedRadioGroupInput, + ConnectedSelect, + ConnectedTextArea, +} from '.'; + +const meta: Meta = { + title: 'Inputs & forms/ConnectedForm/ConnectedFormInputs', +}; + +export default meta; +type Story = StoryObj; + +const onSubmit = fn(); + +const DebouncedTextInput: React.FC<{ shouldDirtyOnChange?: boolean }> = ({ + shouldDirtyOnChange, +}) => { + const { onBlur, onChange, value } = useDebouncedField({ + name: 'episode', + watchUpdateKeyName: 'updatedAt', + type: 'text', + shouldDirtyOnChange, + }); + + return ( + + + + ); +}; + +const DebouncedCheckbox: React.FC = () => { + const { onBlur, onChange, value } = useDebouncedField({ + name: 'watched', + type: 'checkbox', + }); + + return ( + + ); +}; + +const FormStateReadout: React.FC = () => { + const { isDirty, reset, watch } = useFormState(); + const [episode, watched] = watch(['episode', 'watched']); + + return ( + <> + Form episode: {episode} + Form watched: {String(watched)} + Form dirty: {String(isDirty)} + + reset({ episode: 'Home', watched: false, updatedAt: 'later' }) + } + > + Load saved values + + + ); +}; + +const debouncedDefaults = { + episode: 'Bad Blood', + watched: false, + updatedAt: 'now', +}; + +export const Default: Story = { + render: () => ( + + + + + + + Submit + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.type( + canvas.getByRole('textbox', { name: /ConnectedInput/ }), + 'typed' + ); + await userEvent.selectOptions( + canvas.getByRole('combobox', { name: /ConnectedSelect/ }), + 'three' + ); + await userEvent.click(canvas.getByRole('checkbox', { name: 'Check me' })); + await userEvent.type( + canvas.getByRole('textbox', { name: /ConnectedTextArea/ }), + 'many words' + ); + await userEvent.click(canvas.getByRole('radio', { name: 'b' })); + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { + input: 'typed', + select: 'three', + checkbox: true, + textArea: 'many words', + radioGroup: 'b', + }, + expect.anything() + ) + ); + }, +}; + +export const DebouncedField: Story = { + render: () => ( + + + + + + ), + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: /Favorite episode/ }); + await expect(input).toHaveValue('Bad Blood'); + + await userEvent.clear(input); + await userEvent.type(input, 'Tifa'); + await expect(input).toHaveValue('Tifa'); + canvas.getByText('Form episode: Bad Blood'); + canvas.getByText('Form dirty: false'); + + await userEvent.tab(); + await canvas.findByText('Form episode: Tifa'); + canvas.getByText('Form dirty: true'); + + const checkbox = canvas.getByRole('checkbox', { name: 'Watched it' }); + await userEvent.click(checkbox); + await expect(checkbox).toBeChecked(); + canvas.getByText('Form watched: false'); + + await userEvent.tab(); + await canvas.findByText('Form watched: true'); + + await userEvent.click(checkbox); + await userEvent.tab(); + await canvas.findByText('Form watched: false'); + await expect(checkbox).not.toBeChecked(); + + await userEvent.click( + canvas.getByRole('button', { name: 'Load saved values' }) + ); + await waitFor(() => expect(input).toHaveValue('Home')); + canvas.getByText('Form episode: Home'); + }, +}; + +export const DebouncedFieldDirtyOnChange: Story = { + render: () => ( + + + + + ), + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: /Favorite episode/ }); + + await userEvent.type(input, '!'); + await canvas.findByText('Form dirty: true'); + canvas.getByText('Form episode: Bad Blood'); + + await userEvent.tab(); + await canvas.findByText('Form episode: Bad Blood!'); + + await userEvent.click( + canvas.getByRole('button', { name: 'Load saved values' }) + ); + await waitFor(() => expect(input).toHaveValue('Home')); + await canvas.findByText('Form dirty: false'); + + await userEvent.type(input, '!'); + await canvas.findByText('Form dirty: true'); + }, +}; + +const onCheckboxUpdate = fn(); + +export const CheckboxUpdates: Story = { + render: () => ( + + + Accept terms, + }} + label="Terms" + name="terms" + /> + + ), + play: async ({ canvas, userEvent }) => { + const subscribe = canvas.getByRole('checkbox', { name: 'Subscribe' }); + + await userEvent.click(subscribe); + await expect(onCheckboxUpdate).toHaveBeenLastCalledWith(true); + + await userEvent.click(subscribe); + await expect(onCheckboxUpdate).toHaveBeenLastCalledWith(false); + await expect(onCheckboxUpdate).toHaveBeenCalledTimes(2); + + canvas.getByRole('checkbox', { name: 'checkbox' }); + }, +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/ConnectedNestedCheckboxes.stories.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/ConnectedNestedCheckboxes.stories.tsx new file mode 100644 index 0000000000..0338d40fb3 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/ConnectedNestedCheckboxes.stories.tsx @@ -0,0 +1,355 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { ConnectedForm } from '../../ConnectedForm'; +import { ConnectedFormGroup } from '../../ConnectedFormGroup'; +import { SubmitButton } from '../../SubmitButton'; +import type { NestedConnectedCheckboxOption } from '../types'; +import { ConnectedNestedCheckboxes } from '.'; + +const options: NestedConnectedCheckboxOption[] = [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { + value: 'node', + label: 'Node.js', + options: [ + { value: 'express', label: 'Express.js' }, + { value: 'fastify', label: 'Fastify' }, + ], + }, + { value: 'python', label: 'Python' }, + { value: 'ruby', label: 'Ruby', disabled: true }, + ], + }, + { + value: 'databases', + label: 'Databases', + }, +]; + +const onUpdate = fn(); +const onSubmit = fn(); + +type NestedCheckboxesStoryArgs = React.ComponentProps< + typeof ConnectedNestedCheckboxes +> & { + defaultValue?: string[]; + required?: boolean; +}; + +const meta: Meta = { + title: 'Inputs & forms/ConnectedForm/ConnectedNestedCheckboxes', + component: ConnectedNestedCheckboxes, + args: { name: 'skills', options }, + argTypes: { + name: { control: false }, + onUpdate: { control: false }, + defaultValue: { table: { disable: true } }, + required: { table: { disable: true } }, + }, + render: ({ defaultValue, required, name, disabled, options, spacing }) => ( + + + submit this form + + ), +}; + +export default meta; +type Story = StoryObj; + +type Canvas = Parameters>[0]['canvas']; + +const expectChecked = (canvas: Canvas, labels: string[]) => + Promise.all( + labels.map((label) => expect(canvas.getByLabelText(label)).toBeChecked()) + ); + +const expectUnchecked = (canvas: Canvas, labels: string[]) => + Promise.all( + labels.map((label) => + expect(canvas.getByLabelText(label)).not.toBeChecked() + ) + ); + +const expectMixed = (canvas: Canvas, labels: string[]) => + Promise.all( + labels.map((label) => + expect(canvas.getByLabelText(label)).toHaveAttribute( + 'aria-checked', + 'mixed' + ) + ) + ); + +const allLabels = [ + 'Frontend Technologies', + 'React', + 'Vue', + 'Angular', + 'Backend Technologies', + 'Node.js', + 'Express.js', + 'Fastify', + 'Python', + 'Ruby', + 'Databases', +]; + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getAllByRole('listitem')).toHaveLength(11); + await expectUnchecked(canvas, allLabels); + + const frontend = canvas.getByLabelText('Frontend Technologies'); + await expect(frontend).toHaveAttribute( + 'aria-label', + 'Frontend Technologies' + ); + await expect(frontend).toHaveAttribute('aria-checked', 'false'); + await expect(frontend).toHaveAttribute('id', 'skills-frontend'); + await expect(canvas.getByLabelText('React')).toHaveAttribute( + 'id', + 'skills-react' + ); + await expect(canvas.getByLabelText('Express.js')).toHaveAttribute( + 'id', + 'skills-express' + ); + + await expect(canvas.getByLabelText('Ruby')).toBeDisabled(); + await expect(canvas.getByLabelText('Vue')).toBeEnabled(); + }, +}; + +export const SelectingOptions: Story = { + play: async ({ canvas, userEvent }) => { + const react = canvas.getByLabelText('React'); + await userEvent.click(react); + await expect(react).toBeChecked(); + await expectMixed(canvas, ['Frontend Technologies']); + await expect(onUpdate).toHaveBeenLastCalledWith(['react']); + + await userEvent.click(react); + await expect(onUpdate).toHaveBeenLastCalledWith([]); + await expect( + canvas.getByLabelText('Frontend Technologies') + ).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(canvas.getByLabelText('Frontend Technologies')); + await expectChecked(canvas, [ + 'Frontend Technologies', + 'React', + 'Vue', + 'Angular', + ]); + await expect(onUpdate).toHaveBeenLastCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + + await userEvent.click(canvas.getByLabelText('Node.js')); + await expectChecked(canvas, ['Node.js', 'Express.js', 'Fastify']); + await expectMixed(canvas, ['Backend Technologies']); + await expect(onUpdate).toHaveBeenLastCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + 'express', + 'fastify', + 'node', + ]); + }, +}; + +export const WithDefaultValues: Story = { + args: { defaultValue: ['react', 'python'] }, + play: async ({ canvas }) => { + await expectChecked(canvas, ['React', 'Python']); + await expectMixed(canvas, [ + 'Frontend Technologies', + 'Backend Technologies', + ]); + await expectUnchecked(canvas, [ + 'Vue', + 'Angular', + 'Node.js', + 'Express.js', + 'Fastify', + 'Ruby', + 'Databases', + ]); + }, +}; + +export const DeeplyNestedDefaultValues: Story = { + args: { defaultValue: ['express', 'fastify'] }, + play: async ({ canvas }) => { + const node = canvas.getByLabelText('Node.js'); + await expect(node).toBeChecked(); + await expect(node).toHaveProperty('indeterminate', false); + await expect( + canvas.getByLabelText('Backend Technologies') + ).not.toBeChecked(); + await expectMixed(canvas, ['Backend Technologies']); + }, +}; + +export const ParentDefaultValues: Story = { + args: { defaultValue: ['frontend', 'backend'] }, + play: async ({ canvas, userEvent }) => { + await waitFor(() => + expectChecked( + canvas, + allLabels.filter((label) => label !== 'Databases') + ) + ); + + await userEvent.click(canvas.getByLabelText('Python')); + await expect(canvas.getByLabelText('Python')).not.toBeChecked(); + await expectMixed(canvas, ['Backend Technologies']); + await expectChecked(canvas, ['Node.js', 'Express.js', 'Fastify']); + }, +}; + +export const AllChildrenSelected: Story = { + args: { defaultValue: ['react', 'vue', 'angular'] }, + play: async ({ canvas, userEvent }) => { + const frontend = canvas.getByLabelText('Frontend Technologies'); + await expect(frontend).toBeChecked(); + + await userEvent.click(canvas.getByLabelText('React')); + await expect(frontend).not.toBeChecked(); + await expectMixed(canvas, ['Frontend Technologies']); + + await userEvent.click(canvas.getByLabelText('React')); + await expect(frontend).toBeChecked(); + + await userEvent.click(frontend); + await expectUnchecked(canvas, [ + 'Frontend Technologies', + 'React', + 'Vue', + 'Angular', + ]); + }, +}; + +export const Disabled: Story = { + args: { disabled: true }, + play: async ({ canvas, userEvent }) => { + await Promise.all( + allLabels.map((label) => + expect(canvas.getByLabelText(label)).toBeDisabled() + ) + ); + + const react = canvas.getByLabelText('React'); + await userEvent.click(react); + await expect(react).not.toBeChecked(); + await expect(onUpdate).not.toHaveBeenCalled(); + }, +}; + +export const Required: Story = { + args: { required: true }, + play: async ({ canvas, userEvent }) => { + await expect( + canvas.getByLabelText('Frontend Technologies') + ).toHaveAttribute('aria-required', 'true'); + + await userEvent.click( + canvas.getByRole('button', { name: 'submit this form' }) + ); + await canvas.findByText('At least one skill is required'); + await expect(onSubmit).not.toHaveBeenCalled(); + + await userEvent.click(canvas.getByLabelText('React')); + await userEvent.click( + canvas.getByRole('button', { name: 'submit this form' }) + ); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { skills: ['react'] }, + expect.anything() + ) + ); + }, +}; + +export const FlatOptions: Story = { + args: { + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + }, + play: async ({ canvas }) => { + await expect(canvas.getAllByRole('listitem')).toHaveLength(2); + canvas.getByLabelText('Option 1'); + canvas.getByLabelText('Option 2'); + }, +}; + +export const NumericValues: Story = { + args: { + options: [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as unknown as NestedConnectedCheckboxOption, + ], + }, + play: async ({ canvas }) => { + await expect(canvas.getByLabelText('Parent Option')).toHaveAttribute( + 'id', + 'skills-1' + ); + await expect(canvas.getByLabelText('Child Option')).toHaveAttribute( + 'id', + 'skills-2' + ); + }, +}; + +export const EmptyOptions: Story = { + args: { options: [] }, + play: async ({ canvas }) => { + canvas.getByRole('list'); + await expect(canvas.queryAllByRole('listitem')).toHaveLength(0); + }, +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx deleted file mode 100644 index 52cf06dd39..0000000000 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ /dev/null @@ -1,582 +0,0 @@ -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { ConnectedForm, ConnectedFormGroup, SubmitButton } from '../../..'; -import type { NestedConnectedCheckboxOption } from '../../types'; -import { ConnectedNestedCheckboxes } from '../index'; - -const mockOptions: NestedConnectedCheckboxOption[] = [ - { - value: 'frontend', - label: 'Frontend Technologies', - options: [ - { value: 'react', label: 'React' }, - { value: 'vue', label: 'Vue' }, - { value: 'angular', label: 'Angular' }, - ], - }, - { - value: 'backend', - label: 'Backend Technologies', - options: [ - { - value: 'node', - label: 'Node.js', - options: [ - { value: 'express', label: 'Express.js' }, - { value: 'fastify', label: 'Fastify' }, - ], - }, - { value: 'python', label: 'Python' }, - { value: 'ruby', label: 'Ruby', disabled: true }, - ], - }, - { - value: 'databases', - label: 'Databases', - }, -]; - -const mockOnUpdate = jest.fn(); -const mockOnSubmit = jest.fn(); -const TestForm: React.FC<{ - defaultValues?: { skills?: string[] }; - validationRules?: any; - disabled?: boolean; - options?: NestedConnectedCheckboxOption[]; -}> = ({ - defaultValues = {}, - validationRules, - disabled, - options = mockOptions, -}) => ( - - - submit this form - -); - -const renderView = setupRtl(TestForm, {}); - -describe('ConnectedNestedCheckboxes', () => { - describe('rendering', () => { - it('should render all checkbox options in a flat list', () => { - const { view } = renderView(); - - view.getByLabelText('Frontend Technologies'); - view.getByLabelText('Backend Technologies'); - view.getByLabelText('Databases'); - view.getByLabelText('React'); - view.getByLabelText('Vue'); - view.getByLabelText('Angular'); - view.getByLabelText('Node.js'); - view.getByLabelText('Python'); - view.getByLabelText('Express.js'); - view.getByLabelText('Fastify'); - }); - - it.each([ - { useLogicalProperties: true, marginLeft: 'marginInlineStart' }, - { useLogicalProperties: false, marginLeft: 'marginLeft' }, - ])( - 'should render checkboxes with proper indentation levels (useLogicalProperties: $useLogicalProperties)', - ({ useLogicalProperties, marginLeft }) => { - render( - - - - ); - - const frontendCheckbox = screen - .getByLabelText('Frontend Technologies') - .closest('li'); - const reactCheckbox = screen.getByLabelText('React').closest('li'); - const nodeCheckbox = screen.getByLabelText('Node.js').closest('li'); - const expressCheckbox = screen - .getByLabelText('Express.js') - .closest('li'); - - expect(frontendCheckbox).toHaveStyle({ [marginLeft]: '0' }); - expect(reactCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); - expect(nodeCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); - expect(expressCheckbox).toHaveStyle({ [marginLeft]: '3rem' }); - } - ); - - it('should render with unique IDs for each checkbox', () => { - const { view } = renderView(); - - expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( - 'id', - 'skills-frontend' - ); - expect(view.getByLabelText('React')).toHaveAttribute( - 'id', - 'skills-react' - ); - expect(view.getByLabelText('Express.js')).toHaveAttribute( - 'id', - 'skills-express' - ); - }); - }); - - describe('default values', () => { - it('should render with default values checked', () => { - const { view } = renderView({ - defaultValues: { skills: ['react', 'python'] }, - }); - - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - expect(view.getByLabelText('Vue')).not.toBeChecked(); - }); - - it('should render parent as indeterminate when some children are selected', () => { - const { view } = renderView({ - defaultValues: { skills: ['react', 'vue'] }, - }); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - - expect(frontendCheckbox.indeterminate).toBe(true); - expect(frontendCheckbox).not.toBeChecked(); - }); - - it('should render parent as checked when all children are selected', () => { - const { view } = renderView({ - defaultValues: { skills: ['react', 'vue', 'angular'] }, - }); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - expect(frontendCheckbox).toBeChecked(); - }); - - it('should render deeply nested parent states correctly', () => { - const { view } = renderView({ - defaultValues: { skills: ['express', 'fastify'] }, - }); - - const nodeCheckbox = view.getByLabelText('Node.js') as HTMLInputElement; - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - - expect(nodeCheckbox).toBeChecked(); - expect(nodeCheckbox.indeterminate).toBe(false); - expect(backendCheckbox).not.toBeChecked(); - expect(backendCheckbox.indeterminate).toBe(true); - }); - - it('should automatically check all children when parent is in default values', () => { - const { view } = renderView({ - defaultValues: { skills: ['backend'] }, - }); - - const backendCheckbox = view.getByLabelText('Backend Technologies'); - expect(backendCheckbox).toBeChecked(); - - expect(view.getByLabelText('Node.js')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - }); - - it('should handle multiple parent defaults correctly', async () => { - const { view } = renderView({ - defaultValues: { skills: ['frontend', 'backend'] }, - }); - - expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); - expect(view.getByLabelText('Backend Technologies')).toBeChecked(); - - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Vue')).toBeChecked(); - expect(view.getByLabelText('Angular')).toBeChecked(); - expect(view.getByLabelText('Node.js')).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - expect(view.getByLabelText('Ruby')).toBeChecked(); - }); - - it('should preserve individual child selections in default values', () => { - const { view } = renderView({ - defaultValues: { skills: ['react', 'python'] }, - }); - - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - - expect(frontendCheckbox.indeterminate).toBe(true); - expect(backendCheckbox.indeterminate).toBe(true); - - expect(view.getByLabelText('Vue')).not.toBeChecked(); - expect(view.getByLabelText('Angular')).not.toBeChecked(); - expect(view.getByLabelText('Node.js')).not.toBeChecked(); - expect(view.getByLabelText('Express.js')).not.toBeChecked(); - expect(view.getByLabelText('Fastify')).not.toBeChecked(); - expect(view.getByLabelText('Ruby')).not.toBeChecked(); - }); - - it('should allow unchecking children that were auto-checked by default parent selection', async () => { - const { view } = renderView({ - defaultValues: { skills: ['backend'] }, - }); - - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - const pythonCheckbox = view.getByLabelText('Python'); - - expect(backendCheckbox).toBeChecked(); - expect(pythonCheckbox).toBeChecked(); - - await act(async () => { - fireEvent.click(pythonCheckbox); - }); - - expect(pythonCheckbox).not.toBeChecked(); - - expect(backendCheckbox.indeterminate).toBe(true); - expect(backendCheckbox).not.toBeChecked(); - - expect(view.getByLabelText('Node.js')).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - }); - }); - - describe('user interactions', () => { - it('should update form value when leaf checkbox is clicked', async () => { - const { view } = renderView(); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(reactCheckbox).toBeChecked(); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - expect(frontendCheckbox.indeterminate).toBe(true); - }); - - it('should select all children when parent checkbox is clicked', async () => { - const { view } = renderView(); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - - await act(async () => { - fireEvent.click(frontendCheckbox); - }); - - expect(frontendCheckbox).toBeChecked(); - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Vue')).toBeChecked(); - expect(view.getByLabelText('Angular')).toBeChecked(); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'react', - 'vue', - 'angular', - 'frontend', - ]); - }); - - it('should deselect all children when checked parent is clicked', async () => { - const { view } = renderView({ - defaultValues: { skills: ['react', 'vue', 'angular'] }, - }); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - expect(frontendCheckbox).toBeChecked(); - - await act(async () => { - fireEvent.click(frontendCheckbox); - }); - - expect(frontendCheckbox).not.toBeChecked(); - expect(view.getByLabelText('React')).not.toBeChecked(); - expect(view.getByLabelText('Vue')).not.toBeChecked(); - expect(view.getByLabelText('Angular')).not.toBeChecked(); - }); - - it('should handle deeply nested selections correctly', async () => { - const { view } = renderView(); - - const nodeCheckbox = view.getByLabelText('Node.js'); - - await act(async () => { - fireEvent.click(nodeCheckbox); - }); - - expect(nodeCheckbox).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - expect(backendCheckbox.indeterminate).toBe(true); - - expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); - }); - - it('should handle individual child deselection affecting parent state', async () => { - const { view } = renderView({ - defaultValues: { skills: ['react', 'vue', 'angular'] }, - }); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - expect(frontendCheckbox).toBeChecked(); - - const reactCheckbox = view.getByLabelText('React'); - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(reactCheckbox).not.toBeChecked(); - expect(frontendCheckbox.indeterminate).toBe(true); - expect(frontendCheckbox).not.toBeChecked(); - }); - }); - - describe('onUpdate callback', () => { - it('should call onUpdate when checkbox values change', async () => { - const { view } = renderView(); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(mockOnUpdate).toHaveBeenCalledWith(['react']); - }); - - it('should call onUpdate with correct values when parent is selected', async () => { - const { view } = renderView(); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - - await act(async () => { - fireEvent.click(frontendCheckbox); - }); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'react', - 'vue', - 'angular', - 'frontend', - ]); - }); - - it('should call onUpdate with empty array when all items are deselected', async () => { - const { view } = renderView({ - defaultValues: { skills: ['react'] }, - }); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(mockOnUpdate).toHaveBeenCalledWith([]); - }); - }); - - describe('disabled state', () => { - it('should render all checkboxes as disabled when disabled prop is true', () => { - const { view } = renderView({ disabled: true }); - - expect(view.getByLabelText('Frontend Technologies')).toBeDisabled(); - expect(view.getByLabelText('React')).toBeDisabled(); - expect(view.getByLabelText('Databases')).toBeDisabled(); - }); - - it('should not respond to clicks when disabled', async () => { - const { view } = renderView({ - disabled: true, - }); - - const reactCheckbox = view.getByLabelText('React'); - expect(reactCheckbox).toBeDisabled(); - expect(reactCheckbox).not.toBeChecked(); - - await userEvent.click(reactCheckbox); - - expect(reactCheckbox).not.toBeChecked(); - expect(mockOnUpdate).not.toHaveBeenCalled(); - }); - - it('should handle individual option disabled state', () => { - const { view } = renderView(); - - expect(view.getByLabelText('Ruby')).toBeDisabled(); - expect(view.getByLabelText('Vue')).not.toBeDisabled(); - }); - }); - - describe('validation', () => { - it('should handle required validation', async () => { - const validationRules = { - skills: { required: 'At least one skill is required' }, - }; - - const { view } = renderView({ validationRules }); - - expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( - 'aria-required', - 'true' - ); - }); - - it('should submit successfully when validation passes', async () => { - const validationRules = { - skills: { required: 'At least one skill is required' }, - }; - - const { view } = renderView({ validationRules }); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - const submitButton = view.getByRole('button'); - await act(async () => { - fireEvent.click(submitButton); - }); - - expect(mockOnSubmit).toHaveBeenCalledWith( - { skills: ['react'] }, - expect.any(Object) - ); - }); - - it('should show validation errors and not submit when validation fails', async () => { - const validationRules = { - skills: { required: 'At least one skill is required' }, - }; - - const { view } = renderView({ validationRules }); - - const submitButton = view.getByRole('button'); - await act(async () => { - fireEvent.click(submitButton); - }); - - expect(mockOnSubmit).not.toHaveBeenCalled(); - view.getByText('At least one skill is required'); - }); - }); - - describe('edge cases', () => { - it('should handle empty options array', () => { - const { view } = renderView({ options: [] }); - - const list = view.container.querySelector('ul'); - expect(list).toBeInTheDocument(); - expect(list?.children).toHaveLength(0); - }); - - it('should handle options without nested children', () => { - const { view } = renderView({ - options: [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, - ], - }); - - view.getByLabelText('Option 1'); - view.getByLabelText('Option 2'); - }); - - it('should handle numeric values correctly', () => { - const { view } = renderView({ - options: [ - { - value: 1, - label: 'Parent Option', - options: [{ value: 2, label: 'Child Option' }], - } as any, // Type assertion for testing numeric values - ], - }); - - expect(view.getByLabelText('Parent Option')).toHaveAttribute( - 'id', - 'skills-1' - ); - expect(view.getByLabelText('Child Option')).toHaveAttribute( - 'id', - 'skills-2' - ); - }); - }); - - describe('accessibility', () => { - it('should have proper aria attributes', () => { - const { view } = renderView(); - - const checkbox = view.getByLabelText('Frontend Technologies'); - - expect(checkbox).toHaveAttribute('aria-label', 'Frontend Technologies'); - expect(checkbox).toHaveAttribute('aria-checked', 'false'); - }); - - it('should have proper aria-checked states for indeterminate checkboxes', () => { - const { view } = renderView({ - defaultValues: { skills: ['react'] }, - }); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - - expect(frontendCheckbox).toHaveAttribute('aria-checked', 'mixed'); - }); - - it('should use proper list semantics', () => { - const { view } = renderView({}); - - const list = view.container.querySelector('ul'); - expect(list).toBeInTheDocument(); - - expect(list?.children).toHaveLength(11); // Total flattened options - Array.from(list?.children || []).forEach((item) => { - // each child of the ul should be an li - expect(item).toBeInstanceOf(HTMLLIElement); - }); - }); - }); -}); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.test.tsx similarity index 99% rename from packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx rename to packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.test.tsx index 194c40df89..3650aada77 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.test.tsx @@ -7,7 +7,7 @@ import { getAllDescendants, handleCheckboxChange, renderCheckbox, -} from '../utils'; +} from './utils'; describe('ConnectedNestedCheckboxes utils', () => { describe('flattenOptions', () => { diff --git a/packages/gamut/src/ConnectedForm/SubmitButton.stories.tsx b/packages/gamut/src/ConnectedForm/SubmitButton.stories.tsx new file mode 100644 index 0000000000..20e6c6be4b --- /dev/null +++ b/packages/gamut/src/ConnectedForm/SubmitButton.stories.tsx @@ -0,0 +1,215 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useState } from 'react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { FlexBox } from '../Box'; +import { CTAButton, StrokeButton } from '../Button'; +import { Text } from '../Typography'; +import { + ConnectedForm, + ConnectedFormGroup, + ConnectedInput, + SubmitButton, +} from '.'; + +const meta: Meta = { + title: 'Inputs & forms/ConnectedForm/SubmitButton', + component: SubmitButton, + argTypes: { + loading: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + as: { + table: { + disable: true, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const onSubmit = fn(); + +export const Default: Story = { + render: (args) => ( + + + submit the form, please + + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.click( + canvas.getByRole('button', { name: 'submit the form, please' }) + ); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }, +}; + +export const States: Story = { + render: () => ( + + + default + submit the form, please + + + loading + + submit the form, please + + + + disabled + + you cannot submit this form + + + + ), + play: async ({ canvas }) => { + await expect( + canvas.getByRole('button', { name: 'submit the form, please' }) + ).toBeEnabled(); + await expect( + canvas.getByRole('button', { name: 'Loading' }) + ).toBeDisabled(); + canvas.getByTitle('Spinner'); + await expect( + canvas.getByRole('button', { name: 'you cannot submit this form' }) + ).toBeDisabled(); + }, +}; + +export const AsCTAButton: Story = { + render: () => ( + + + submit the form, please + + + ), + play: async ({ canvas }) => { + await expect( + canvas.getByRole('button', { name: 'submit the form, please' }) + ).toHaveAttribute('type', 'submit'); + }, +}; + +export const FormStateCallbacks: Story = { + render: function FormStateCallbacksStory() { + const [finishSubmit, setFinishSubmit] = useState<() => void>(); + + return ( + + new Promise((resolve) => { + onSubmit(values); + setFinishSubmit(() => resolve); + }) + } + > + + !isValid} + loading={({ isSubmitting }) => isSubmitting} + m={8} + > + Save + + finishSubmit?.()}> + Finish submitting + + + ); + }, + play: async ({ canvas, userEvent }) => { + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Save' })).toBeDisabled() + ); + + await userEvent.type(canvas.getByRole('textbox', { name: /Name/ }), 'Ada'); + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Save' })).toBeEnabled() + ); + + await userEvent.click(canvas.getByRole('button', { name: 'Save' })); + await expect( + await canvas.findByRole('button', { name: 'Loading' }) + ).toBeDisabled(); + await expect(onSubmit).toHaveBeenCalledWith({ name: 'Ada' }); + + await userEvent.click( + canvas.getByRole('button', { name: 'Finish submitting' }) + ); + await canvas.findByRole('button', { name: 'Save' }); + await expect(canvas.queryByRole('button', { name: 'Loading' })).toBeNull(); + }, +}; + +export const ValidatingState: Story = { + render: function ValidatingStateStory() { + const finishValidation = useRef<(valid: boolean) => void>(); + + return ( + + new Promise((resolve) => { + finishValidation.current = resolve; + }), + }, + }} + onSubmit={onSubmit} + > + + isValidating} m={8}> + Save + + finishValidation.current?.(true)}> + Finish validating + + + ); + }, + play: async ({ canvas, userEvent }) => { + canvas.getByRole('button', { name: 'Save' }); + + await userEvent.type( + canvas.getByRole('textbox', { name: /Username/ }), + 'a' + ); + await expect( + await canvas.findByRole('button', { name: 'Loading' }) + ).toBeDisabled(); + + await userEvent.click( + canvas.getByRole('button', { name: 'Finish validating' }) + ); + await canvas.findByRole('button', { name: 'Save' }); + await expect(canvas.queryByRole('button', { name: 'Loading' })).toBeNull(); + }, +}; diff --git a/packages/gamut/src/ConnectedForm/__fixtures__/helpers.tsx b/packages/gamut/src/ConnectedForm/__fixtures__/helpers.tsx deleted file mode 100644 index 404ca38528..0000000000 --- a/packages/gamut/src/ConnectedForm/__fixtures__/helpers.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type * as React from 'react'; - -import { - ConnectedCheckbox, - ConnectedFormGroup, - ConnectedInput, - ConnectedRadioGroupInput, - ConnectedSelect, - SubmitButton, -} from '..'; - -export const PlainConnectedFields: React.FC<{ - onChangeValidation?: boolean; -}> = ({ onChangeValidation }) => { - return ( - <> - - !isValid : undefined} - m={32} - variant="secondary" - > - submit this form. - - - - - - ); -}; diff --git a/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx deleted file mode 100644 index cc2a04fd31..0000000000 --- a/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx +++ /dev/null @@ -1,540 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import type { queries } from '@testing-library/dom'; -import { fireEvent } from '@testing-library/dom'; -import type { RenderResult } from '@testing-library/react'; -import { act, waitFor } from '@testing-library/react'; - -import type { InfoTipProps } from '../../Tips/InfoTip'; -import { createPromise } from '../../utils'; -import { - ConnectedForm, - ConnectedFormGroup, - ConnectedInput, - ConnectedRadioGroupInput, -} from '..'; -import { PlainConnectedFields } from '../__fixtures__/helpers'; - -const renderView = setupRtl(ConnectedForm, { - defaultValues: { - checkbox: false, - select: 'zero', - input: 'state your name.', - radiogroup: 'three', - }, - children: , -}); - -const getBaseCases = (view: RenderResult) => { - const checkboxField = view.getByRole('checkbox', { - name: 'cool-checkbox', - }) as HTMLInputElement; - const selectField = view.getByRole('combobox', { - name: /cool-select/, - }) as HTMLInputElement; - const textField = view.getByRole('textbox', { - name: /cool-input/, - }) as HTMLInputElement; - const radioGroup = view.getByRole('radiogroup'); - const radioOption = view.getByLabelText('two') as HTMLInputElement; - return { checkboxField, selectField, textField, radioGroup, radioOption }; -}; - -const selectValue = 'two'; -const textValue = 'Hooray!'; -const radioValue = 'two'; - -const doBaseFormActions = ( - selectField: HTMLInputElement, - textField: HTMLInputElement, - checkboxField: HTMLInputElement, - radioOption: HTMLElement -) => { - const newValues = [ - [selectField, selectValue], - [textField, textValue], - ] as const; - - act(() => { - fireEvent.click(checkboxField); - fireEvent.click(radioOption); - - for (const [selector, value] of newValues) { - fireEvent.change(selector, { - target: { - value, - }, - }); - } - }); -}; - -const baseResults = { - checkbox: true, - select: selectValue, - input: textValue, - radiogroup: radioValue, -}; - -const validationRules = { - checkbox: { required: 'you must check it' }, - select: { required: 'you must select something' }, - input: { required: 'you must type something' }, - radiogroup: { required: 'you must radio something' }, -}; - -const defaultValues = { - checkbox: false, - select: '', - input: '', - radiogroup: undefined, -}; - -describe('ConnectedForm', () => { - it('submits the form when all inputs are filled out', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ onSubmit }); - - const { checkboxField, selectField, textField, radioOption } = - getBaseCases(view); - - doBaseFormActions(selectField, textField, checkboxField, radioOption); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - - await api.innerPromise; - }); - - const result = await api.innerPromise; - - expect(result).toEqual(baseResults); - }); - - it('only sets aria-live prop on the first validation error in a form', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - const { view } = renderView({ - validationRules, - defaultValues, - onSubmit, - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - // there should only be a single "assertive" error from the form submission - expect(view.getAllByRole('alert').length).toBe(1); - expect(view.getAllByRole('status').length).toBe(3); - }); - - it('calls clearError onError so error text is re-read by screen reader', async () => { - const mockHandleSubmit = jest.fn(); - - /* We need a different approach to mock useForm and clearErrors, though since this is blocking the Storybook upgrade we'll use a stopgap for now. - const mockedUseForm = - jest.spyOn(rhf, 'useForm').getMockImplementation() ?? mockHandleSubmit; - - jest.spyOn(rhf, 'useForm').mockImplementationOnce(() => { - const returnMock = mockedUseForm(); - return { ...returnMock, clearErrors: mockHandleSubmit }; - }); - */ - - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - const { view } = renderView({ - validationRules, - defaultValues, - onSubmit, - onError: mockHandleSubmit, - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - expect(mockHandleSubmit).toHaveBeenCalled(); - }); - - it('sets aria-required to true for the appropriate inputs', () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - validationRules, - onSubmit, - }); - - const { checkboxField, selectField, textField, radioGroup } = - getBaseCases(view); - - expect(checkboxField).toHaveAttribute('aria-required'); - expect(selectField).toHaveAttribute('aria-required'); - expect(textField).toHaveAttribute('aria-required'); - expect(radioGroup).toHaveAttribute('aria-required'); - }); - - it('does not disable submit by default', () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ validationRules, defaultValues, onSubmit }); - - const submitButton = view.getByRole('button'); - - expect(submitButton).not.toBeDisabled(); - }); - - describe('onChange validation', () => { - it('disables the submit button when required fields are incomplete', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - children: , - validation: 'onChange', - validationRules, - defaultValues, - onSubmit, - }); - - const { textField } = getBaseCases(view); - const submitButton = view.getByRole('button'); - - act(() => { - fireEvent.input(textField, { - target: { - value: '', - }, - }); - }); - - expect(submitButton).toBeDisabled(); - }); - - it('enables the submit button after the required fields are completed', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - children: , - validation: 'onChange', - validationRules, - defaultValues, - onSubmit, - }); - - const { checkboxField, selectField, textField, radioOption } = - getBaseCases(view); - - doBaseFormActions(selectField, textField, checkboxField, radioOption); - - expect(checkboxField.checked).toEqual(true); - expect(selectField.value).toEqual(selectValue); - expect(textField.value).toEqual(textValue); - expect(radioOption.value).toEqual(radioValue); - await waitFor(() => expect(view.getByRole('button')).not.toBeDisabled()); - }); - }); - - describe('disableFieldsOnSubmit', () => { - it('disables fields when form is successfully submitted', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - disableFieldsOnSubmit: true, - defaultValues, - onSubmit, - }); - - const { checkboxField, selectField, textField, radioOption } = - getBaseCases(view); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - await api.innerPromise; - }); - - expect(checkboxField).toBeDisabled(); - expect(selectField).toBeDisabled(); - expect(textField).toBeDisabled(); - expect(radioOption).toBeDisabled(); - }); - - it('does not disable fields when form has a validation error', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - validationRules, - onSubmit, - disableFieldsOnSubmit: true, - }); - - const { checkboxField, selectField, textField, radioOption } = - getBaseCases(view); - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - expect(checkboxField).not.toBeDisabled(); - expect(selectField).not.toBeDisabled(); - expect(textField).not.toBeDisabled(); - expect(radioOption).not.toBeDisabled(); - }); - - describe('resetOnSubmit', () => { - it('resets fields when form is successfully submitted', async () => { - let submitCount = 0; - const api = createPromise<{}>(); - const api2 = createPromise<{}>(); - const onSubmit = async (values: {}) => { - return submitCount < 1 ? api.resolve(values) : api2.resolve(values); - }; - - const { view } = renderView({ - onSubmit, - defaultValues, - resetOnSubmit: true, - }); - - const { checkboxField, selectField, textField, radioOption } = - getBaseCases(view); - - doBaseFormActions(selectField, textField, checkboxField, radioOption); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - - await api.innerPromise; - }); - - const firstResult = await api.innerPromise; - - await act(async () => { - submitCount += 1; - fireEvent.submit(view.getByRole('button')); - await api2.innerPromise; - }); - - const secondResult = await api2.innerPromise; - - expect(firstResult).toEqual(baseResults); - expect(secondResult).toEqual({ ...defaultValues, radiogroup: null }); - }); - - it('does not reset fields when form has a validation error', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - onSubmit, - defaultValues, - resetOnSubmit: true, - }); - - const { textField, radioOption } = getBaseCases(view); - - act(() => { - fireEvent.click(radioOption); - - fireEvent.input(textField, { - target: { - value: 'an arbitrary value', - }, - }); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - - await api.innerPromise; - }); - - const result = await api.innerPromise; - - expect(result).toEqual({ - checkbox: defaultValues.checkbox, - select: defaultValues.select, - input: 'an arbitrary value', - radiogroup: 'two', - }); - }); - }); - }); -}); - -describe('ConnectedFormGroup infotip accessibility', () => { - const label = 'Infotip Label'; - const info = 'helpful information'; - const ariaLabel = 'Custom label'; - - const renderConnectedFormGroupView = setupRtl(ConnectedForm, { - defaultValues: { input: '' }, - onSubmit: jest.fn(), - children: null, - }); - - const renderWithInfotip = ({ - infotip, - fieldLabel = label, - }: { - infotip: InfoTipProps; - fieldLabel?: React.ReactNode; - }) => - renderConnectedFormGroupView({ - children: ( - - ), - }); - - it('automatically labels InfoTip button by the field label when label is a string', () => { - const { view } = renderWithInfotip({ infotip: { info } }); - - view.getByRole('button', { name: `${label}\u00A0(optional)` }); - }); - - it('uses explicit ariaLabel when provided', () => { - const { view } = renderWithInfotip({ - infotip: { info, ariaLabel }, - }); - - view.getByRole('button', { name: ariaLabel }); - }); - - it('uses explicit ariaLabelledby when provided', () => { - const externalLabelId = 'external-label-id'; - const externalLabelText = 'External Label'; - - const { view } = renderConnectedFormGroupView({ - children: ( - <> - {externalLabelText} - - - ), - }); - - view.getByRole('button', { name: externalLabelText }); - }); - - it('does not automatically label InfoTip when explicit ariaLabel is provided', () => { - const { view } = renderWithInfotip({ - infotip: { info, ariaLabel }, - fieldLabel: {label}, - }); - - view.getByRole('button', { name: ariaLabel }); - expect( - view.queryByRole('button', { name: `${label} (optional)` }) - ).toBeNull(); - }); - - it('automatically labels InfoTip by field label with ReactNode label', () => { - const { view } = renderWithInfotip({ - infotip: { info }, - fieldLabel: {label}, - }); - - view.getByRole('button', { name: `${label} (optional)` }); - }); -}); - -describe('ConnectedRadioGroup InfoTip accessibility', () => { - const info = 'helpful information'; - const radioLabel = 'Radio Option'; - const ariaLabel = 'Custom InfoTip Label'; - - const renderConnectedFormGroupView = setupRtl(ConnectedForm, { - defaultValues: { radioGroup: '' }, - onSubmit: jest.fn(), - children: null, - }); - - it('links Radio InfoTip to Radio label via aria-labelledby', () => { - const { view } = renderConnectedFormGroupView({ - children: ( - - ), - }); - - view.getByRole('button', { name: radioLabel }); - }); - - it('allows overriding Radio InfoTip aria-labelledby with explicit ariaLabel', () => { - const { view } = renderConnectedFormGroupView({ - children: ( - - ), - }); - - view.getByRole('button', { name: ariaLabel }); - expect( - view.queryByRole('button', { name: radioLabel }) - ).not.toBeInTheDocument(); - }); - - it('links RadioGroup InfoTip to RadioGroup label via FormGroupLabel', () => { - const groupLabel = 'Radio Group Label'; - const { view } = renderConnectedFormGroupView({ - children: ( - - ), - }); - - view.getByRole('button', { name: `${groupLabel}\u00A0(optional)` }); - }); -}); diff --git a/packages/gamut/src/ConnectedForm/__tests__/SubmitButton.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/SubmitButton.test.tsx deleted file mode 100644 index de5ee19f62..0000000000 --- a/packages/gamut/src/ConnectedForm/__tests__/SubmitButton.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { theme } from '@skillsoft/gamut-styles'; -import { setupRtl } from '@skillsoft/gamut-tests'; -import type { FormState } from 'react-hook-form'; -import hookform from 'react-hook-form'; - -import { CTAButton } from '../../Button'; -import { SubmitButton } from '../SubmitButton'; - -jest.mock('react-hook-form', () => ({ - useFormContext: jest.fn(), -})); - -const mockedHook = hookform.useFormContext as jest.Mock; - -const mockFormState = ({ - isValidating = false, - isValid = true, - isSubmitting = false, -}: FormState<{}>) => { - mockedHook.mockImplementation(() => ({ - formState: { isSubmitting, isValidating, isValid }, - })); -}; - -mockedHook.mockImplementation(() => ({ - formState: { isSubmitting: false, isValidating: false, isValid: false }, -})); - -const renderView = setupRtl(SubmitButton, {}); - -describe('SubmitButton', () => { - it('renders as a FillButton by default', () => { - const { view } = renderView(); - - const button = view.getByRole('button'); - - const borderRadiusSize = theme.borderRadii.md; - expect(button).toHaveStyle({ borderRadius: borderRadiusSize }); - }); - - it('renders as a CTAButton when configured', () => { - const { view } = renderView({ as: CTAButton }); - - const button = view.getByRole('button'); - - const borderRadiusSize = theme.borderRadii.md; - expect(button).toHaveStyle({ borderRadius: borderRadiusSize }); - }); - - it.each([ - [{ isValid: false }], - [{ isValidating: true }], - [{ isSubmitting: true }], - ])('disabled states (%o)', (formState: FormState<{}>) => { - mockFormState(formState); - const { view } = renderView({ - disabled: ({ isValidating, isSubmitting, isValid }) => - isValidating || isSubmitting || !isValid, - }); - expect(view.getByRole('button')).toBeDisabled(); - }); - it.each([ - [{}, 0], - [{ isValidating: true }, 1], - [{ isSubmitting: true }, 1], - ])('loading states (%o)', (formState: FormState<{}>, expected: number) => { - mockFormState(formState); - const { view } = renderView({ - loading: ({ isValidating, isSubmitting }) => isValidating || isSubmitting, - }); - - expect(view.queryAllByTitle('Spinner')).toHaveLength(expected); - }); -}); diff --git a/packages/gamut/src/ConnectedForm/__tests__/useDebouncedField.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/useDebouncedField.test.tsx deleted file mode 100644 index 2b42cae829..0000000000 --- a/packages/gamut/src/ConnectedForm/__tests__/useDebouncedField.test.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act } from '@testing-library/react'; -import type * as React from 'react'; - -import { Checkbox, Input } from '../../Form'; -import type { WithChildrenProp } from '../../utils'; -import { ConnectedForm } from '../ConnectedForm'; -import { useDebouncedField, useFormState } from '../utils'; - -const mockedSetValue = jest.fn(); - -jest.mock('react-hook-form', () => { - const original = jest.requireActual('react-hook-form'); - - return { - ...original, - useFormContext: () => { - return { - ...original.useFormContext(), - setValue: mockedSetValue, - }; - }, - }; -}); - -const mockInputKey = 'final-fantasy-vii'; -const mockDefaultValue = 'is your tv screen big enough?'; -const mockChangeValue = - 'A story of war and friendship. A story of love that can never be.'; -const mockChangeKey = 'remake-intergrade'; -const mockCheckboxKey = 'episode-inter-mission'; - -const FormWrapper: React.FC = ({ children }) => ( - null} - > - {children} - -); - -const TestTextInput: React.FC<{ shouldDirtyOnChange?: boolean }> = ({ - shouldDirtyOnChange, -}) => { - const { onBlur, onChange, value } = useDebouncedField({ - name: mockInputKey, - watchUpdateKeyName: mockChangeKey, - type: 'text', - shouldDirtyOnChange, - }); - const { reset } = useFormState(); - - const contrivedOnClick = () => { - reset({ - [mockChangeKey]: - 'It is the first in a planned trilogy of games remaking the 1997 PlayStation game Final Fantasy VII. Set in the dystopian cyberpunk metropolis of Midgar, players control mercenary Cloud Strife.', - [mockInputKey]: mockChangeValue, - }); - }; - - return ( - <> - - - - ); -}; - -const TestCheckboxInput: React.FC = () => { - const { onBlur, onChange, value } = useDebouncedField({ - name: mockCheckboxKey, - type: 'checkbox', - }); - - return ( - - ); -}; - -const renderView = setupRtl(TestTextInput).options({ wrapper: FormWrapper }); -const renderCheckboxView = setupRtl(TestCheckboxInput).options({ - wrapper: FormWrapper, -}); - -describe('ConnectedForm - useDebouncedField', () => { - it('should properly receive the default value', () => { - const { view } = renderView(); - - const input = view.getByRole('textbox') as HTMLInputElement; - expect(input.value).toEqual(mockDefaultValue); - }); - - it('should allow users to change the value', async () => { - const { view } = renderView(); - const input = view.getByRole('textbox') as HTMLInputElement; - act(() => { - fireEvent.change(input, { target: { value: mockChangeValue } }); - }); - - expect(input.value).toEqual(mockChangeValue); - }); - - it("should ONLY update the input's value on the form on blur of input", async () => { - const { view } = renderView({ shouldDirtyOnChange: false }); - - const input = view.getByRole('textbox') as HTMLInputElement; - - input.focus(); - expect(mockedSetValue).toHaveBeenCalledTimes(0); - await act(async () => { - fireEvent.change(input, { target: { value: 't' } }); - fireEvent.change(input, { target: { value: 'ti' } }); - fireEvent.change(input, { target: { value: 'tif' } }); - fireEvent.change(input, { target: { value: 'tifa' } }); - }); - - // Form has been dirtied by changing input - input.blur(); - // Now it has finally been called to update the data - expect(mockedSetValue).toHaveBeenCalledTimes(1); - expect(mockedSetValue).toHaveBeenLastCalledWith(mockInputKey, 'tifa', { - shouldDirty: true, - }); - }); - - it("doesn't call the dirty function on change if shouldDirtyOnChange is false", async () => { - const { view } = renderView(); - - const input = view.getByRole('textbox') as HTMLInputElement; - input.focus(); - expect(mockedSetValue).toHaveBeenCalledTimes(0); - - await act(async () => { - fireEvent.change(input, { target: { value: 't' } }); - fireEvent.change(input, { target: { value: 'ti' } }); - fireEvent.change(input, { target: { value: 'tif' } }); - fireEvent.change(input, { target: { value: 'tifa' } }); - }); - // Form has NOT been dirtied by changing input - expect(mockedSetValue).toHaveBeenCalledTimes(0); - - input.blur(); - expect(mockedSetValue).toHaveBeenCalledTimes(1); - expect(mockedSetValue).toHaveBeenLastCalledWith(mockInputKey, 'tifa', { - shouldDirty: true, - }); - }); - - it('should update the "initial" value of the input based on a change to the `watchUpdateKeyName` value', async () => { - const { view } = renderView(); - const input = view.getByRole('textbox') as HTMLInputElement; - const changeButton = view.getByRole('button'); - - expect(input.value).toEqual(mockDefaultValue); - - await act(async () => { - fireEvent.click(changeButton); - }); - - expect(input.value).toEqual(mockChangeValue); - }); - - it('should allow the form to be re-dirtied after submission', async () => { - const { view } = renderView({ shouldDirtyOnChange: true }); - - const input = view.getByRole('textbox') as HTMLInputElement; - const changeButton = view.getByRole('button'); - - input.focus(); - expect(mockedSetValue).toHaveBeenCalledTimes(0); - await act(async () => { - fireEvent.change(input, { target: { value: 'tifa' } }); - }); - // Form has been dirtied by changing input - expect(mockedSetValue).toHaveBeenCalledTimes(1); - expect(mockedSetValue).toHaveBeenLastCalledWith('', '', { - shouldDirty: true, - }); - - input.blur(); - // Now it has finally been called to update the data - expect(mockedSetValue).toHaveBeenCalledTimes(2); - expect(mockedSetValue).toHaveBeenLastCalledWith(mockInputKey, 'tifa', { - shouldDirty: false, - }); - - await act(async () => { - fireEvent.click(changeButton); - }); - - input.focus(); - await act(async () => { - fireEvent.change(input, { target: { value: 'tifa' } }); - }); - // Form has been dirtied by changing input - expect(mockedSetValue).toHaveBeenCalledTimes(3); - expect(mockedSetValue).toHaveBeenLastCalledWith('', '', { - shouldDirty: true, - }); - }); - - it('can handle non-string values (aka a checkbox)', async () => { - const { view } = renderCheckboxView(); - - const input = view.getByRole('checkbox') as HTMLInputElement; - expect(input.checked).toBe(false); - - // inital value setting - expect(mockedSetValue).toHaveBeenCalledTimes(0); - - input.focus(); - - await act(async () => { - fireEvent.click(input); - }); - - input.blur(); - - expect(mockedSetValue).toHaveBeenLastCalledWith(mockCheckboxKey, true, { - shouldDirty: true, - }); - expect(input.checked).toBe(true); - expect(mockedSetValue).toHaveBeenCalledTimes(1); - - input.focus(); - - await act(async () => { - fireEvent.click(input); - }); - - input.blur(); - - expect(mockedSetValue).toHaveBeenLastCalledWith(mockCheckboxKey, false, { - shouldDirty: true, - }); - expect(input.checked).toBe(false); - expect(mockedSetValue).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx deleted file mode 100644 index 15e0001f8b..0000000000 --- a/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act, waitFor } from '@testing-library/react'; -import { useState } from 'react'; - -import { createPromise } from '../../utils'; -import { ConnectedForm, ConnectedFormGroup } from '..'; -import { ConnectedInput } from '../ConnectedInputs/ConnectedInput'; - -const mockInputKey = 'email'; -const mockDefaultValue = ''; -const customErrorMessage = 'Please enter a valid email address'; -const customRequiredMessage = 'Email is required'; - -// ─── Custom field-level validations (no form-level rules) ──────────────────── -// All validation rules come exclusively from customValidations on the field. - -const TestFormWithCustomValidations: React.FC = () => { - return ( - <> - - - - ); -}; - -const renderView = setupRtl(ConnectedForm, { - defaultValues: { - [mockInputKey]: mockDefaultValue, - }, - onSubmit: () => null, - children: , -}); - -// ─── customValidations overriding form-level rules (same key) ──────────────── -// Both the form and the field define the same rule key; the field-level value wins. - -const TestFormWithOverrideValidations: React.FC = () => { - return ( - <> - - - - ); -}; - -const renderViewWithOverrideValidations = setupRtl(ConnectedForm, { - defaultValues: { - [mockInputKey]: mockDefaultValue, - }, - validationRules: { - [mockInputKey]: { - required: 'This field is required from form level', - }, - }, - onSubmit: () => null, - children: , -}); - -// ─── Merging form-level and customValidations (different keys) ──────────────── -// Form provides `required`; the field adds `minLength`. Both rules are enforced. - -const TestFormWithBothValidations: React.FC = () => { - return ( - <> - - - - ); -}; - -const renderViewWithBothValidations = setupRtl(ConnectedForm, { - defaultValues: { - [mockInputKey]: mockDefaultValue, - }, - validationRules: { - [mockInputKey]: { - required: 'This field is required from form level', - }, - }, - onSubmit: () => null, - children: , -}); - -// ─── Dynamic customValidations (rules changing after initial render) ────────── -// Validates that memoized `validation` updates when customValidations changes. - -const DynamicValidationsForm: React.FC = () => { - const [strict, setStrict] = useState(false); - - return ( - <> - - - - - ); -}; - -const renderViewWithDynamicValidations = setupRtl(ConnectedForm, { - defaultValues: { [mockInputKey]: mockDefaultValue }, - onSubmit: () => null, - children: , -}); - -describe('ConnectedForm - useField', () => { - describe('custom field-level validations (no form-level rules)', () => { - it('should apply custom validation pattern rules', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ onSubmit }); - - const input = view.getByRole('textbox') as HTMLInputElement; - - // Try to submit with invalid email - await act(async () => { - fireEvent.change(input, { target: { value: 'invalid-email' } }); - fireEvent.blur(input); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - // Should show the custom pattern validation error - await waitFor(() => { - expect(view.getByText(customErrorMessage)).toBeInTheDocument(); - }); - }); - - it('should validate required fields with custom validation', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ onSubmit }); - - // Try to submit with empty field - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - // Should show the custom required validation error - await waitFor(() => { - expect(view.getByText(customRequiredMessage)).toBeInTheDocument(); - }); - }); - - it('should pass validation with valid input', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ onSubmit }); - - const input = view.getByRole('textbox') as HTMLInputElement; - - // Submit with valid email - await act(async () => { - fireEvent.change(input, { target: { value: 'test@example.com' } }); - fireEvent.blur(input); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - await api.innerPromise; - }); - - const result = await api.innerPromise; - - // Should successfully submit with the correct value - expect(result).toEqual({ [mockInputKey]: 'test@example.com' }); - }); - - it('should set isRequired to true when custom validation includes required', () => { - const { view } = renderView(); - - const input = view.getByRole('textbox') as HTMLInputElement; - expect(input).toHaveAttribute('aria-required', 'true'); - }); - }); - - describe('customValidations overriding form-level rules (same key)', () => { - it('should give customValidations priority over form-level for the same key', async () => { - const { view } = renderViewWithOverrideValidations(); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - await waitFor(() => { - expect(view.getByText(customRequiredMessage)).toBeInTheDocument(); - expect( - view.queryByText('This field is required from form level') - ).not.toBeInTheDocument(); - }); - }); - }); - - describe('dynamic customValidations (rules changing after initial render)', () => { - it('should enforce a rule added to customValidations after initial render', async () => { - const { view } = renderViewWithDynamicValidations(); - - const input = view.getByRole('textbox') as HTMLInputElement; - - // Enter a short value that would fail minLength — before the rule exists - await act(async () => { - fireEvent.change(input, { target: { value: 'short' } }); - fireEvent.blur(input); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button', { name: 'Submit' })); - }); - - // No minLength error yet — the rule hasn't been added - expect( - view.queryByText('Must be at least 10 characters') - ).not.toBeInTheDocument(); - - // Now enable the stricter rule - await act(async () => { - fireEvent.click(view.getByRole('button', { name: 'Enable strict' })); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button', { name: 'Submit' })); - }); - - // The newly added minLength rule should now fire - await waitFor(() => { - expect( - view.getByText('Must be at least 10 characters') - ).toBeInTheDocument(); - }); - }); - }); - - describe('merging form-level and customValidations (different keys)', () => { - it('should merge form-level and custom validations', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderViewWithBothValidations({ onSubmit }); - - const input = view.getByRole('textbox') as HTMLInputElement; - - // Try to submit with empty field - should trigger form-level required validation - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - await waitFor(() => { - expect( - view.getByText('This field is required from form level') - ).toBeInTheDocument(); - }); - - // Now test with value that fails custom minLength validation - await act(async () => { - fireEvent.change(input, { target: { value: 'abc' } }); - fireEvent.blur(input); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - await waitFor(() => { - expect( - view.getByText('Email must be at least 5 characters') - ).toBeInTheDocument(); - }); - - // Finally test with valid value that passes both validations - await act(async () => { - fireEvent.change(input, { target: { value: 'abcdef' } }); - fireEvent.blur(input); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - await api.innerPromise; - }); - - const result = await api.innerPromise; - - // Should successfully submit - expect(result).toEqual({ [mockInputKey]: 'abcdef' }); - }); - }); -}); diff --git a/packages/gamut/src/DataList/DataList.stories.tsx b/packages/gamut/src/DataList/DataList.stories.tsx new file mode 100644 index 0000000000..75a9e6adf7 --- /dev/null +++ b/packages/gamut/src/DataList/DataList.stories.tsx @@ -0,0 +1,436 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor, within } from 'storybook/test'; +import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; + +import { FlexBox } from '../Box'; +import { DataList } from './DataList'; +import { DataTable } from './DataTable'; +import { + cols, + createDemoTable, + CustomEmptyState, + DisableContainerQueryExample, + simpleColumns, + simpleRows, +} from './testUtils'; +import type { ColumnConfig } from './types'; + +const crewRows = [ + { + name: 'Jean Luc Picard', + role: 'Captain', + ship: 'USS Enterprise', + age: '59', + species: 'Human', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '35', + homeworld: 'La Barre, France, Earth', + specialization: 'Command & Diplomacy', + }, + { + name: 'Wesley Crusher', + role: 'Deus Ex Machina', + ship: 'USS Enterprise', + age: '18', + species: 'Human/Traveler', + sector: 'Multiple Dimensions', + status: 'Transcended', + yearsOfService: '2', + homeworld: 'Earth', + specialization: 'Space-Time Manipulation', + }, + { + name: 'Geordie LaForge', + role: 'Chief Engineer / Rascal', + ship: 'Borg Cube', + age: '35', + species: 'Human', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '15', + homeworld: 'Mogadishu, Somalia, Earth', + specialization: 'Engineering & Technology', + }, + { + name: 'Data', + role: 'Lt. Commander / Scamp', + ship: 'He is a ship', + age: '30', + species: 'Soong-type Android', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '26', + homeworld: 'Omicron Theta', + specialization: 'Operations & Analysis', + }, +]; + +type CrewRow = (typeof crewRows)[number]; + +const crewColumns: ColumnConfig[] = [ + { + header: 'Name', + key: 'name', + size: 'lg', + type: 'header', + sortable: true, + }, + { header: 'Rank', key: 'role', size: 'lg', sortable: true }, + { header: 'Ship', key: 'ship', size: 'lg', sortable: true }, + { header: 'Age', key: 'age', size: 'sm', sortable: true }, + { header: 'Species', key: 'species', size: 'md', sortable: true }, + { header: 'Sector', key: 'sector', size: 'md', sortable: true }, + { header: 'Status', key: 'status', size: 'sm', sortable: true }, + { + header: 'Years of service', + key: 'yearsOfService', + size: 'md', + sortable: true, + }, + { header: 'Homeworld', key: 'homeworld', size: 'lg', sortable: true }, + { + header: 'Specialization', + key: 'specialization', + size: 'xl', + sortable: true, + fill: true, + }, +]; + +type CrewDataList = typeof DataList[]>; +const onEmptyQueryChange = fn(); + +const meta: TypeWithDeepControls> = { + title: 'Data display/DataList', + component: DataList, + parameters: { deepControls: { enabled: true } }, + args: { + id: 'crew', + idKey: 'name', + query: { sort: { name: 'desc', role: 'asc' } }, + rows: crewRows, + columns: crewColumns, + header: false, + spacing: 'condensed', + onQueryChange: fn(), + onRowSelect: fn(), + onRowExpand: fn(), + expandedContent: ({ row }) => ( + + + + ), + }, + argTypes: { + spacing: { control: 'radio' }, + variant: { control: 'radio' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const DataListTemplate = createDemoTable(DataList, { + scrollable: false, + height: 'auto', + showOverflow: true, +}); + +export const Default: Story = { + args: {}, + play: async ({ args, canvas, userEvent }) => { + await expect( + canvas.queryByRole('columnheader', { name: /Rank/ }) + ).toBeNull(); + for (const name of ['Jean Luc Picard', 'Wesley Crusher', 'Data']) { + canvas.getByText(name); + } + canvas.getByText('Command & Diplomacy'); + + await userEvent.click( + canvas.getByRole('checkbox', { name: 'Select Data' }) + ); + await expect(args.onRowSelect).toHaveBeenLastCalledWith({ + type: 'select', + payload: { rowId: 'Data', toggle: false }, + }); + + const expandButton = canvas.getByRole('button', { + name: 'Expand Data Row', + }); + await expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + await userEvent.click(expandButton); + await expect(args.onRowExpand).toHaveBeenLastCalledWith({ + type: 'expand', + payload: { rowId: 'Data', toggle: false }, + }); + }, +}; + +export const FullDataList: Story = { + render: () => , + play: async ({ canvas, userEvent }) => { + const selectAll = canvas.getByRole('checkbox', { name: 'Select All' }); + const picard = canvas.getByRole('checkbox', { + name: 'Select Jean Luc Picard', + }); + + await userEvent.click(picard); + await expect(picard).toBeChecked(); + await expect(selectAll).not.toBeChecked(); + + await userEvent.click(selectAll); + for (const checkbox of canvas.getAllByRole('checkbox')) { + await expect(checkbox).toBeChecked(); + } + + await userEvent.click(selectAll); + for (const checkbox of canvas.getAllByRole('checkbox')) { + await expect(checkbox).not.toBeChecked(); + } + + picard.focus(); + await userEvent.keyboard(' '); + await expect(picard).toBeChecked(); + + const expandButton = canvas.getByRole('button', { + name: 'Expand Jean Luc Picard Row', + }); + await userEvent.click(expandButton); + await expect(expandButton).toHaveAttribute('aria-expanded', 'true'); + within(canvas.getByRole('region')).getByText('Nothing to see here'); + + await userEvent.click( + canvas.getByRole('button', { name: 'Get me out of here!' }) + ); + await expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + await waitFor(() => expect(canvas.queryByRole('region')).toBeNull()); + }, +}; + +export const Expanded: Story = { + args: { + expanded: ['Data'], + }, + play: async ({ args, canvas, userEvent }) => { + const region = canvas.getByRole('region'); + await expect(within(region).getAllByText('Ooo very cool')).toHaveLength(3); + + const expandButton = canvas.getByRole('button', { expanded: true }); + await expect(expandButton).toHaveAccessibleName('Expand Data Row'); + await userEvent.click(expandButton); + await expect(args.onRowExpand).toHaveBeenLastCalledWith({ + type: 'expand', + payload: { rowId: 'Data', toggle: true }, + }); + }, +}; + +export const Selected: Story = { + args: { + selected: ['Data'], + }, + play: async ({ args, canvas, userEvent }) => { + const selectedCheckbox = canvas.getByRole('checkbox', { + name: 'Select Data', + }); + await expect(selectedCheckbox).toBeChecked(); + await expect( + canvas.getByRole('checkbox', { name: 'Select Wesley Crusher' }) + ).not.toBeChecked(); + + await userEvent.click(selectedCheckbox); + await expect(args.onRowSelect).toHaveBeenLastCalledWith({ + type: 'select', + payload: { rowId: 'Data', toggle: true }, + }); + + await userEvent.click( + canvas.getByRole('checkbox', { name: 'Select Wesley Crusher' }) + ); + await expect(args.onRowSelect).toHaveBeenLastCalledWith({ + type: 'select', + payload: { rowId: 'Wesley Crusher', toggle: false }, + }); + }, +}; + +export const EmptyState: Story = { + render: () => ( + + ), + play: async ({ canvas, userEvent }) => { + onEmptyQueryChange.mockClear(); + canvas.getByText('No Results Found'); + canvas.getByText('Remove filters to view'); + await expect( + canvas.getByRole('checkbox', { name: 'Select All' }) + ).toBeDisabled(); + + await userEvent.click( + canvas.getByRole('button', { name: 'Reset Filters' }) + ); + await expect(onEmptyQueryChange).toHaveBeenLastCalledWith({ + type: 'reset', + }); + }, +}; + +export const EmptyStateCustom: Story = { + render: () => ( + } + height="45vh" + id="crew" + idKey="name" + minHeight="300px" + rows={[]} + spacing="condensed" + onRowSelect={fn()} + /> + ), + play: async ({ canvas }) => { + canvas.getByText('Nothing to see here!'); + await expect(canvas.queryByText('No Results Found')).toBeNull(); + }, +}; + +export const NonSelectable: Story = { + args: { + onRowSelect: undefined, + onRowExpand: undefined, + expandedContent: undefined, + header: true, + }, + play: async ({ args, canvas, userEvent }) => { + await expect(canvas.queryByRole('checkbox')).toBeNull(); + await expect(canvas.queryByRole('button', { name: /^Expand/ })).toBeNull(); + + await expect( + canvas.getByRole('columnheader', { name: /Name/ }) + ).toHaveAttribute('aria-sort', 'descending'); + await expect( + canvas.getByRole('columnheader', { name: /Rank/ }) + ).toHaveAttribute('aria-sort', 'ascending'); + await expect( + canvas.getByRole('columnheader', { name: /Ship/ }) + ).toHaveAttribute('aria-sort', 'none'); + + await userEvent.click(canvas.getByRole('button', { name: 'sort by name' })); + await expect(args.onQueryChange).toHaveBeenLastCalledWith({ + type: 'sort', + payload: { dimension: 'name', value: 'none' }, + }); + await userEvent.click(canvas.getByRole('button', { name: 'sort by role' })); + await expect(args.onQueryChange).toHaveBeenLastCalledWith({ + type: 'sort', + payload: { dimension: 'role', value: 'desc' }, + }); + await userEvent.click(canvas.getByRole('button', { name: 'sort by ship' })); + await expect(args.onQueryChange).toHaveBeenLastCalledWith({ + type: 'sort', + payload: { dimension: 'ship', value: 'asc' }, + }); + }, +}; + +export const DisableContainerQuery: Story = { + args: {}, + render: () => ( + + } + disabledComponent={ + + } + /> + ), + play: async ({ canvasElement }) => { + await expect( + getComputedStyle(canvasElement.querySelector('#default-container-query')!) + .containerType + ).toBe('inline-size'); + await expect( + getComputedStyle( + canvasElement.querySelector('#disabled-container-query')! + ).containerType + ).toBe('normal'); + }, +}; + +export const HideSelectAll: Story = { + args: { header: true, hideSelectAll: true }, + play: async ({ canvas }) => { + await expect( + canvas.queryByRole('checkbox', { name: 'Select All' }) + ).toBeNull(); + await expect(canvas.getAllByRole('checkbox')).toHaveLength(crewRows.length); + }, +}; + +export const MultipleExpanded: Story = { + args: { expanded: ['Data', 'Wesley Crusher'] }, + parameters: { + a11y: { + config: { rules: [{ id: 'landmark-unique', enabled: false }] }, + }, + }, + play: async ({ canvas }) => { + // TODO: expanded rows render unnamed region landmarks, so several open rows aren't distinguishable + await expect(canvas.getAllByRole('region')).toHaveLength(2); + await expect( + canvas.getAllByRole('button', { expanded: true }) + ).toHaveLength(2); + }, +}; + +export const CardVariant: Story = { + args: { header: true, variant: 'card' }, + play: async ({ canvas }) => { + canvas.getByRole('table'); + canvas.getByRole('columnheader', { name: /Rank/ }); + await expect(canvas.getAllByRole('checkbox')).toHaveLength( + crewRows.length + 1 + ); + }, +}; diff --git a/packages/gamut/src/DataList/DataTable.stories.tsx b/packages/gamut/src/DataList/DataTable.stories.tsx new file mode 100644 index 0000000000..94e759b8ff --- /dev/null +++ b/packages/gamut/src/DataList/DataTable.stories.tsx @@ -0,0 +1,662 @@ +import { MiniKebabMenuIcon } from '@skillsoft/gamut-icons'; +import { Background } from '@skillsoft/gamut-styles'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useState } from 'react'; +import { expect, fn, screen, waitFor, within } from 'storybook/test'; +import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; + +import { Box } from '../Box'; +import { IconButton } from '../Button'; +import { Menu, MenuItem } from '../Menu'; +import { Dialog } from '../Modals'; +import { PopoverContainer } from '../PopoverContainer'; +import { Text } from '../Typography'; +import { DataTable } from './DataTable'; +import { + createDemoTable, + CustomEmptyState, + DisableContainerQueryExample, + shipFilters, + simpleColumns, + simpleRows, +} from './testUtils'; +import type { ColumnConfig } from './types'; + +const crewRows = [ + { + name: 'Jean Luc Picard', + 'a very important role': 'Captain', + ship: 'USS Enterprise', + age: '59', + species: 'Human', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '35', + homeworld: 'La Barre, France, Earth', + specialization: 'Command & Diplomacy', + }, + { + name: 'Wesley Crusher', + 'a very important role': 'Deus Ex Machina', + ship: 'USS Enterprise', + age: '18', + species: 'Human/Traveler', + sector: 'Multiple Dimensions', + status: 'Transcended', + yearsOfService: '2', + homeworld: 'Earth', + specialization: 'Space-Time Manipulation', + }, + { + name: 'Geordie LaForge', + 'a very important role': 'Chief Engineer / Rascal', + ship: 'Borg Cube', + age: '35', + species: 'Human', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '15', + homeworld: 'Mogadishu, Somalia, Earth', + specialization: 'Engineering & Technology', + }, + { + name: 'Data', + 'a very important role': 'Lt. Commander / Scamp', + ship: 'He is a ship', + age: '30', + species: 'Soong-type Android', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '26', + homeworld: 'Omicron Theta', + specialization: 'Operations & Analysis', + }, + { + name: `Miles Edward O'Brien, 24th Century Man`, + 'a very important role': 'Command Master Chief', + ship: 'Deep Space 9', + age: '40', + species: 'Human', + sector: 'Bajoran System', + status: 'Active', + yearsOfService: '22', + homeworld: 'Ireland, Earth', + specialization: 'Engineering & Transporter Operations', + }, +]; + +type CrewRow = (typeof crewRows)[number]; + +const crewColumns: ColumnConfig[] = [ + { + header: 'Name', + key: 'name', + size: 'lg', + type: 'header', + sortable: true, + }, + { + header: 'Rank', + key: 'a very important role', + size: 'lg', + sortable: true, + }, + { header: 'Ship', key: 'ship', size: 'lg', sortable: true }, + { header: 'Age', key: 'age', size: 'sm', sortable: true }, + { header: 'Species', key: 'species', size: 'md', sortable: true }, + { header: 'Sector', key: 'sector', size: 'md', sortable: true }, + { header: 'Status', key: 'status', size: 'sm', sortable: true }, + { + header: 'Years of Service', + key: 'yearsOfService', + size: 'sm', + sortable: true, + }, + { header: 'Homeworld', key: 'homeworld', size: 'lg', sortable: true }, + { + header: 'Specialization', + key: 'specialization', + size: 'xl', + sortable: true, + fill: true, + }, +]; + +type CrewDataTable = typeof DataTable[]>; + +const meta: TypeWithDeepControls> = { + title: 'Data display/DataTable', + component: DataTable, + parameters: { deepControls: { enabled: true } }, + args: { + id: 'crew', + idKey: 'name', + query: { sort: { name: 'desc', 'a very important role': 'asc' } }, + rows: crewRows, + columns: crewColumns, + spacing: 'condensed', + onQueryChange: fn(), + }, + argTypes: { + spacing: { control: 'radio' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const DataTableTemplate = createDemoTable(DataTable, { + onRowSelect: undefined, + onRowExpand: undefined, +}); + +const RowMenu = ({ rowName }: { rowName: string }) => { + const [isOpen, setIsOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const menuButtonRef = useRef(null); + + const handleClose = () => setIsOpen(false); + + const handleOpenModal = () => { + setIsOpen(false); + setIsModalOpen(true); + }; + + return ( + + setIsOpen(!isOpen)} + /> + + + + + Edit {rowName} + + + + + Delete {rowName} + + + + + Clone {rowName} + + + + + setIsModalOpen(false), + }} + confirmCta={{ + children: 'Clone', + onClick: () => setIsModalOpen(false), + }} + isOpen={isModalOpen} + size="small" + title={`Clone ${rowName}`} + onRequestClose={() => setIsModalOpen(false)} + > + Are you sure you want to clone{' '} + + {rowName}{' '} + + ? This action cannot be undone. + + + ); +}; + +const moreCrewRows: CrewRow[] = [ + ...crewRows, + { + name: 'William Riker', + 'a very important role': 'Commander', + ship: 'USS Enterprise', + age: '32', + species: 'Human', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '15', + homeworld: 'Alaska, Earth', + specialization: 'Command & Diplomacy', + }, + { + name: 'Deanna Troi', + 'a very important role': 'Counselor', + ship: 'USS Enterprise', + age: '28', + species: 'Human / Betazoid', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '8', + homeworld: 'Betazed', + specialization: 'Psychology & Empathy', + }, + { + name: 'Worf', + 'a very important role': 'Security Officer', + ship: 'USS Enterprise', + age: '35', + species: 'Klingon', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '12', + homeworld: "Qo'noS", + specialization: 'Security & Combat', + }, + { + name: 'Beverly Crusher', + 'a very important role': 'Chief Medical Officer', + ship: 'USS Enterprise', + age: '40', + species: 'Human', + sector: 'Alpha Quadrant', + status: 'Active', + yearsOfService: '18', + homeworld: 'Earth', + specialization: 'Medicine & Research', + }, + { + name: 'Tasha Yar', + 'a very important role': 'Security Chief', + ship: 'USS Enterprise', + age: '24', + species: 'Human', + sector: 'Alpha Quadrant', + status: 'Deceased', + yearsOfService: '3', + homeworld: 'Turkana IV, Earth', + specialization: 'Security & Tactics', + }, +]; + +const bodyRows = (canvasElement: HTMLElement) => + Array.from(canvasElement.querySelectorAll('tbody tr')); + +const firstRowName = (canvasElement: HTMLElement) => + bodyRows(canvasElement)[0].querySelector('th, td')?.textContent; + +export const Default: Story = { + args: {}, + play: async ({ canvas, canvasElement }) => { + // TODO: sort and filter buttons are named from the column key, not the header text + await expect(bodyRows(canvasElement)).toHaveLength(crewRows.length); + canvas.getByRole('columnheader', { name: /Rank/ }); + canvas.getByText('Command Master Chief'); + await expect(canvas.queryByRole('checkbox')).toBeNull(); + await expect(canvas.queryByRole('button', { name: /^Expand/ })).toBeNull(); + + await expect( + canvas.getByRole('columnheader', { name: /Name/ }) + ).toHaveAttribute('aria-sort', 'descending'); + await expect( + canvas.getByRole('columnheader', { name: /Rank/ }) + ).toHaveAttribute('aria-sort', 'ascending'); + }, +}; + +export const FullDataTable: Story = { + render: () => , + play: async ({ canvas, userEvent }) => { + await expect( + canvas.getByRole('columnheader', { name: 'Controls' }) + ).not.toHaveAttribute('aria-sort'); + + await userEvent.click( + canvas.getByRole('button', { name: 'filter by ship' }) + ); + const menu = await screen.findByRole('list'); + await expect( + within(menu).getByRole('checkbox', { name: 'Select All ship' }) + ).toBeChecked(); + for (const ship of shipFilters) { + await expect( + within(menu).getByRole('checkbox', { name: ship }) + ).toBeChecked(); + } + + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('list')).toBeNull()); + }, +}; + +export const EmptyState: Story = { + args: { + rows: [], + shadow: true, + scrollable: true, + height: '45vh', + minHeight: '300px', + }, + play: async ({ args, canvas, userEvent }) => { + canvas.getByText('No Results Found'); + await userEvent.click( + canvas.getByRole('button', { name: 'Reset Filters' }) + ); + await expect(args.onQueryChange).toHaveBeenLastCalledWith({ + type: 'reset', + }); + }, +}; + +export const EmptyStateCustom: Story = { + args: { + rows: [], + shadow: true, + scrollable: true, + height: '45vh', + minHeight: '300px', + emptyMessage: , + }, + play: async ({ canvas }) => { + canvas.getByText('Nothing to see here!'); + await expect(canvas.queryByText('No Results Found')).toBeNull(); + }, +}; + +export const LoadingRows: Story = { + args: { loading: true, shadow: true }, + play: async ({ canvas }) => { + await expect(canvas.getAllByText('Loading data')).toHaveLength( + crewRows.length + ); + await expect(canvas.queryByText('Command & Diplomacy')).toBeNull(); + }, +}; + +export const Scrollable: Story = { + args: { + shadow: true, + scrollable: true, + height: '400px', + wrapperWidth: '800px', + }, + play: async ({ canvas }) => { + // TODO: wrapperWidth doesn't constrain DataTable, since a table treats width as a minimum + await expect(canvas.getByTestId('scrollable-crew')).toHaveStyle({ + maxWidth: '800px', + }); + }, +}; + +export const BackgroundColors: Story = { + args: {}, + render: (args) => ( + + + + ), +}; + +export const DisableContainerQuery: Story = { + args: {}, + render: () => ( + + } + disabledComponent={ + + } + /> + ), + play: async ({ canvasElement }) => { + await expect( + getComputedStyle(canvasElement.querySelector('#default-table-query')!) + .containerType + ).toBe('inline-size'); + await expect( + getComputedStyle(canvasElement.querySelector('#disabled-table-query')!) + .containerType + ).toBe('normal'); + }, +}; + +export const WithFloatingMenu: Story = { + args: { + id: 'crew-with-menu', + rows: moreCrewRows, + columns: [ + ...crewColumns, + { + header: 'Actions', + key: 'name', + size: 'sm', + justify: 'right', + type: 'control', + render: (row) => , + }, + ], + shadow: true, + scrollable: true, + height: '400px', + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click( + canvas.getAllByRole('button', { name: 'Show options' })[0] + ); + await userEvent.click( + await screen.findByRole('button', { name: 'Clone Jean Luc Picard' }) + ); + const dialog = await screen.findByRole('dialog'); + within(dialog).getByText('Clone Jean Luc Picard'); + await userEvent.click( + within(dialog).getByRole('button', { name: 'Cancel' }) + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + const wrapper = canvas.getByTestId('scrollable-crew-with-menu') + .parentElement!; + await waitFor(() => + expect(getComputedStyle(wrapper).backgroundSize).toBe('124px 100%') + ); + + const headerGroup = canvas.getAllByRole('rowgroup')[0]; + await expect(wrapper.scrollHeight).toBeGreaterThan(wrapper.clientHeight); + wrapper.scrollTop = 100; + await waitFor(() => expect(wrapper.scrollTop).toBe(100)); + await expect(headerGroup.getBoundingClientRect().top).toBe( + wrapper.getBoundingClientRect().top + ); + + const nameCell = canvas.getByText('Data').closest('th')!; + const nameLeft = nameCell.getBoundingClientRect().left; + wrapper.scrollLeft = 300; + await waitFor(() => expect(wrapper.scrollLeft).toBe(300)); + await expect( + Math.abs(nameCell.getBoundingClientRect().left - nameLeft) + ).toBeLessThanOrEqual(1); + + wrapper.scrollLeft = wrapper.scrollWidth; + await waitFor(() => + expect(getComputedStyle(wrapper).backgroundSize).toBe('0px 100%') + ); + }, +}; + +export const LocalSorting: Story = { + render: () => , + play: async ({ canvas, canvasElement, userEvent }) => { + /* + * TODO: clearing a sort leaves rows in ascending order instead of the original order. + * Sorting a second column also clears the first column's sort. + */ + const nameHeader = canvas.getByRole('columnheader', { name: /Name/ }); + const sortByName = canvas.getByRole('button', { name: 'sort by name' }); + await expect(firstRowName(canvasElement)).toBe('Jean Luc Picard'); + + await userEvent.click(sortByName); + await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'); + await expect(firstRowName(canvasElement)).toBe('Barclay'); + + await userEvent.click(sortByName); + await expect(nameHeader).toHaveAttribute('aria-sort', 'descending'); + await expect(firstRowName(canvasElement)).toBe('Worf'); + + await userEvent.click(sortByName); + await expect(nameHeader).toHaveAttribute('aria-sort', 'none'); + + sortByName.focus(); + await userEvent.keyboard('{Enter}'); + await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'); + + await userEvent.click(canvas.getByRole('button', { name: 'sort by role' })); + await expect( + canvas.getByRole('columnheader', { name: /Rank/ }) + ).toHaveAttribute('aria-sort', 'ascending'); + }, +}; + +export const LocalFiltering: Story = { + render: () => , + play: async ({ canvas, canvasElement, userEvent }) => { + // TODO: filtering a second column clears the first column's filter + const filterByShip = canvas.getByRole('button', { name: 'filter by ship' }); + await userEvent.click(filterByShip); + const shipMenu = await screen.findByRole('list'); + await expect(shipMenu).toContainElement( + shipMenu.ownerDocument.activeElement as HTMLElement + ); + + await userEvent.click( + within(shipMenu).getByRole('checkbox', { name: 'USS Enterprise' }) + ); + await expect( + within(shipMenu).getByRole('checkbox', { name: 'Select All ship' }) + ).not.toBeChecked(); + await expect(bodyRows(canvasElement)).toHaveLength(7); + await expect(canvas.queryByText('Jean Luc Picard')).toBeNull(); + + await userEvent.click( + within(shipMenu).getByRole('checkbox', { name: 'Select All ship' }) + ); + await expect(bodyRows(canvasElement)).toHaveLength(12); + await expect( + within(shipMenu).getByRole('checkbox', { name: 'USS Enterprise' }) + ).toBeChecked(); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('list')).toBeNull()); + await expect(filterByShip).toHaveFocus(); + + await userEvent.click( + canvas.getByRole('button', { name: 'filter by species' }) + ); + await userEvent.click( + within(await screen.findByRole('list')).getByRole('checkbox', { + name: 'Human', + }) + ); + await expect(canvas.queryByText('Beverly Crusher')).toBeNull(); + await userEvent.keyboard('{Escape}'); + }, +}; + +export const ResetFilters: Story = { + render: () => , + play: async ({ canvas, canvasElement, userEvent }) => { + await userEvent.click( + canvas.getByRole('button', { name: 'filter by ship' }) + ); + const shipMenu = await screen.findByRole('list'); + for (const ship of shipFilters) { + await userEvent.click( + within(shipMenu).getByRole('checkbox', { name: ship }) + ); + } + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('list')).toBeNull()); + + canvas.getByText('No Results Found'); + await userEvent.click( + canvas.getByRole('button', { name: 'Reset Filters' }) + ); + await expect(bodyRows(canvasElement)).toHaveLength(12); + await expect(canvas.queryByText('No Results Found')).toBeNull(); + }, +}; + +export const RowActions: Story = { + render: () => , + play: async ({ canvas, userEvent }) => { + await userEvent.click( + canvas.getAllByRole('button', { name: 'Show options' })[0] + ); + await userEvent.click( + await screen.findByRole('button', { name: 'Arrest Jean Luc Picard' }) + ); + + const dialog = await screen.findByRole('dialog'); + within(dialog).getByText('Arrest Crew Member'); + await userEvent.click( + within(dialog).getByRole('button', { name: 'Arrest' }) + ); + within(dialog).getByText('Jean Luc Picard has been arrested successfully.'); + await userEvent.click( + within(dialog).getByRole('button', { name: 'Close' }) + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }, +}; + +export const LoadingWithoutRows: Story = { + args: { loading: true, rows: [] }, + play: async ({ canvas, canvasElement }) => { + await expect(canvas.getAllByText('Loading data')).toHaveLength(5); + await expect(bodyRows(canvasElement)).toHaveLength(5); + await expect(canvas.queryByText('No Results Found')).toBeNull(); + }, +}; + +const ScrollToTopTemplate = createDemoTable(DataTable, { + height: 200, + onRowExpand: undefined, + onRowSelect: undefined, + scrollToTopOnUpdate: true, +}); + +export const ScrollToTopOnUpdate: Story = { + render: () => , + play: async ({ canvas, canvasElement, userEvent }) => { + // TODO: scrollToTopOnUpdate scrolls the table element, not the wrapper that actually scrolls + const wrapper = canvas.getByTestId('scrollable-example').parentElement!; + wrapper.scrollTop = 150; + await waitFor(() => expect(wrapper.scrollTop).toBe(150)); + + await userEvent.click(canvas.getByRole('button', { name: 'sort by name' })); + await expect(firstRowName(canvasElement)).toBe('Barclay'); + }, +}; diff --git a/packages/gamut/src/DataList/__tests__/DataGrid.test.tsx b/packages/gamut/src/DataList/__tests__/DataGrid.test.tsx deleted file mode 100644 index 7f8cc00328..0000000000 --- a/packages/gamut/src/DataList/__tests__/DataGrid.test.tsx +++ /dev/null @@ -1,668 +0,0 @@ -import { matchers } from '@emotion/jest'; -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; -import type { RenderResult } from '@testing-library/react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; - -import type { DataGridProps } from '../DataGrid'; -import { DataGrid } from '../DataGrid'; -import type { ColumnConfig } from '../types'; - -// Add the custom matchers provided by '@emotion/jest' -expect.extend(matchers); - -type Row = { id: string; name: string; sin: string }; -type Columns = ColumnConfig[]; - -type Props = DataGridProps; - -const onRowSelect = jest.fn(); -const onRowExpand = jest.fn(); -const onQueryChange = jest.fn(); - -const props = { - id: 'test', - idKey: 'id', - rows: [ - { id: '1', name: 'Hari Seldon', sin: 'Messiah Complex' }, - { id: '2', name: 'Bel Roise', sin: 'Competence' }, - { id: '3', name: 'Cleon II', sin: 'Hubris' }, - ], - columns: [{ key: 'name' }, { key: 'sin' }], - onRowSelect, - onRowExpand, - onQueryChange, - expandedContent: ({ row: { id } }) =>
Expanded {id}
, -} as Props; - -const renderView = setupRtl(DataGrid, props as any) as < - T extends Partial ->( - props?: T -) => { view: RenderResult }; - -describe('DataGrid', () => { - describe('Column Rendering', () => { - it('renders rows with multiple columns', () => { - renderView(); - - props.rows.forEach(({ name, sin }) => { - screen.getByText(name); - screen.getByText(sin); - }); - }); - it('allows custom rendered columns', () => { - renderView({ - columns: [ - { - key: 'name', - render: ({ id, name, sin }) => ( - - {name} - {sin} - - ), - }, - ], - }); - - props.rows.forEach(({ id }) => { - screen.getByTestId(id); - }); - }); - - it('allows custom header labels', () => { - renderView({ columns: [{ key: 'sin', header: 'Mortal Sin' }] }); - - screen.getByText('Mortal Sin'); - }); - }); - - describe('Selecting Rows', () => { - it('renders no checkboxes when no onRowSelect callback is passed', () => { - renderView({ onRowSelect: undefined }); - - expect(screen.queryByRole('checkbox')).toBeNull(); - }); - it("clicking the row's checkbox selects the row", () => { - renderView(); - - const checkbox = screen.getByRole('checkbox', { name: 'Select 1' }); - - act(() => { - fireEvent.click(checkbox); - }); - - expect(onRowSelect).toHaveBeenLastCalledWith({ - type: 'select', - payload: { rowId: '1', toggle: false }, - }); - }); - - it("clicking the row's checkbox deselects the row when the row is already selected", () => { - renderView({ selected: ['1'] }); - - const checkbox = screen.getByRole('checkbox', { name: 'Select 1' }); - - act(() => { - fireEvent.click(checkbox); - }); - - expect(onRowSelect).toHaveBeenLastCalledWith({ - type: 'select', - payload: { - rowId: '1', - toggle: true, - }, - }); - }); - - it('selecting another row adds the row to the selection', () => { - renderView({ selected: ['2'] }); - - const checkbox = screen.getByRole('checkbox', { name: 'Select 1' }); - - act(() => { - fireEvent.click(checkbox); - }); - - expect(onRowSelect).toHaveBeenLastCalledWith({ - type: 'select', - payload: { - rowId: '1', - toggle: false, - }, - }); - }); - - it('selects all rows when the header checkbox is clicked', () => { - renderView(); - - const checkbox = screen.getByRole('checkbox', { - name: 'Select All', - hidden: true, - }); - - act(() => { - fireEvent.click(checkbox); - }); - - expect(onRowSelect).toHaveBeenLastCalledWith({ - type: 'select-all', - payload: { toggle: false }, - }); - }); - it('it unselects all rows when the header checkbox is clicked and all rows are selected', () => { - renderView({ selected: ['1', '2', '3'] }); - - const checkbox = screen.getByRole('checkbox', { - name: 'Select All', - hidden: true, - }); - - act(() => { - fireEvent.click(checkbox); - }); - - expect(onRowSelect).toHaveBeenLastCalledWith({ - type: 'select-all', - payload: { toggle: true }, - }); - }); - - it('hides the select all checkmark when hideSelectAll is true', () => { - renderView({ hideSelectAll: true }); - - const checkbox = screen.queryByRole('checkbox', { name: 'test-all' }); - - expect(checkbox).toBeNull(); - }); - }); - - describe('Expanding Rows', () => { - it('Renders an expanded row when passed the correct id', () => { - renderView({ expanded: ['1'] }); - - screen.getByText('Expanded 1'); - screen.getByRole('button', { expanded: true }); - }); - - it('allows multiple expanded rows by default', () => { - renderView({ expanded: ['1', '2'] }); - - screen.getByText('Expanded 1'); - screen.getByText('Expanded 2'); - }); - - it('calls the onRowExpand callback when a collapsed row toggle is clicked', () => { - renderView(); - - expect(screen.queryByRole('button', { expanded: true })).toBeNull(); - - const expandButton = screen.getByRole('button', { name: `Expand 1 Row` }); - - act(() => { - fireEvent.click(expandButton); - }); - expect(onRowExpand).toHaveBeenLastCalledWith({ - payload: { - rowId: '1', - toggle: false, - }, - type: 'expand', - }); - }); - it('calls the onRowExpand with the id omitted when an expanded row toggle is clicked', () => { - renderView({ expanded: ['1'] }); - - const expandButton = screen.getByRole('button', { name: `Expand 1 Row` }); - - act(() => { - fireEvent.click(expandButton); - }); - - expect(onRowExpand).toHaveBeenLastCalledWith({ - type: 'expand', - payload: { - rowId: '1', - toggle: true, - }, - }); - }); - }); - - describe('Querying', () => { - describe('Sorting', () => { - it('renders the column header with an ascending label when sorted', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: { name: 'asc' } }, - }); - - screen.getByLabelText('ascending'); - }); - it('renders the column header with an descending label when sorted', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: { name: 'desc' } }, - }); - - screen.getByLabelText('descending'); - }); - it('renders the column header with no label when not sorted', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: {} }, - }); - - expect(screen.queryByText('sort by name')).toBeNull(); - }); - - it('calls onQueryChange with the column as desc when previously unsorted', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: {} }, - }); - act(() => { - fireEvent.click(screen.getByLabelText('sort by name')); - }); - - expect(onQueryChange).toHaveBeenLastCalledWith({ - type: 'sort', - payload: { - dimension: 'name', - value: 'asc', - }, - }); - }); - - it('calls onQueryChange with the column as desc when previously asc', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: { name: 'asc' } }, - }); - act(() => { - fireEvent.click(screen.getByLabelText('sort by name')); - }); - - expect(onQueryChange).toHaveBeenLastCalledWith({ - type: 'sort', - payload: { - dimension: 'name', - value: 'desc', - }, - }); - }); - - it('calls onQueryChange with the column unsorted when previously desc', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: { name: 'desc' } }, - }); - act(() => { - fireEvent.click(screen.getByLabelText('sort by name')); - }); - - expect(onQueryChange).toHaveBeenLastCalledWith({ - type: 'sort', - payload: { - dimension: 'name', - value: 'none', - }, - }); - }); - - it('preserves multiple dimensions of sorted columns', () => { - renderView({ - columns: [ - { key: 'name', sortable: true }, - { key: 'sin', sortable: true }, - ], - query: { sort: { name: 'desc', sin: 'asc' } }, - }); - act(() => { - fireEvent.click(screen.getByLabelText('sort by sin')); - }); - - expect(onQueryChange).toHaveBeenLastCalledWith({ - type: 'sort', - payload: { - dimension: 'sin', - value: 'desc', - }, - }); - }); - - it('sets aria-sort="ascending" on column header when sorted ascending', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: { name: 'asc' } }, - }); - - const nameHeader = screen.getByRole('columnheader', { name: /name/i }); - expect(nameHeader.tagName).toBe('TH'); - expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'); - }); - - it('sets aria-sort="descending" on column header when sorted descending', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: { name: 'desc' } }, - }); - - const nameHeader = screen.getByRole('columnheader', { name: /name/i }); - expect(nameHeader.tagName).toBe('TH'); - expect(nameHeader).toHaveAttribute('aria-sort', 'descending'); - }); - - it('sets aria-sort="none" on column header when not sorted', () => { - renderView({ - columns: [{ key: 'name', sortable: true }], - query: { sort: {} }, - }); - - const nameHeader = screen.getByRole('columnheader', { name: /name/i }); - expect(nameHeader.tagName).toBe('TH'); - expect(nameHeader).toHaveAttribute('aria-sort', 'none'); - }); - - it('does not set aria-sort attribute on non-sortable column headers', () => { - renderView({ - columns: [{ key: 'name', sortable: false }], - query: { sort: {} }, - }); - - const nameHeader = screen.getByRole('columnheader', { name: /name/i }); - expect(nameHeader.tagName).toBe('TH'); - expect(nameHeader).not.toHaveAttribute('aria-sort'); - }); - - it('sets correct aria-sort values on multiple sortable columns', () => { - renderView({ - columns: [ - { key: 'name', sortable: true }, - { key: 'sin', sortable: true }, - ], - query: { sort: { name: 'asc', sin: 'desc' } }, - }); - - const nameHeader = screen.getByRole('columnheader', { name: /name/i }); - const sinHeader = screen.getByRole('columnheader', { name: /sin/i }); - - expect(nameHeader.tagName).toBe('TH'); - expect(sinHeader.tagName).toBe('TH'); - expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'); - expect(sinHeader).toHaveAttribute('aria-sort', 'descending'); - }); - }); - - describe('Filtering', () => { - it('opens a popup menu with selectable options', () => { - renderView({ - columns: [ - { - key: 'sin', - header: 'Some Filter', - filters: ['idk', 'dude'], - }, - ], - query: {}, - }); - - const button = screen.getByLabelText('filter by sin'); - - act(() => { - fireEvent.click(button); - }); - - screen.getByText('idk'); - screen.getByText('dude'); - }); - it('the menu selects all options if not filter query is specified', () => { - renderView({ - columns: [ - { - key: 'sin', - header: 'Some Filter', - filters: ['idk', 'dude'], - }, - ], - query: {}, - }); - - const button = screen.getByLabelText('filter by sin'); - - act(() => { - fireEvent.click(button); - }); - - screen.getByRole('checkbox', { - name: 'Select All sin', - checked: true, - hidden: true, - }); - screen.getByRole('checkbox', { - name: 'idk', - checked: true, - hidden: true, - }); - screen.getByRole('checkbox', { - name: 'dude', - checked: true, - hidden: true, - }); - }); - - it('calls onQueryChange when an option is clicked', () => { - renderView({ - columns: [ - { - key: 'sin', - header: 'Some Filter', - filters: ['idk', 'dude'], - }, - ], - query: {}, - }); - - const button = screen.getByLabelText('filter by sin'); - - act(() => { - fireEvent.click(button); - }); - - const checkbox = screen.getByRole('checkbox', { - name: 'idk', - hidden: true, - }); - - act(() => { - fireEvent.click(checkbox); - }); - - expect(onQueryChange).toHaveBeenLastCalledWith({ - type: 'filter', - payload: { - dimension: 'sin', - value: ['idk'], - }, - }); - }); - - it('unchecks Select All when a filter is present', () => { - renderView({ - columns: [ - { - key: 'sin', - header: 'Some Filter', - filters: ['idk', 'dude'], - }, - ], - query: { filter: { sin: ['idk'] } }, - }); - - const button = screen.getByLabelText('filter by sin'); - - act(() => { - fireEvent.click(button); - }); - - screen.getByRole('checkbox', { - name: 'idk', - checked: false, - hidden: true, - }); - screen.getByRole('checkbox', { - name: 'Select All sin', - checked: false, - hidden: true, - }); - }); - }); - - describe('Scroll behavior', () => { - it('resets scroll when scollToTopOnUpdate is true', () => { - const scrollMock = jest.fn(); - Element.prototype.scrollTo = scrollMock; - - renderView({ - columns: [ - { - key: 'sin', - header: 'Some Filter', - filters: ['idk', 'dude'], - }, - ], - query: {}, - scrollable: true, - scrollToTopOnUpdate: true, - height: '50px', - }); - - const container = screen.getByTestId('scrollable-test'); - - fireEvent.scroll(container, { - target: { scrollTop: container.scrollHeight }, - }); - - const button = screen.getByLabelText('filter by sin'); - - act(() => { - fireEvent.click(button); - }); - - expect(scrollMock).toHaveBeenCalled(); - }); - - it('does not reset scroll by default', () => { - const scrollMock = jest.fn(); - Element.prototype.scrollTo = scrollMock; - - renderView({ - columns: [ - { - key: 'sin', - header: 'Some Filter', - filters: ['idk', 'dude'], - }, - ], - query: {}, - scrollable: true, - }); - - const container = screen.getByTestId('scrollable-test'); - - fireEvent.scroll(container, { - target: { scrollTop: container.scrollHeight }, - }); - - const button = screen.getByLabelText('filter by sin'); - - act(() => { - fireEvent.click(button); - }); - - expect(scrollMock).not.toHaveBeenCalled(); - }); - }); - }); - - describe.each([ - { - useLogicalProperties: true, - widthProp: 'inlineSize', - maxWidthProp: 'maxInlineSize', - }, - { - useLogicalProperties: false, - widthProp: 'width', - maxWidthProp: 'maxWidth', - }, - ])( - 'wrapperWidth prop (useLogicalProperties: $useLogicalProperties)', - ({ useLogicalProperties, widthProp, maxWidthProp }) => { - const renderWithLogicalProps = (overrides?: Partial) => - render( - - - - ); - - it('applies wrapperWidth to the table container when provided', () => { - renderWithLogicalProps({ wrapperWidth: '600px' }); - - const tableContainer = screen.getByTestId('scrollable-test'); - expect(tableContainer).toHaveStyle({ - [maxWidthProp]: '600px', - [widthProp]: '600px', - }); - }); - - it('uses default width when wrapperWidth is not provided', () => { - renderWithLogicalProps(); - - const tableContainer = screen.getByTestId('scrollable-test'); - expect(tableContainer).toHaveStyle({ - [maxWidthProp]: '100%', - [widthProp]: 'inherit', - }); - }); - - it('passes wrapperWidth through to the underlying List component', () => { - renderWithLogicalProps({ wrapperWidth: '750px' }); - - const tableContainer = screen.getByTestId('scrollable-test'); - expect(tableContainer).toHaveStyle({ - [maxWidthProp]: '750px', - [widthProp]: '750px', - }); - }); - } - ); - - describe('Loading state', () => { - it('renders screenreader loading text for header type columns when loading', () => { - const { view } = renderView({ - loading: true, - rows: [], - columns: [{ key: 'name', type: 'header' }, { key: 'sin' }], - }); - - expect(view.getAllByText('Loading data')).toHaveLength(5); - }); - }); - - describe('Container query control', () => { - it('applies container query styles by default', () => { - const { view } = renderView(); - - const wrapper = view.container.querySelector('#test'); - expect(wrapper).toHaveStyleRule('container-type', 'inline-size'); - }); - - it('disables container queries when disableContainerQuery is true', () => { - const { view } = renderView({ disableContainerQuery: true }); - - const wrapper = view.container.querySelector('#test'); - expect(wrapper).toHaveStyleRule('container-type', 'normal'); - }); - }); -}); diff --git a/packages/styleguide/src/lib/Organisms/ListsAndTables/examples.tsx b/packages/gamut/src/DataList/testUtils.tsx similarity index 65% rename from packages/styleguide/src/lib/Organisms/ListsAndTables/examples.tsx rename to packages/gamut/src/DataList/testUtils.tsx index c96e5a0e8e..6f0cb66595 100644 --- a/packages/styleguide/src/lib/Organisms/ListsAndTables/examples.tsx +++ b/packages/gamut/src/DataList/testUtils.tsx @@ -1,18 +1,3 @@ -import type { ColumnConfig } from '@skillsoft/gamut'; -import { - Box, - DataList, - DataTable, - FillButton, - FlexBox, - IconButton, - Menu, - MenuItem, - Modal, - PopoverContainer, - Text, - useLocalQuery, -} from '@skillsoft/gamut'; import { MiniKebabMenuIcon, RocketIcon, @@ -20,42 +5,120 @@ import { } from '@skillsoft/gamut-icons'; import { BlueprintWhite } from '@skillsoft/gamut-illustrations'; import uniq from 'lodash/uniq'; -import type React from 'react'; +import type { ComponentType, ReactNode } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; +import { Box, FlexBox } from '../Box'; +import { FillButton, IconButton } from '../Button'; +import { Menu, MenuItem } from '../Menu'; +import { Modal } from '../Modals'; +import { PopoverContainer } from '../PopoverContainer'; +import { Text } from '../Typography'; +import { useLocalQuery } from './hooks/useLocalQuery'; +import type { ColumnConfig } from './types'; + +export const crew = [ + { + name: 'Jean Luc Picard', + role: 'Captain', + ship: 'USS Enterprise', + species: 'Human', + power: 'Monologues', + }, + { + name: 'Wesley Crusher', + role: 'Deus Ex Machina', + ship: 'USS Enterprise', + species: 'Mary Sue', + power: 'Being super preachy', + }, + { + name: 'Geordie LaForge', + role: 'Rascal', + ship: 'USS Enterprise', + species: 'Human', + power: 'Really bad a first dates', + }, + { + name: 'Data', + role: 'Scamp', + ship: 'USS Enterprise', + species: 'Android', + power: 'Annoying existentialism. Real magnet for zany adventures', + }, + { + name: 'Tasha Yar', + role: 'Fond Memory', + ship: "Yesterday's Enterprise", + species: 'Ectoplasm', + power: + 'Coming back to the show after getting written off as their own clone', + }, + { + name: 'Beverly Crusher', + role: 'Medical Officer', + ship: "Jean Luc's Dreams", + species: 'Human', + power: 'Knowing glances', + }, + { + name: 'William Riker', + role: 'Numba One', + ship: 'Pleasure Cruiser', + species: 'Hedonist', + power: 'Seduction of alien dignataries', + }, + { + name: 'Barclay', + role: 'Punching Bag', + ship: 'Titanic (Holodeck)', + species: 'Demigod', + power: 'Immunity to addiction therapies', + }, + { + name: 'Worf', + role: 'Security Officer', + ship: 'Just another victim of the ambient honorability', + species: 'Klingon', + power: 'Combusts if they act dishonorably', + }, + { + name: 'Q', + role: 'Costume Budget Line Item', + ship: 'USS Moral Quandry', + species: 'Q', + power: 'World class showman', + }, + { + name: 'Deanna Troi', + role: 'Hot Chocolate Enthusiast', + ship: 'USS Enterprise', + species: 'Human / Betazoid', + power: 'Really good at asking the ship for hot chocolate', + }, + { + name: 'Guinan', + role: "Picking up Deanna's slack", + ship: 'Saucer Section every few seasons', + species: "Q d'etat", + power: 'Quantum sensitivity, talk therapy', + }, +]; + +type CrewMember = (typeof crew)[number]; + interface MenuItemConfig { action: string; onClick: () => void; } -const MenuItemGenerator: React.FC<{ - items: MenuItemConfig[]; - name: string; -}> = ({ items, name }) => { - return ( - <> - {items.map((item) => ( - - - {`${item.action} ${name}`} - - - ))} - - ); -}; - -const CrewMgmtDropdown: React.FC<{ - row: (typeof crew)[1]; -}> = ({ row }) => { +const CrewMgmtDropdown = ({ row }: { row: CrewMember }) => { const [isOpen, setIsOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const menuButtonRef = useRef(null); const { name } = row; - const handleClose = () => { - setIsOpen(false); - }; + const handleClose = () => setIsOpen(false); const handleArrestClick = () => { setIsOpen(false); @@ -63,26 +126,11 @@ const CrewMgmtDropdown: React.FC<{ }; const menuItems: MenuItemConfig[] = [ - { - onClick: handleClose, - action: 'Edit', - }, - { - onClick: handleClose, - action: 'Fire', - }, - { - onClick: handleClose, - action: 'Clone', - }, - { - onClick: handleClose, - action: 'Disipline', - }, - { - onClick: handleArrestClick, - action: 'Arrest', - }, + { onClick: handleClose, action: 'Edit' }, + { onClick: handleClose, action: 'Fire' }, + { onClick: handleClose, action: 'Clone' }, + { onClick: handleClose, action: 'Discipline' }, + { onClick: handleArrestClick, action: 'Arrest' }, ]; return ( @@ -97,7 +145,6 @@ const CrewMgmtDropdown: React.FC<{ variant="secondary" onClick={() => setIsOpen(!isOpen)} /> - - + {menuItems.map((item) => ( + + + {`${item.action} ${name}`} + + + ))} - - {/* - Modal with multiple views: - - "confirm" (default): confirmation dialog - - "success": after arrest - - "error": example error state - */} { - // Simulate arrest logic - this will advance to next view - }, + onClick: () => {}, }, secondaryCta: { children: 'Cancel', actionType: 'cancel', - onClick: () => { - setIsModalOpen(false); - }, + onClick: () => setIsModalOpen(false), }, }, { @@ -151,9 +193,7 @@ const CrewMgmtDropdown: React.FC<{ primaryCta: { children: 'Close', actionType: 'confirm', - onClick: () => { - setIsModalOpen(false); - }, + onClick: () => setIsModalOpen(false), }, }, { @@ -166,22 +206,17 @@ const CrewMgmtDropdown: React.FC<{ primaryCta: { children: 'Back', actionType: 'confirm', - onClick: () => { - // This will close the modal and reset to first view - setIsModalOpen(false); - }, + onClick: () => setIsModalOpen(false), }, }, ]} - onRequestClose={() => { - setIsModalOpen(false); - }} + onRequestClose={() => setIsModalOpen(false)} /> ); }; -export const CustomEmptyState: React.FC = () => ( +export const CustomEmptyState = () => ( ( ); -const crew = [ - { - name: 'Jean Luc Picard', - role: 'Captain', - ship: 'USS Enterprise', - species: 'Human', - power: 'Monologues', - }, - { - name: 'Wesley Crusher', - role: 'Deus Ex Machina', - ship: 'USS Enterprise', - species: 'Mary Sue', - power: 'Being super preachy', - }, - { - name: 'Geordie LaForge', - role: 'Rascal', - ship: 'USS Enterprise', - species: 'Human', - power: 'Really bad a first dates', - }, - { - name: 'Data', - role: 'Scamp', - ship: 'USS Enterprise', - species: 'Android', - power: 'Annoying existentialism. Real magnet for zany adventures', - }, - { - name: 'Tasha Yar', - role: 'Fond Memory', - ship: "Yesterday's Enterprise", - species: 'Ectoplasm', - power: - 'Coming back to the show after getting written off as their own clone', - }, - { - name: 'Beverly Crusher', - role: 'Medical Officer', - ship: "Jean Luc's Dreams", - species: 'Human', - power: 'Knowing glances', - }, - { - name: 'William Riker', - role: 'Numba One', - ship: 'Pleasure Cruiser', - species: 'Hedonist', - power: 'Seduction of alien dignataries', - }, - { - name: 'Barclay', - role: 'Punching Bag', - ship: 'Titanic (Holodeck)', - species: 'Demigod', - power: 'Immunity to addiction therapies', - }, - { - name: 'Worf', - role: 'Security Officer', - ship: 'Just another victim of the ambient honorability', - species: 'Klingon', - power: 'Combusts if they act dishonorably', - }, - { - name: 'Q', - role: 'Costume Budget Line Item', - ship: 'USS Moral Quandry', - species: 'Q', - power: 'World class showman', - }, - { - name: 'Deanna Troi', - role: 'Hot Chocolate Enthusiast', - ship: 'USS Enterprise', - species: 'Human / Betazoid', - power: 'Really good at asking the ship for hot chocolate', - }, - { - name: 'Guinan', - role: "Picking up Deanna's slack", - ship: 'Saucer Section every few seasons', - species: "Q d'etat", - power: 'Quantum sensitivity, talk therapy', - }, -]; -const shipFilters = uniq(crew.map(({ ship }) => ship)); +export const shipFilters = uniq(crew.map(({ ship }) => ship)); -export const cols = [ +export const cols: ColumnConfig[] = [ { header: 'Name', key: 'name', @@ -338,17 +286,13 @@ export const cols = [ type: 'control', render: (row) => , }, -] as ColumnConfig<(typeof crew)[number]>[]; +]; export const createDemoTable = - (Component: any, overrides = {}) => + (Component: ComponentType, overrides: Record = {}) => () => { - const [selectedRows, setSelectedRows] = useState< - (typeof crew)[number]['name'][] - >([]); - const [expandedRows, setExpandedRows] = useState< - (typeof crew)[number]['name'][] - >([]); + const [selectedRows, setSelectedRows] = useState([]); + const [expandedRows, setExpandedRows] = useState([]); const { idKey, query, rows, onQueryChange } = useLocalQuery({ idKey: 'name', @@ -367,17 +311,15 @@ export const createDemoTable = payload: { toggle: boolean; rowId: string }; }) => { if (type === 'select') { - return setSelectedRows((prev = []) => { - return toggle - ? prev?.filter((id) => id !== rowId) - : [...prev, rowId]; - }); + return setSelectedRows((prev) => + toggle ? prev.filter((id) => id !== rowId) : [...prev, rowId] + ); } if (type === 'select-all') { return setSelectedRows(toggle ? [] : allIds); } }, - [setSelectedRows, allIds] + [allIds] ); const onRowExpand = useCallback( @@ -385,11 +327,10 @@ export const createDemoTable = payload: { toggle, rowId }, }: { payload: { toggle: boolean; rowId: string }; - }) => { - return setExpandedRows((prev = []) => { - return toggle ? prev?.filter((id) => id !== rowId) : [...prev, rowId]; - }); - }, + }) => + setExpandedRows((prev) => + toggle ? prev.filter((id) => id !== rowId) : [...prev, rowId] + ), [] ); @@ -427,69 +368,18 @@ export const createDemoTable = ); }; -export const DataTableTemplate = createDemoTable(DataTable, { - onRowSelect: undefined, - onRowExpand: undefined, -}); - -export const DataListTemplate = createDemoTable(DataList, { - scrollable: false, - height: 'auto', - showOverflow: true, -}); - -export interface DisableContainerQueryExampleProps { - defaultComponent: React.ReactNode; - disabledComponent: React.ReactNode; - componentName: string; -} - -export const DisableContainerQueryExample: React.FC< - DisableContainerQueryExampleProps -> = ({ defaultComponent, disabledComponent, componentName }) => { - return ( - - - - Default (Container Queries Enabled) - - - {defaultComponent} - - - Container queries apply responsive behavior based on the{' '} - {componentName} 's container width - - - - - - With Container Queries Disabled - - - {disabledComponent} - - - Built in responsive behavior is disabled - custom responsive behavior - is required - - - - ); -}; - export const simpleRows = [ { name: 'Jean Luc Picard', role: 'Captain', ship: 'USS Enterprise' }, { name: 'Wesley Crusher', role: 'Deus Ex Machina', ship: 'USS Enterprise' }, ]; -export const simpleColumns = [ - { key: 'name', header: 'Name', type: 'header', size: 'md' } as const, +export const simpleColumns: ColumnConfig<(typeof simpleRows)[number]>[] = [ + { key: 'name', header: 'Name', type: 'header', size: 'md' }, { key: 'role', header: 'Role', fill: true, justify: 'right' }, { header: 'Controls', key: 'name', - size: 'xs', + size: 'sm', type: 'control', render: () => ( <> @@ -506,5 +396,41 @@ export const simpleColumns = [ ), }, - , ]; + +export const DisableContainerQueryExample = ({ + componentName, + defaultComponent, + disabledComponent, +}: { + componentName: string; + defaultComponent: ReactNode; + disabledComponent: ReactNode; +}) => ( + + + + Default (Container Queries Enabled) + + + {defaultComponent} + + + Container queries apply responsive behavior based on the {componentName} + 's container width + + + + + With Container Queries Disabled + + + {disabledComponent} + + + Built in responsive behavior is disabled - custom responsive behavior is + required + + + +); diff --git a/packages/gamut/src/DatePicker/DatePicker.stories.tsx b/packages/gamut/src/DatePicker/DatePicker.stories.tsx new file mode 100644 index 0000000000..1630499afb --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,1323 @@ +import type { Decorator, Meta, StoryObj } from '@storybook/react'; +import { useRef, useState } from 'react'; +import { expect, fn, screen, waitFor, within } from 'storybook/test'; + +import { Box } from '../Box'; +import { FillButton } from '../Button'; +import { PopoverContainer } from '../PopoverContainer'; +import { + DatePicker, + DatePickerCalendar, + DatePickerInput, + matchDisabledDates, + useDatePicker, +} from '.'; +import { getMonthGrid } from './DatePickerCalendar/Calendar/utils/dateGrid'; +import { formatDateForAriaLabel } from './DatePickerCalendar/Calendar/utils/format'; +import { formatDateISO8601DateOnly } from './DatePickerInput/utils'; +import { getIsoFirstDayFromLocale } from './utils/locale'; + +const storybookLocaleOptions = [ + undefined, + 'en', + 'en-US', + 'en-GB', + 'de-DE', + 'es', + 'es-ES', + 'fr-FR', + 'ja-JP', + 'pt-BR', + 'zh-CN', + 'ko-KR', + 'it-IT', + 'nl-NL', + 'pl-PL', + 'ru-RU', + 'sv-SE', + 'tr-TR', + 'hi-IN', + 'ar', + 'ar-SA', + 'he-IL', + 'th-TH', +] as const; + +const meta: Meta = { + title: 'Inputs & forms/DatePicker', + component: DatePicker, + args: { + mode: 'single', + locale: 'en-US', + }, + argTypes: { + mode: { + description: 'Pick a single date or a range of dates.', + type: { + name: 'enum', + value: ['single', 'range'], + required: true, + }, + control: { type: 'radio' }, + table: { type: { summary: "'single' | 'range'" } }, + }, + locale: { + description: + 'BCP 47 language tag for `Intl` segment order, month names, and quick-action relative dates. `undefined` uses runtime default.', + control: { type: 'select' }, + options: [...storybookLocaleOptions], + }, + selectedDate: { + if: { arg: 'mode', eq: 'single' }, + control: false, + }, + onSelected: { + if: { arg: 'mode', eq: 'single' }, + control: false, + }, + startDate: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + endDate: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + onStartSelected: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + onEndSelected: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState( + args.mode === 'single' ? args.selectedDate ?? null : null + ); + const [startDate, setStartDate] = useState( + args.mode === 'range' ? args.startDate ?? null : null + ); + const [endDate, setEndDate] = useState( + args.mode === 'range' ? args.endDate ?? null : null + ); + + if (args.mode === 'range') { + return ( + { + setEndDate(date); + onEndSelected(date); + }} + onStartSelected={(date) => { + setStartDate(date); + onStartSelected(date); + }} + /> + ); + } + + return ( + { + setSelectedDate(date); + onSelected(date); + }} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +const onSelected = fn(); +const onStartSelected = fn(); +const onEndSelected = fn(); +const onQuickActionClick = fn(); +const onFormSubmit = fn(); + +const fixedMode = { mode: { control: false } } as const; + +const tallCanvas: Decorator[] = [ + (Story) => ( + + + + ), +]; + +type Queries = ReturnType; + +const getHiddenValue = (root: HTMLElement, name: string) => + root.querySelector(`input[type="hidden"][name="${name}"]`) + ?.value; + +const getSegment = ( + root: Queries, + field: 'month' | 'day' | 'year', + index = 0 +) => root.getAllByRole('spinbutton', { name: field })[index]; + +const clickOutside = async ( + canvasElement: HTMLElement, + user: { click: (element: Element) => Promise } +) => + waitFor(async () => { + await user.click(canvasElement); + await expect(screen.queryByRole('dialog')).toBeNull(); + }); + +const toStartOfDay = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +const addDays = (date: Date, days: number) => + new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); + +export const Default: Story = { + decorators: tallCanvas, +}; + +export const SingleDate: Story = { + args: { mode: 'single', placement: 'inline' }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, canvasElement, userEvent }) => { + canvas.getByText('Date'); + const group = canvas.getByRole('group'); + const labelledBy = group.getAttribute('aria-labelledby'); + await expect(canvasElement.querySelector(`#${labelledBy}`)).not.toBeNull(); + await expect(canvas.queryByRole('dialog')).toBeNull(); + + await userEvent.click(group); + await expect(canvas.getByRole('dialog')).toBeVisible(); + + const month = getSegment(canvas, 'month'); + month.focus(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('dialog')).toBeNull(); + await expect(month).toHaveFocus(); + + await userEvent.click( + canvas.getByRole('button', { name: 'Open calendar' }) + ); + await expect(canvas.getByRole('dialog')).toBeVisible(); + + canvas.getByRole('button', { name: 'Last month' }).focus(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('dialog')).toBeNull(); + + await userEvent.click(group); + await expect(canvas.getByRole('dialog')).toBeVisible(); + await clickOutside(canvasElement, userEvent); + }, +}; + +export const Range: Story = { + args: { mode: 'range', placement: 'inline' }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, userEvent }) => { + const groups = canvas.getAllByRole('group'); + await expect(groups).toHaveLength(2); + canvas.getByText('Start date'); + canvas.getByText('End date'); + + await userEvent.click(groups[1]); + const dialog = canvas.getByRole('dialog'); + await expect( + within(dialog).getByRole('button', { name: 'Clear' }) + ).toBeDisabled(); + within(dialog).getByRole('button', { name: 'Last 7 days' }); + }, +}; + +export const WithInitialDateSingle: Story = { + args: { + mode: 'single', + placement: 'floating', + selectedDate: new Date(2026, 1, 15), + }, + argTypes: fixedMode, + play: async ({ canvas, canvasElement, userEvent }) => { + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + '2026-02-15' + ); + await expect(getSegment(canvas, 'month')).toHaveAttribute( + 'aria-valuetext', + '02' + ); + + await userEvent.click(canvas.getByRole('group')); + const dialog = await screen.findByRole('dialog', { name: 'Choose date' }); + const feb15 = within(dialog).getByRole('gridcell', { + name: /February 15, 2026/, + }); + await expect(feb15).toHaveAttribute('aria-selected', 'true'); + + await userEvent.click( + within(dialog).getByRole('gridcell', { name: /February 20, 2026/ }) + ); + await expect(onSelected).toHaveBeenLastCalledWith(new Date(2026, 1, 20)); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + '2026-02-20' + ); + + await userEvent.click(canvas.getByRole('group')); + await userEvent.click( + await screen.findByRole('gridcell', { name: /February 20, 2026/ }) + ); + await expect(onSelected).toHaveBeenLastCalledWith(null); + await waitFor(() => + expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe('') + ); + }, +}; + +export const WithInitialDateRange: Story = { + args: { + mode: 'range', + placement: 'floating', + startDate: new Date(2026, 1, 15), + endDate: new Date(2026, 1, 20), + }, + argTypes: fixedMode, + play: async ({ canvas, canvasElement, userEvent }) => { + const [startGroup, endGroup] = canvas.getAllByRole('group'); + await userEvent.click(startGroup); + const dialog = await screen.findByRole('dialog'); + const cell = (day: number) => + within(dialog).getByRole('gridcell', { + name: new RegExp(`February ${day}, 2026`), + }); + + await expect(cell(14)).toHaveAttribute('aria-selected', 'false'); + await expect(cell(15)).toHaveAttribute('aria-selected', 'true'); + await expect(cell(17)).toHaveAttribute('aria-selected', 'true'); + await expect(cell(20)).toHaveAttribute('aria-selected', 'true'); + await expect(cell(21)).toHaveAttribute('aria-selected', 'false'); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Clear' }) + ); + await expect(onStartSelected).toHaveBeenLastCalledWith(null); + await expect(onEndSelected).toHaveBeenLastCalledWith(null); + await expect(cell(17)).toHaveAttribute('aria-selected', 'false'); + await expect( + within(dialog).getByRole('button', { name: 'Clear' }) + ).toBeDisabled(); + + await userEvent.click(cell(10)); + await expect(onStartSelected).toHaveBeenLastCalledWith( + new Date(2026, 1, 10) + ); + await expect(onEndSelected).toHaveBeenLastCalledWith(null); + screen.getByRole('dialog'); + + await userEvent.click(cell(20)); + await expect(onStartSelected).toHaveBeenLastCalledWith( + new Date(2026, 1, 10) + ); + await expect(onEndSelected).toHaveBeenLastCalledWith(new Date(2026, 1, 20)); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + await userEvent.click(endGroup); + await userEvent.click( + await screen.findByRole('gridcell', { name: /February 25, 2026/ }) + ); + await expect(onStartSelected).toHaveBeenLastCalledWith( + new Date(2026, 1, 10) + ); + await expect(onEndSelected).toHaveBeenLastCalledWith(new Date(2026, 1, 25)); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await expect(getHiddenValue(canvasElement, 'datePickerInputStart')).toBe( + '2026-02-10' + ); + await expect(getHiddenValue(canvasElement, 'datePickerInputEnd')).toBe( + '2026-02-25' + ); + }, +}; + +export const FloatingPlacement: Story = { + args: { mode: 'range', placement: 'floating' }, + argTypes: fixedMode, + play: async ({ canvas, canvasElement, userEvent }) => { + await userEvent.click(canvas.getAllByRole('group')[0]); + const dialog = await screen.findByRole('dialog'); + await expect(canvasElement.contains(dialog)).toBe(false); + + await clickOutside(canvasElement, userEvent); + }, +}; + +export const RangeSmall: Story = { + args: { mode: 'range', inputSize: 'small', placement: 'floating' }, + argTypes: fixedMode, +}; + +export const SingleDefaultQuickActions: Story = { + args: { mode: 'single', placement: 'floating' }, + argTypes: fixedMode, + play: async ({ canvas, canvasElement, userEvent }) => { + const today = new Date(); + await userEvent.click(canvas.getByRole('group')); + const dialog = await screen.findByRole('dialog'); + + await expect( + within(dialog).getByLabelText( + formatDateForAriaLabel({ + date: today, + locale: new Intl.Locale('en-US'), + }) + ) + ).toHaveAttribute('aria-current', 'date'); + within(dialog).getByRole('button', { name: 'Yesterday' }); + within(dialog).getByRole('button', { name: 'Tomorrow' }); + await expect( + within(dialog).queryByRole('button', { name: 'Clear' }) + ).toBeNull(); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Today' }) + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await expect(onSelected).toHaveBeenCalledTimes(1); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + formatDateISO8601DateOnly(toStartOfDay(today)) + ); + }, +}; + +export const RangeDefaultQuickActions: Story = { + args: { mode: 'range', placement: 'floating' }, + argTypes: fixedMode, + play: async ({ canvas, canvasElement, userEvent }) => { + await userEvent.click(canvas.getAllByRole('group')[0]); + const dialog = await screen.findByRole('dialog'); + within(dialog).getByRole('button', { name: 'Last 30 days' }); + within(dialog).getByRole('button', { name: 'Last 90 days' }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Last 7 days' }) + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await expect(onStartSelected).toHaveBeenCalledTimes(1); + await expect(getHiddenValue(canvasElement, 'datePickerInputEnd')).toBe( + formatDateISO8601DateOnly(new Date()) + ); + await expect( + getHiddenValue(canvasElement, 'datePickerInputStart') + ).not.toBe(''); + }, +}; + +export const SingleNoQuickActions: Story = { + args: { mode: 'single', quickActions: null, placement: 'floating' }, + argTypes: fixedMode, + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('group')); + const dialog = await screen.findByRole('dialog'); + const buttons = within(dialog).getAllByRole('button'); + await expect(buttons.map((button) => button.ariaLabel)).toEqual([ + 'Last month', + 'Next month', + ]); + }, +}; + +export const RangeNoQuickActions: Story = { + args: { mode: 'range', quickActions: null, placement: 'floating' }, + argTypes: fixedMode, + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getAllByRole('group')[0]); + const dialog = await screen.findByRole('dialog'); + within(dialog).getByRole('button', { name: 'Clear' }); + await expect( + within(dialog).queryByRole('button', { name: 'Last 7 days' }) + ).toBeNull(); + }, +}; + +export const SingleCustomQuickActions: Story = { + args: { + mode: 'single', + placement: 'floating', + quickActions: [ + { num: -3, timePeriod: 'day', displayText: '3 days ago' }, + { num: 0, timePeriod: 'day', displayText: 'Today' }, + { + num: 3, + timePeriod: 'day', + displayText: 'In 3 days', + onClick: onQuickActionClick, + }, + ], + }, + argTypes: fixedMode, + play: async ({ canvas, canvasElement, userEvent }) => { + await userEvent.click(canvas.getByRole('group')); + const dialog = await screen.findByRole('dialog'); + within(dialog).getByRole('button', { name: '3 days ago' }); + await expect( + within(dialog).queryByRole('button', { name: 'Yesterday' }) + ).toBeNull(); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'In 3 days' }) + ); + await expect(onQuickActionClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + formatDateISO8601DateOnly(addDays(new Date(), 3)) + ); + }, +}; + +export const RangeCustomQuickActions: Story = { + args: { + mode: 'range', + placement: 'floating', + quickActions: [ + { num: -7, timePeriod: 'day', displayText: 'Last 7 days' }, + { num: -14, timePeriod: 'day', displayText: 'Last 14 days' }, + { num: -30, timePeriod: 'day', displayText: 'Last 30 days' }, + ], + }, + argTypes: fixedMode, +}; + +export const MatchDisabledDates: Story = { + args: { + mode: 'range', + disableDate: matchDisabledDates([new Date(2026, 3, 22)]), + placement: 'floating', + }, + argTypes: fixedMode, +}; + +const calendarDate = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +export const Range30DayWindowFromStart: Story = { + args: { mode: 'range', placement: 'floating' }, + argTypes: fixedMode, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + return ( + { + if (startDate === null) { + return false; + } + const start = calendarDate(startDate); + const min = new Date(start); + min.setDate(min.getDate() - 30); + const max = new Date(start); + max.setDate(max.getDate() + 30); + const day = calendarDate(date); + return day < min || day > max; + }} + endDate={endDate} + mode="range" + startDate={startDate} + onEndSelected={setEndDate} + onStartSelected={setStartDate} + /> + ); + }, +}; + +export const RangeDisabledBeforeToday: Story = { + args: { + mode: 'range', + disableDate: (date) => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + return date < startOfToday; + }, + placement: 'floating', + }, + argTypes: fixedMode, +}; + +export const RangeDisabledMoreThan30DaysBeforeToday: Story = { + args: { + mode: 'range', + disableDate: (date) => { + const today = new Date(); + const thirtyDaysAgo = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 30 + ); + return date < thirtyDaysAgo; + }, + placement: 'floating', + }, + argTypes: fixedMode, +}; + +export const RangeDisabledWeekends: Story = { + args: { + mode: 'range', + disableDate: (date) => date.getDay() === 0 || date.getDay() === 6, + placement: 'floating', + }, + argTypes: fixedMode, +}; + +export const Locale: Story = { + args: { mode: 'single', locale: 'de-DE', placement: 'floating' }, + argTypes: fixedMode, + play: async ({ canvas }) => { + await expect( + canvas.getAllByRole('spinbutton').map((segment) => segment.ariaLabel) + ).toEqual(['day', 'month', 'year']); + }, +}; + +export const Translations: Story = { + args: { + mode: 'range', + locale: 'es', + translations: { + clearButtonText: 'Borrar', + dateLabel: 'Fecha', + startDateLabel: 'Fecha de inicio', + endDateLabel: 'Fecha de fin', + calendarDialogAriaLabel: 'Elegir fecha', + openCalendarLabel: 'Abrir calendario', + last7DaysDisplayText: 'Últimos 7 días', + last30DaysDisplayText: 'Últimos 30 días', + last90DaysDisplayText: 'Últimos 90 días', + invalidDate: 'Fecha no válida', + invalidDateIncomplete: 'Fecha incompleta', + invalidDateInvalidMonth: 'El mes debe estar entre 1 y 12', + invalidDateInvalidDay: '{{month}} no tiene {{day}} días', + invalidDateRollover: '{{month}} no tiene {{day}} días', + invalidDateNotAvailable: 'Esta fecha no está disponible', + invalidDateRangeContainsDisabledDate: + 'Este rango de fechas contiene fechas no disponibles', + }, + placement: 'floating', + }, + argTypes: fixedMode, + play: async ({ canvas, userEvent }) => { + canvas.getByText('Fecha de inicio'); + canvas.getByText('Fecha de fin'); + await expect( + canvas.getAllByRole('button', { name: 'Abrir calendario' }) + ).toHaveLength(2); + + await userEvent.click(canvas.getAllByRole('group')[0]); + const dialog = await screen.findByRole('dialog', { name: 'Elegir fecha' }); + within(dialog).getByRole('button', { name: 'Borrar' }); + within(dialog).getByRole('button', { name: 'Últimos 7 días' }); + }, +}; + +export const ComposedWithContext: Story = { + args: { mode: 'single', placement: 'floating' }, + argTypes: fixedMode, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + + + ); + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.getAllByRole('group')).toHaveLength(1); + await expect(screen.queryByRole('dialog')).toBeNull(); + + await userEvent.click(canvas.getByRole('group')); + const dialog = await screen.findByRole('dialog', { name: 'Choose date' }); + await expect(dialog).toHaveAttribute('id', 'calendar-dialog'); + }, +}; + +const ComposedDatePickerLayout: React.FC = () => { + const { isCalendarOpen, closeCalendar } = useDatePicker(); + const inputRef = useRef(null); + + return ( + <> + + + + + + + + ); +}; + +export const CustomInputLabels: Story = { + args: { mode: 'range' }, + argTypes: fixedMode, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + + + + + + ); + }, + play: ({ canvas }) => { + canvas.getByText('The Beginning'); + canvas.getByText('The End'); + }, +}; + +export const DisabledInput: Story = { + args: { mode: 'single' }, + argTypes: fixedMode, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + + + ); + }, + play: async ({ canvas, userEvent }) => { + canvas.getByText('Departure date'); + const month = getSegment(canvas, 'month'); + await expect(month).toHaveAttribute('aria-disabled', 'true'); + await expect(month).toHaveAttribute('tabIndex', '-1'); + + await userEvent.click(month); + await userEvent.keyboard('5'); + await expect(month).toHaveAttribute('aria-valuetext', 'MM'); + }, +}; + +export const KeyboardEntry: Story = { + args: { mode: 'single', placement: 'inline' }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, canvasElement, userEvent }) => { + const month = getSegment(canvas, 'month'); + const day = getSegment(canvas, 'day'); + const year = getSegment(canvas, 'year'); + await expect(month).toHaveAttribute('aria-valuetext', 'MM'); + await expect(day).toHaveAttribute('aria-valuetext', 'DD'); + await expect(year).toHaveAttribute('aria-valuetext', 'YYYY'); + + month.focus(); + await userEvent.keyboard('{ArrowRight}'); + await expect(day).toHaveFocus(); + await userEvent.keyboard('{ArrowRight}'); + await expect(year).toHaveFocus(); + await userEvent.keyboard('{ArrowLeft}'); + await expect(day).toHaveFocus(); + + month.focus(); + await userEvent.keyboard('{ArrowDown}'); + await expect(month).toHaveAttribute('aria-valuetext', '12'); + await userEvent.keyboard('{Backspace}'); + await expect(month).toHaveAttribute('aria-valuetext', '1'); + await userEvent.keyboard('{Backspace}'); + await expect(month).toHaveAttribute('aria-valuetext', 'MM'); + await userEvent.keyboard('{ArrowUp}'); + await expect(month).toHaveAttribute('aria-valuetext', '01'); + await userEvent.keyboard('{ArrowUp}'); + await expect(month).toHaveAttribute('aria-valuetext', '02'); + await userEvent.keyboard('{ArrowDown}'); + await expect(month).toHaveAttribute('aria-valuetext', '01'); + await userEvent.keyboard('{Backspace}{Backspace}'); + + await userEvent.keyboard('03'); + await expect(month).toHaveAttribute('aria-valuetext', '03'); + await expect(day).toHaveFocus(); + day.blur(); + await expect(await canvas.findByRole('alert')).toHaveTextContent( + 'Incomplete date' + ); + + day.focus(); + await userEvent.keyboard('15'); + await expect(year).toHaveFocus(); + await userEvent.keyboard('2024'); + await expect(canvas.queryByRole('alert')).toBeNull(); + await expect(onSelected).toHaveBeenLastCalledWith(new Date(2024, 2, 15)); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + '2024-03-15' + ); + + year.blur(); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + '2024-03-15' + ); + + month.focus(); + await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}'); + await expect(canvas.getByRole('dialog')).toBeVisible(); + await waitFor(() => + expect( + canvas.getByRole('gridcell', { name: /March 15, 2024/ }) + ).toHaveFocus() + ); + + day.focus(); + await userEvent.keyboard('16'); + await expect(year).toHaveFocus(); + await expect( + canvas.getByRole('gridcell', { name: /March 16, 2024/ }) + ).toHaveAttribute('aria-selected', 'true'); + + await userEvent.keyboard('{Escape}'); + year.focus(); + await userEvent.keyboard('{Backspace>4/}'); + day.focus(); + await userEvent.keyboard('{Backspace>2/}'); + month.focus(); + await userEvent.keyboard('{Backspace>2/}'); + await expect(onSelected).toHaveBeenLastCalledWith(null); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe(''); + + month.blur(); + await expect(canvas.queryByRole('alert')).toBeNull(); + await expect(month).toHaveAttribute('aria-valuetext', 'MM'); + await expect(day).toHaveAttribute('aria-valuetext', 'DD'); + await expect(year).toHaveAttribute('aria-valuetext', 'YYYY'); + }, +}; + +export const CalendarNavigation: Story = { + args: { + mode: 'single', + placement: 'inline', + selectedDate: new Date(2024, 2, 20), + disableDate: matchDisabledDates([new Date(2024, 2, 1)]), + }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, userEvent }) => { + const cell = (name: string) => + canvas.getByRole('gridcell', { name: new RegExp(name) }); + + await userEvent.click(canvas.getByRole('group')); + const grid = canvas.getByRole('grid', { name: 'March 2024' }); + const headers = within(grid).getAllByRole('columnheader'); + await expect(headers.map((header) => header.ariaLabel)).toEqual([ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]); + await expect(headers.map((header) => header.getAttribute('scope'))).toEqual( + Array(7).fill('col') + ); + await expect(within(grid).getAllByRole('gridcell')).toHaveLength( + getMonthGrid({ + year: 2024, + month: 2, + firstWeekday: getIsoFirstDayFromLocale(new Intl.Locale('en-US')), + }).flat().length + ); + + await expect(cell('March 20, 2024')).toHaveAttribute( + 'aria-selected', + 'true' + ); + await expect(cell('March 20, 2024')).toHaveAttribute('tabIndex', '0'); + await expect(cell('March 10, 2024')).toHaveAttribute('tabIndex', '-1'); + + await expect( + canvas.getAllByRole('button', { name: 'Last month' }) + ).toHaveLength(1); + await expect( + canvas.getAllByRole('button', { name: 'Next month' }) + ).toHaveLength(1); + await userEvent.click(canvas.getByRole('button', { name: 'Next month' })); + canvas.getByRole('grid', { name: 'April 2024' }); + await userEvent.click(canvas.getByRole('button', { name: 'Last month' })); + canvas.getByRole('grid', { name: 'March 2024' }); + + cell('March 31, 2024').focus(); + await userEvent.keyboard('{ArrowRight}'); + await expect(cell('April 1, 2024')).toHaveFocus(); + canvas.getByRole('grid', { name: 'March 2024' }); + await userEvent.keyboard('{ArrowLeft}'); + await expect(cell('March 31, 2024')).toHaveFocus(); + + canvas.getByRole('button', { name: 'Next month' }).focus(); + await userEvent.keyboard('{Enter}'); + canvas.getByRole('grid', { name: 'May 2024' }); + await userEvent.tab(); + await waitFor(() => expect(cell('April 1, 2024')).toHaveFocus()); + await userEvent.click(canvas.getByRole('button', { name: 'Last month' })); + canvas.getByRole('grid', { name: 'March 2024' }); + + const march1 = cell('March 1, 2024'); + await expect(march1).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(march1); + canvas.getByRole('dialog'); + march1.focus(); + await expect(march1).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + canvas.getByRole('dialog'); + await expect(onSelected).not.toHaveBeenCalled(); + + cell('March 20, 2024').focus(); + await userEvent.keyboard('{ArrowLeft}'); + await expect(cell('March 19, 2024')).toHaveFocus(); + await userEvent.keyboard('x'); + await expect(cell('March 19, 2024')).toHaveFocus(); + await userEvent.keyboard('{PageDown}'); + canvas.getByRole('grid', { name: 'April 2024' }); + await waitFor(() => expect(cell('April 19, 2024')).toHaveFocus()); + await userEvent.keyboard('{Enter}'); + await expect(onSelected).toHaveBeenLastCalledWith(new Date(2024, 3, 19)); + await waitFor(() => expect(canvas.queryByRole('dialog')).toBeNull()); + await expect(getSegment(canvas, 'month')).toHaveFocus(); + + await userEvent.click(canvas.getByRole('group')); + cell('April 19, 2024').focus(); + await userEvent.keyboard('{ArrowRight}'); + await expect(cell('April 20, 2024')).toHaveFocus(); + await userEvent.keyboard(' '); + await expect(onSelected).toHaveBeenLastCalledWith(new Date(2024, 3, 20)); + await waitFor(() => expect(canvas.queryByRole('dialog')).toBeNull()); + + await userEvent.click(canvas.getByRole('group')); + cell('April 20, 2024').focus(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('dialog')).toBeNull(); + }, +}; + +export const SingleMonthLayout: Story = { + args: { + mode: 'single', + placement: 'inline', + selectedDate: new Date(2024, 2, 20), + }, + argTypes: fixedMode, + decorators: tallCanvas, + globals: { viewport: { value: 'mobile2', isRotated: false } }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('group')); + canvas.getByRole('grid', { name: 'March 2024' }); + await expect(canvas.queryByRole('grid', { name: 'April 2024' })).toBeNull(); + await expect( + canvas.getAllByRole('button', { name: 'Last month' }) + ).toHaveLength(1); + await expect( + canvas.getAllByRole('button', { name: 'Next month' }) + ).toHaveLength(1); + + canvas.getByRole('gridcell', { name: /March 31, 2024/ }).focus(); + await userEvent.keyboard('{ArrowRight}'); + canvas.getByRole('grid', { name: 'April 2024' }); + await expect(canvas.queryByRole('grid', { name: 'March 2024' })).toBeNull(); + await waitFor(() => + expect( + canvas.getByRole('gridcell', { name: /April 1, 2024/ }) + ).toHaveFocus() + ); + }, +}; + +export const CalendarFollowsSelection: Story = { + args: { + mode: 'range', + placement: 'inline', + startDate: new Date(2024, 0, 10), + endDate: new Date(2024, 5, 5), + }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, userEvent }) => { + const [startGroup, endGroup] = canvas.getAllByRole('group'); + + await userEvent.click(endGroup); + canvas.getByRole('grid', { name: 'June 2024' }); + await userEvent.keyboard('{Escape}'); + + await userEvent.click(startGroup); + canvas.getByRole('grid', { name: 'January 2024' }); + + getSegment(within(startGroup), 'month').focus(); + await userEvent.keyboard('04'); + await expect(onStartSelected).toHaveBeenLastCalledWith( + new Date(2024, 3, 10) + ); + await waitFor(() => canvas.getByRole('grid', { name: 'April 2024' })); + await expect( + canvas.getByRole('gridcell', { name: /April 10, 2024/ }) + ).toHaveAttribute('aria-selected', 'true'); + }, +}; + +export const ErrorState: Story = { + args: { + mode: 'single', + placement: 'inline', + disableDate: matchDisabledDates([new Date(2024, 2, 20)]), + }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, userEvent }) => { + const month = getSegment(canvas, 'month'); + const day = getSegment(canvas, 'day'); + + month.focus(); + await userEvent.keyboard('13152024'); + await expect(canvas.getByRole('alert')).toHaveTextContent( + 'Month must be between 1 and 12' + ); + await expect(month).toHaveAttribute('aria-invalid', 'true'); + await expect(onSelected).not.toHaveBeenCalled(); + + month.focus(); + await userEvent.keyboard('03'); + await expect( + canvas.queryByText('Month must be between 1 and 12') + ).toBeNull(); + await expect(onSelected).toHaveBeenLastCalledWith(new Date(2024, 2, 15)); + + month.focus(); + await userEvent.keyboard('0230'); + await expect(canvas.getByRole('alert')).toHaveTextContent( + 'February does not have 30 days' + ); + + month.focus(); + await userEvent.keyboard('03'); + day.focus(); + await userEvent.keyboard('20'); + await expect(canvas.getByRole('alert')).toHaveTextContent( + 'This date is not available' + ); + await expect(onSelected).not.toHaveBeenCalledWith(new Date(2024, 2, 20)); + }, +}; + +export const RangeErrorState: Story = { + args: { + mode: 'range', + placement: 'inline', + startDate: new Date(2024, 2, 15), + disableDate: matchDisabledDates([new Date(2024, 2, 20)]), + }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, userEvent }) => { + const endGroup = within(canvas.getAllByRole('group')[1]); + + getSegment(endGroup, 'month').focus(); + await userEvent.keyboard('03252024'); + await expect(canvas.getByRole('alert')).toHaveTextContent( + 'This date range contains unavailable dates' + ); + await expect(onEndSelected).not.toHaveBeenCalled(); + + getSegment(endGroup, 'year').focus(); + await userEvent.keyboard('{Backspace>4/}'); + getSegment(endGroup, 'day').focus(); + await userEvent.keyboard('18'); + getSegment(endGroup, 'year').focus(); + await userEvent.keyboard('2024'); + await expect(canvas.queryByRole('alert')).toBeNull(); + await expect(onStartSelected).toHaveBeenLastCalledWith( + new Date(2024, 2, 15) + ); + await expect(onEndSelected).toHaveBeenLastCalledWith(new Date(2024, 2, 18)); + }, +}; + +export const RangeAcrossDisabledDates: Story = { + args: { + mode: 'range', + placement: 'inline', + startDate: new Date(2024, 2, 15), + disableDate: (date) => + [new Date(2024, 2, 20), addDays(new Date(), -3)].some( + (disabled) => disabled.getTime() === toStartOfDay(date).getTime() + ), + }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, userEvent }) => { + const cell = (name: string) => + canvas.getByRole('gridcell', { name: new RegExp(name) }); + + await userEvent.click(canvas.getAllByRole('group')[1]); + await expect(cell('March 20, 2024')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + + await userEvent.click(cell('March 25, 2024')); + await expect(onStartSelected).toHaveBeenLastCalledWith( + new Date(2024, 2, 25) + ); + await expect(onEndSelected).toHaveBeenLastCalledWith(null); + canvas.getByRole('dialog'); + await expect(cell('March 15, 2024')).toHaveAttribute( + 'aria-selected', + 'false' + ); + await expect(cell('March 25, 2024')).toHaveAttribute( + 'aria-selected', + 'true' + ); + + await userEvent.click(canvas.getByRole('button', { name: 'Last 7 days' })); + await expect(onStartSelected).toHaveBeenLastCalledWith( + toStartOfDay(new Date()) + ); + await expect(onEndSelected).toHaveBeenLastCalledWith(null); + await waitFor(() => expect(canvas.queryByRole('dialog')).toBeNull()); + }, +}; + +export const RangeTypedOutOfOrder: Story = { + args: { + mode: 'range', + placement: 'inline', + startDate: new Date(2024, 2, 10), + endDate: new Date(2024, 2, 15), + }, + argTypes: fixedMode, + decorators: tallCanvas, + play: async ({ canvas, canvasElement, userEvent }) => { + const [startGroup, endGroup] = canvas + .getAllByRole('group') + .map((group) => within(group)); + + getSegment(startGroup, 'day').focus(); + await userEvent.keyboard('20'); + await expect(onStartSelected).toHaveBeenLastCalledWith( + new Date(2024, 2, 20) + ); + await expect(onEndSelected).toHaveBeenLastCalledWith(null); + await expect(getSegment(endGroup, 'day')).toHaveAttribute( + 'aria-valuetext', + 'DD' + ); + await expect(getHiddenValue(canvasElement, 'datePickerInputEnd')).toBe(''); + }, +}; + +export const ControlledFromParent: Story = { + args: { mode: 'single', placement: 'inline' }, + argTypes: fixedMode, + decorators: tallCanvas, + render: function DatePickerControlledStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + <> + + + setSelectedDate(new Date(2024, 5, 1))}> + Set June 1, 2024 + + setSelectedDate(null)}> + Clear date + + + + ); + }, + play: async ({ canvas, canvasElement, userEvent }) => { + await userEvent.click( + canvas.getByRole('button', { name: 'Set June 1, 2024' }) + ); + await expect(getSegment(canvas, 'month')).toHaveAttribute( + 'aria-valuetext', + '06' + ); + await expect(getSegment(canvas, 'day')).toHaveAttribute( + 'aria-valuetext', + '01' + ); + await expect(getSegment(canvas, 'year')).toHaveAttribute( + 'aria-valuetext', + '2024' + ); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + '2024-06-01' + ); + + await userEvent.click(canvas.getByRole('group')); + canvas.getByRole('grid', { name: 'June 2024' }); + await userEvent.keyboard('{Escape}'); + + await userEvent.click(canvas.getByRole('button', { name: 'Clear date' })); + await expect(getSegment(canvas, 'month')).toHaveAttribute( + 'aria-valuetext', + 'MM' + ); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe(''); + }, +}; + +export const InForm: Story = { + args: { + mode: 'single', + selectedDate: new Date(2024, 2, 1), + translations: { dateLabel: 'Ship date' }, + }, + argTypes: fixedMode, + decorators: tallCanvas, + render: function DatePickerInFormStory(args) { + const [selectedDate, setSelectedDate] = useState( + args.mode === 'single' ? args.selectedDate : null + ); + return ( +
{ + e.preventDefault(); + onFormSubmit(Object.fromEntries(new FormData(e.currentTarget))); + }} + > + + + Save + + + ); + }, + play: async ({ canvas, canvasElement, userEvent }) => { + const form = canvas.getByRole('form', { name: 'Shipping' }); + within(form).getByRole('group'); + canvas.getByText('Ship date'); + await expect(getHiddenValue(canvasElement, 'datePickerInput')).toBe( + '2024-03-01' + ); + + await userEvent.click(canvas.getByRole('group')); + getSegment(canvas, 'month').focus(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('dialog')).toBeNull(); + + await userEvent.click(canvas.getByRole('group')); + canvas.getByRole('button', { name: 'Last month' }).focus(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('dialog')).toBeNull(); + + await userEvent.click(canvas.getByRole('group')); + await userEvent.click( + canvas.getByRole('gridcell', { name: /March 20, 2024/ }) + ); + await waitFor(() => expect(canvas.queryByRole('dialog')).toBeNull()); + await expect(onFormSubmit).not.toHaveBeenCalled(); + + await userEvent.click(canvas.getByRole('button', { name: 'Save' })); + await expect(onFormSubmit).toHaveBeenCalledTimes(1); + await expect(onFormSubmit).toHaveBeenCalledWith({ + datePickerInput: '2024-03-20', + }); + }, +}; + +export const RangeInForm: Story = { + args: { mode: 'range' }, + argTypes: fixedMode, + decorators: tallCanvas, + render: function DatePickerRangeInFormStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( +
{ + e.preventDefault(); + onFormSubmit(Object.fromEntries(new FormData(e.currentTarget))); + }} + > + + + Save + + + ); + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getAllByRole('group')[0]); + await expect(canvas.getByRole('dialog')).toBeVisible(); + canvas.getByRole('button', { name: 'Next month' }).focus(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('dialog')).toBeNull(); + await expect(onFormSubmit).not.toHaveBeenCalled(); + + await userEvent.click(canvas.getByRole('button', { name: 'Save' })); + await expect(onFormSubmit).toHaveBeenCalledWith({ + datePickerInputStart: '', + datePickerInputEnd: '', + }); + }, +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.test.tsx new file mode 100644 index 0000000000..7eb50ac626 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.test.tsx @@ -0,0 +1,25 @@ +import { setupRtl } from '@skillsoft/gamut-tests'; +import { waitFor } from '@testing-library/react'; + +import { CalendarBody } from './CalendarBody'; + +const renderView = setupRtl(CalendarBody, { + displayDate: new Date(2024, 2, 1), + selectedDate: null, + focusedDate: new Date(2024, 2, 15), + labelledById: 'cal-heading', + locale: 'en-US', + onDateSelect: jest.fn(), + onFocusedDateChange: jest.fn(), + onDisplayDateChange: jest.fn(), + onEscapeKeyPress: jest.fn(), +}); + +describe('CalendarBody', () => { + it('moves DOM focus to the focus target when focusGridSync is omitted (standalone)', async () => { + const { view } = renderView(); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + await waitFor(() => expect(march15).toHaveFocus()); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx deleted file mode 100644 index 5db30fb839..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; - -import { getIsoFirstDayFromLocale } from '../../../utils/locale'; -import { CalendarBody } from '../CalendarBody'; -import { getMonthGrid } from '../utils/dateGrid'; -import { formatDateForAriaLabel, getWeekdayNames } from '../utils/format'; - -const displayDate = new Date(2024, 2, 1); -const focusedDate = new Date(2024, 2, 15); -const mockOnDateSelect = jest.fn(); -const mockOnFocusedDateChange = jest.fn(); -const mockOnDisplayDateChange = jest.fn(); -const mockOnGridFocusRequestHandled = jest.fn(); -const mockOnEscapeKeyPress = jest.fn(); - -const defaultCalendarContainerRef = createRef(); - -const renderView = setupRtl(CalendarBody, { - displayDate, - selectedDate: null, - focusedDate, - labelledById: 'cal-heading', - locale: 'en-US', - onDateSelect: mockOnDateSelect, - onFocusedDateChange: mockOnFocusedDateChange, - onDisplayDateChange: mockOnDisplayDateChange, - onEscapeKeyPress: mockOnEscapeKeyPress, - focusGridSync: { - gridFocusRequested: false, - signal: false, - onGridFocusRequestHandled: mockOnGridFocusRequestHandled, - calendarContainerRef: defaultCalendarContainerRef, - }, -}); - -describe('CalendarBody', () => { - it('renders a grid labelled by the heading id', () => { - const { view } = renderView(); - - const grid = view.getByRole('grid'); - expect(grid).toHaveAttribute('aria-labelledby', 'cal-heading'); - }); - - it('selects a day when a gridcell is clicked', async () => { - const { view } = renderView(); - - const day15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); - await userEvent.click(day15); - - expect(mockOnDateSelect).toHaveBeenCalledTimes(1); - expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 15)); - }); - - it('fires onFocusedDateChange when a day receives focus', () => { - const { view } = renderView(); - - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - - expect(mockOnFocusedDateChange).toHaveBeenCalledTimes(1); - expect(mockOnFocusedDateChange).toHaveBeenCalledWith(new Date(2024, 2, 20)); - }); - - it('renders a DateCell with isToday true when the date is today', () => { - const today = new Date(); - const { view } = renderView({ - selectedDate: today, - displayDate: today, - focusedDate: today, - }); - - const todayCell = view.getByLabelText( - formatDateForAriaLabel({ - date: today, - locale: new Intl.Locale('en-US'), - }) - ); - expect(todayCell).toHaveAttribute('aria-current', 'date'); - }); - - it('renders a DateCell with isDisabled true when the date is disabled', () => { - const { view } = renderView({ - disableDate: (date) => - date.getFullYear() === 2024 && - date.getMonth() === 2 && - date.getDate() === 1, - }); - - const disabledCell = view.getByRole('gridcell', { name: /March 1, 2024/i }); - expect(disabledCell).toHaveAttribute('aria-disabled', 'true'); - }); - - it('does not select a day when a disabled gridcell is clicked', async () => { - const { view } = renderView({ - disableDate: (date) => - date.getFullYear() === 2024 && - date.getMonth() === 2 && - date.getDate() === 1, - }); - - const disabledCell = view.getByRole('gridcell', { name: /March 1, 2024/i }); - await userEvent.click(disabledCell); - - expect(mockOnDateSelect).not.toHaveBeenCalled(); - }); - - it('calls onFocusedDateChange when a disabled day cell receives focus', async () => { - const { view } = renderView({ - disableDate: (date) => - date.getFullYear() === 2024 && - date.getMonth() === 2 && - date.getDate() === 1, - }); - - const day1 = view.getByRole('gridcell', { name: /March 1, 2024/i }); - day1.focus(); - - expect(mockOnFocusedDateChange).toHaveBeenCalledWith(new Date(2024, 2, 1)); - }); - - it('renders a DateCell with isSelected true when the date is selected', () => { - const { view } = renderView({ selectedDate: new Date(2024, 2, 15) }); - - const selected = view.getByRole('gridcell', { name: /March 15, 2024/i }); - expect(selected).toHaveAttribute('aria-selected', 'true'); - }); - - it('renders a DateCell with isInRange true when the date is in the range', () => { - const { view } = renderView({ - selectedDate: new Date(2024, 2, 15), - endDate: new Date(2024, 2, 20), - }); - - const inRange = view.getByRole('gridcell', { name: /March 17, 2024/i }); - expect(inRange).toHaveAttribute('aria-selected', 'true'); - }); - - it('renders a DateCell with isRangeStart true when the date is the start of the range', () => { - const { view } = renderView({ - selectedDate: new Date(2024, 2, 15), - endDate: new Date(2024, 2, 20), - }); - - const rangeStart = view.getByRole('gridcell', { name: /March 15, 2024/i }); - expect(rangeStart).toHaveAttribute('aria-selected', 'true'); - }); - - it('renders a DateCell with isRangeEnd true when the date is the end of the range', () => { - const { view } = renderView({ - selectedDate: new Date(2024, 2, 15), - endDate: new Date(2024, 2, 20), - }); - - const rangeEnd = view.getByRole('gridcell', { name: /March 20, 2024/i }); - expect(rangeEnd).toHaveAttribute('aria-selected', 'true'); - }); - - it('moves DOM focus to the focus target and calls onGridFocusRequestHandled when grid focus is requested', async () => { - const { view } = renderView({ - focusGridSync: { - gridFocusRequested: true, - signal: false, - onGridFocusRequestHandled: mockOnGridFocusRequestHandled, - calendarContainerRef: createRef(), - }, - }); - - const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); - await waitFor(() => expect(march15).toHaveFocus()); - expect(mockOnGridFocusRequestHandled).toHaveBeenCalled(); - }); - - it('sets tabIndex 0 on the focus target day and -1 on other day cells', () => { - const { view } = renderView(); - - const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); - const march10 = view.getByRole('gridcell', { name: /March 10, 2024/i }); - expect(march15).toHaveAttribute('tabIndex', '0'); - expect(march10).toHaveAttribute('tabIndex', '-1'); - }); - - it('uses selectedDate as the roving focus target when focusedDate is null', () => { - const { view } = renderView({ - focusedDate: null, - selectedDate: new Date(2024, 2, 22), - }); - - const march22 = view.getByRole('gridcell', { name: /March 22, 2024/i }); - const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); - expect(march22).toHaveAttribute('tabIndex', '0'); - expect(march15).toHaveAttribute('tabIndex', '-1'); - }); - - it('moves DOM focus to the focus target when focusGridSync is omitted (standalone)', async () => { - const { view } = renderView({ - focusGridSync: undefined, - }); - - const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); - await waitFor(() => expect(march15).toHaveFocus()); - }); - - it('renders seven weekday column headers with full accessible names', () => { - const { view } = renderView(); - const locale = new Intl.Locale('en-US'); - const firstWeekday = getIsoFirstDayFromLocale(locale); - const fullNames = getWeekdayNames({ - format: 'long', - locale, - firstWeekday, - }); - - const headers = view.getAllByRole('columnheader'); - expect(headers).toHaveLength(7); - headers.forEach((th, i) => { - expect(th).toHaveAttribute('scope', 'col'); - expect(th).toHaveAccessibleName(fullNames[i]); - }); - }); - - it('renders one gridcell per calendar slot (days plus leading and trailing padding)', () => { - const { view } = renderView(); - - const firstWeekday = getIsoFirstDayFromLocale(new Intl.Locale('en-US')); - const weeks = getMonthGrid({ - year: 2024, - month: 2, - firstWeekday, - }); - const expectedCellCount = weeks.flat().length; - - expect(view.getAllByRole('gridcell')).toHaveLength(expectedCellCount); - }); - - describe('keyboard navigation (integration)', () => { - it('moves focus via ArrowLeft through keyHandler', async () => { - const { view } = renderView(); - - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - await userEvent.keyboard('{ArrowLeft}'); - - expect(mockOnFocusedDateChange).toHaveBeenCalledWith( - new Date(2024, 2, 19) - ); - }); - - it('updates focus and the visible month via PageDown through keyHandler', async () => { - const { view } = renderView(); - - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - await userEvent.keyboard('{PageDown}'); - - expect(mockOnFocusedDateChange).toHaveBeenLastCalledWith( - new Date(2024, 3, 20) - ); - expect(mockOnDisplayDateChange).toHaveBeenCalledWith( - new Date(2024, 3, 1) - ); - }); - - it('selects the focused day on Enter', async () => { - const { view } = renderView(); - - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - await userEvent.keyboard('{Enter}'); - - expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 20)); - }); - - it('selects the focused day when key is Space', () => { - const { view } = renderView(); - - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - fireEvent.keyDown(day20, { key: ' ' }); - - expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 20)); - }); - - it('does not select a disabled day on Enter', async () => { - const { view } = renderView({ - disableDate: (date) => - date.getFullYear() === 2024 && - date.getMonth() === 2 && - date.getDate() === 1, - }); - - const day1 = view.getByRole('gridcell', { name: /March 1, 2024/i }); - day1.focus(); - await userEvent.keyboard('{Enter}'); - - expect(mockOnDateSelect).not.toHaveBeenCalled(); - }); - - it('calls onEscapeKeyPress on Escape', async () => { - const { view } = renderView(); - - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - await userEvent.keyboard('{Escape}'); - - expect(mockOnEscapeKeyPress).toHaveBeenCalled(); - }); - - it('does not move focus after an unhandled key (beyond focus sync)', async () => { - const { view } = renderView(); - - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - expect(mockOnFocusedDateChange).toHaveBeenCalledWith( - new Date(2024, 2, 20) - ); - mockOnFocusedDateChange.mockClear(); - - await userEvent.keyboard('{x}'); - - expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx deleted file mode 100644 index c3d490f990..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import userEvent from '@testing-library/user-event'; - -import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../../utils/translations'; -import { CalendarFooter } from '../CalendarFooter'; - -const mockOnClearDate = jest.fn(); -const mockOnQuickActionClick = jest.fn(); - -const renderView = setupRtl(CalendarFooter, { - clearButton: { - onClick: mockOnClearDate, - }, - quickActions: [ - { - num: 0, - timePeriod: 'day', - displayText: 'Today', - onClick: mockOnQuickActionClick, - }, - ], -}); - -describe('CalendarFooter', () => { - it('renders null when there are no quick actions and no clear button', () => { - const { view } = renderView({ - clearButton: undefined, - quickActions: undefined, - }); - - expect(view.queryByRole('button')).toBeNull(); - }); - - describe('clear button', () => { - it('renders clear button when clearButton object is passed and calls onClick when clicked', async () => { - const { view } = renderView(); - - view.getByRole('button', { - name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, - }); - }); - - it('renders clear button with custom text when text is passed', () => { - const { view } = renderView({ - clearButton: { onClick: mockOnClearDate, text: 'Custom text' }, - }); - - view.getByRole('button', { name: 'Custom text' }); - }); - - it('calls onClick when clear button is clicked', async () => { - const { view } = renderView(); - - await userEvent.click( - view.getByRole('button', { - name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, - }) - ); - - expect(mockOnClearDate).toHaveBeenCalledTimes(1); - }); - - it('does not render clear button when clearButton object is not passed', () => { - const { view } = renderView({ clearButton: undefined }); - - expect( - view.queryByRole('button', { - name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, - }) - ).toBeNull(); - }); - }); - - describe('quick actions', () => { - it('renders quick actions when quickActions array is passed', () => { - const { view } = renderView(); - - view.getByRole('button', { name: 'Today' }); - }); - - it('does not render quick actions when quickActions array is not passed', () => { - const { view } = renderView({ quickActions: undefined }); - - expect(view.queryByRole('button', { name: 'Today' })).toBeNull(); - }); - - it('calls onClick when a quick action is clicked', async () => { - const { view } = renderView(); - - await userEvent.click(view.getByRole('button', { name: 'Today' })); - - expect(mockOnQuickActionClick).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx deleted file mode 100644 index 2e29e05573..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { CalendarHeader } from '../CalendarHeader'; - -const displayDate = new Date(2024, 2, 1); -const mockOnDisplayDateChange = jest.fn(); -const mockOnLastMonthClick = jest.fn(); -const mockOnNextMonthClick = jest.fn(); - -const renderView = setupRtl(CalendarHeader, { - displayDate, - headingId: 'cal-heading', - locale: 'en-US', - onDisplayDateChange: mockOnDisplayDateChange, - onLastMonthClick: mockOnLastMonthClick, - onNextMonthClick: mockOnNextMonthClick, -}); - -describe('CalendarHeader', () => { - it('renders the month/year title with the given id', () => { - const { view } = renderView(); - - const title = view.container.querySelector('#cal-heading'); - expect(title).toBeInTheDocument(); - expect(title).toHaveTextContent('March 2024'); - }); - - it('renders last month and next month nav buttons when hideLastNav and hideNextNav are false', () => { - const { view } = renderView(); - - view.getByRole('button', { name: 'Last month' }); - view.getByRole('button', { name: 'Next month' }); - }); - - it('hides last nav when hideLastNav is true', () => { - const { view } = renderView({ hideLastNav: true }); - - expect(view.queryByRole('button', { name: 'Last month' })).toBeNull(); - view.getByRole('button', { name: 'Next month' }); - }); - - it('hides next nav when hideNextNav is true', () => { - const { view } = renderView({ hideNextNav: true }); - - expect(view.queryByRole('button', { name: 'Next month' })).toBeNull(); - view.queryByRole('button', { name: 'Last month' }); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx deleted file mode 100644 index c778296c25..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import userEvent from '@testing-library/user-event'; - -import { CalendarNavLastMonth } from '../CalendarNavLastMonth'; -import { CalendarNavNextMonth } from '../CalendarNavNextMonth'; - -const onLastDisplayChange = jest.fn(); -const mockOnLastMonthClick = jest.fn(); -const renderLast = setupRtl(CalendarNavLastMonth, { - displayDate: new Date(2024, 5, 15), - locale: 'en-US', - onDisplayDateChange: onLastDisplayChange, - onLastMonthClick: mockOnLastMonthClick, -}); - -const onNextDisplayChange = jest.fn(); -const mockOnNextMonthClick = jest.fn(); -const renderNext = setupRtl(CalendarNavNextMonth, { - displayDate: new Date(2024, 5, 15), - locale: 'en-US', - onDisplayDateChange: onNextDisplayChange, - onNextMonthClick: mockOnNextMonthClick, -}); - -describe('CalendarNavLastMonth', () => { - it('moves displayDate to the first day of the previous month', async () => { - const { view } = renderLast(); - - await userEvent.click(view.getByRole('button')); - - expect(onLastDisplayChange).toHaveBeenCalledTimes(1); - expect(onLastDisplayChange).toHaveBeenCalledWith(new Date(2024, 4, 1)); - expect(mockOnLastMonthClick).toHaveBeenCalledTimes(1); - }); -}); - -describe('CalendarNavNextMonth', () => { - it('moves displayDate to the first day of the next month', async () => { - const { view } = renderNext(); - - await userEvent.click(view.getByRole('button')); - - expect(onNextDisplayChange).toHaveBeenCalledTimes(1); - expect(onNextDisplayChange).toHaveBeenCalledWith(new Date(2024, 6, 1)); - expect(mockOnNextMonthClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.test.ts similarity index 99% rename from packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.test.ts index 023667dc1f..1ad2ff9b96 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.test.ts @@ -7,7 +7,7 @@ import { isDateWithinVisibleMonths, isSameDay, matchDisabledDates, -} from '../dateGrid'; +} from './dateGrid'; describe('getWeekdayOffsetInGrid', () => { it('returns 0 when the 1st matches the grid first weekday (Monday)', () => { diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.test.ts similarity index 99% rename from packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.test.ts index 3fe4353b50..2845a7c6ac 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.test.ts @@ -5,7 +5,7 @@ import { getRelativeMonthLabels, getRelativeTodayLabel, getWeekdayNames, -} from '../format'; +} from './format'; const enUS = new Intl.Locale('en-US'); const frFR = new Intl.Locale('fr-FR'); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.test.tsx similarity index 99% rename from packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.test.tsx index fc3c5ecea3..7e82005d97 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.test.tsx @@ -1,8 +1,8 @@ import type { KeyboardEvent } from 'react'; -import { getDatesWithRow, getMonthGrid, matchDisabledDates } from '../dateGrid'; -import type { KeyHandlerParams } from '../keyHandler'; -import { keyHandler } from '../keyHandler'; +import { getDatesWithRow, getMonthGrid, matchDisabledDates } from './dateGrid'; +import type { KeyHandlerParams } from './keyHandler'; +import { keyHandler } from './keyHandler'; const makeEvent = ( key: string, diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx deleted file mode 100644 index 406c7dc3d9..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { FC } from 'react'; - -import { DatePickerProvider } from '../../DatePickerContext'; -import { - createMockRangeContext, - createMockSingleContext, -} from '../../DatePickerContext/__tests__/mockContexts'; -import type { DatePickerContextValue } from '../../DatePickerContext/types'; -import { DatePickerCalendar } from '..'; - -jest.mock('react-use', () => { - const actual = jest.requireActual('react-use'); - return { - ...actual, - /** One-month layout in tests (stable gridcell queries). */ - useMedia: jest.fn(() => false), - }; -}); - -type CalendarHarnessProps = { context: DatePickerContextValue }; - -const DatePickerCalendarHarness: FC = ({ context }) => ( - - - -); - -const renderCalendar = setupRtl(DatePickerCalendarHarness, { - context: createMockSingleContext({ - isCalendarOpen: true, - selectedDate: new Date(2024, 2, 1), - }), -}); - -describe('DatePickerCalendar', () => { - it('throws when rendered without DatePickerProvider', () => { - expect(() => - render( - - - - ) - ).toThrow(/useDatePickerContext must be used within a DatePicker/); - }); - - it('renders a calendar grid when the picker context is open', () => { - const { view } = renderCalendar(); - - expect(view.getByRole('grid')).toBeInTheDocument(); - }); - - it('calls setSelection when a day cell is activated in single mode', async () => { - const onSelection = jest.fn(); - const { view } = renderCalendar({ - context: createMockSingleContext({ - isCalendarOpen: true, - selectedDate: new Date(2024, 2, 1), - onSelection, - }), - }); - - const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - await userEvent.click(march20); - - expect(onSelection).toHaveBeenCalledWith(new Date(2024, 2, 20)); - }); - - it('calls onSelection(null) when the already-selected day is clicked again in single mode', async () => { - const onSelection = jest.fn(); - const selected = new Date(2024, 2, 20); - const { view } = renderCalendar({ - context: createMockSingleContext({ - isCalendarOpen: true, - selectedDate: selected, - onSelection, - }), - }); - - const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - await userEvent.click(march20); - - expect(onSelection).toHaveBeenCalledWith(null); - }); - - it('calls closeCalendar after selecting a date in single mode', async () => { - const closeCalendar = jest.fn(); - const { view } = renderCalendar({ - context: createMockSingleContext({ - isCalendarOpen: true, - selectedDate: new Date(2024, 2, 1), - closeCalendar, - }), - }); - - const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - await userEvent.click(march20); - - expect(closeCalendar).toHaveBeenCalled(); - }); - - it('invokes closeCalendar when Escape is pressed on the grid', async () => { - const closeCalendar = jest.fn(); - const { view } = renderCalendar({ - context: createMockSingleContext({ - isCalendarOpen: true, - selectedDate: new Date(2024, 2, 1), - closeCalendar, - }), - }); - - const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); - march15.focus(); - await userEvent.keyboard('{Escape}'); - - expect(closeCalendar).toHaveBeenCalled(); - }); - - it('calls onRangeSelection with a new start when the start field is active and a day is chosen', async () => { - const onRangeSelection = jest.fn(); - const { view } = renderCalendar({ - context: createMockRangeContext({ - isCalendarOpen: true, - startDate: new Date(2024, 2, 1), - endDate: null, - activeRangePart: 'start', - onRangeSelection, - }), - }); - - const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - await userEvent.click(march20); - - expect(onRangeSelection).toHaveBeenCalledWith(new Date(2024, 2, 20), null); - }); - - it('calls setSelection in range mode when choosing an end date with the end field active', async () => { - const onRangeSelection = jest.fn(); - const { view } = renderCalendar({ - context: createMockRangeContext({ - isCalendarOpen: true, - startDate: new Date(2024, 2, 1), - endDate: null, - activeRangePart: 'end', - onRangeSelection, - }), - }); - - const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - await userEvent.click(march20); - - expect(onRangeSelection).toHaveBeenCalledWith( - new Date(2024, 2, 1), - new Date(2024, 2, 20) - ); - }); - - it('calls closeCalendar in range mode when a full range is selected', async () => { - const closeCalendar = jest.fn(); - const { view } = renderCalendar({ - context: createMockRangeContext({ - isCalendarOpen: true, - startDate: new Date(2024, 2, 1), - endDate: null, - activeRangePart: 'end', - closeCalendar, - }), - }); - - const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - await userEvent.click(march20); - - expect(closeCalendar).toHaveBeenCalled(); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.test.ts similarity index 99% rename from packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.test.ts index 3c4a14c70d..15f092af9e 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.test.ts @@ -1,10 +1,10 @@ -import { matchDisabledDates } from '../../Calendar/utils/dateGrid'; +import { matchDisabledDates } from '../Calendar/utils/dateGrid'; import { applyRangeOrNewStart, handleDateSelectRange, handleDateSelectSingle, rangeContainsDisabled, -} from '../dateSelect'; +} from './dateSelect'; const createDate = (y: number, month: number, day: number) => new Date(y, month, day); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.test.ts similarity index 98% rename from packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.test.ts index bd5a664e3a..1765f1e5a4 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.test.ts @@ -1,4 +1,4 @@ -import { computeQuickAction } from '../quickActions'; +import { computeQuickAction } from './quickActions'; describe('computeQuickAction', () => { const fixed = new Date(2026, 4, 15); // May 15, 2026 local (“today” anchor) diff --git a/packages/gamut/src/DatePicker/DatePickerContext/DatePickerContext.test.tsx b/packages/gamut/src/DatePicker/DatePickerContext/DatePickerContext.test.tsx new file mode 100644 index 0000000000..ef0197a938 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/DatePickerContext.test.tsx @@ -0,0 +1,129 @@ +import { MockGamutProvider } from '@skillsoft/gamut-tests'; +import { render } from '@testing-library/react'; + +import { DatePickerCalendar } from '../DatePickerCalendar'; +import { DatePickerInput } from '../DatePickerInput'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../utils/translations'; +import { DatePickerProvider, useDatePicker } from '.'; +import type { + DatePickerRangeContextValue, + DatePickerSingleContextValue, +} from './types'; + +const createMockSingleContext = ( + overrides: Partial = {} +): DatePickerSingleContextValue => ({ + mode: 'single', + locale: new Intl.Locale('en-US'), + isCalendarOpen: false, + openCalendar: jest.fn(), + focusCalendar: jest.fn(), + focusGridSignal: false, + gridFocusRequested: false, + clearGridFocusRequest: jest.fn(), + closeCalendar: jest.fn(), + translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, + quickActions: [], + hasError: false, + setHasError: jest.fn(), + selectedDate: new Date(2024, 2, 15), + onSelection: jest.fn(), + ...overrides, +}); + +const createMockRangeContext = ( + overrides: Partial = {} +): DatePickerRangeContextValue => ({ + mode: 'range', + locale: new Intl.Locale('en-US'), + isCalendarOpen: false, + openCalendar: jest.fn(), + focusCalendar: jest.fn(), + focusGridSignal: false, + gridFocusRequested: false, + clearGridFocusRequest: jest.fn(), + closeCalendar: jest.fn(), + translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, + quickActions: [], + hasError: false, + setHasError: jest.fn(), + startDate: null, + endDate: null, + onRangeSelection: jest.fn(), + activeRangePart: null, + setActiveRangePart: jest.fn(), + ...overrides, +}); + +const Consumer = () => { + const ctx = useDatePicker(); + return {ctx.mode}; +}; + +const RangeDatesConsumer = () => { + const ctx = useDatePicker(); + if (ctx.mode !== 'range') return null; + return ( + + {ctx.startDate?.toDateString() ?? 'no-start'}| + {ctx.endDate?.toDateString() ?? 'no-end'} + + ); +}; + +describe('DatePickerContext', () => { + it.each([ + ['useDatePicker', ], + ['DatePickerInput', ], + [ + 'DatePickerCalendar', + , + ], + ])('throws when %s is used outside DatePickerProvider', (_, element) => { + expect(() => + render({element}) + ).toThrow('useDatePickerContext must be used within a DatePicker.'); + }); + + it('returns single-mode context from useDatePicker when wrapped in DatePickerProvider', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('mode')).toHaveTextContent('single'); + }); + + it('exposes range fields when the provider value is range mode', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('mode')).toHaveTextContent('range'); + }); + + it('passes startDate and endDate through to consumers in range mode', () => { + const start = new Date(2026, 3, 10); + const end = new Date(2026, 3, 20); + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('range-dates')).toHaveTextContent( + `${start.toDateString()}|${end.toDateString()}` + ); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx deleted file mode 100644 index 7adc2ed26f..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { MockGamutProvider } from '@skillsoft/gamut-tests'; -import { render } from '@testing-library/react'; - -import { DatePickerProvider, useDatePicker } from '..'; -import { - createMockRangeContext, - createMockSingleContext, -} from './mockContexts'; - -const Consumer = () => { - const ctx = useDatePicker(); - return {ctx.mode}; -}; - -const RangeDatesConsumer = () => { - const ctx = useDatePicker(); - if (ctx.mode !== 'range') return null; - return ( - - {ctx.startDate?.toDateString() ?? 'no-start'}| - {ctx.endDate?.toDateString() ?? 'no-end'} - - ); -}; - -describe('DatePickerContext', () => { - it('throws when useDatePicker is used outside DatePickerProvider', () => { - expect(() => - render( - - - - ) - ).toThrow('useDatePickerContext must be used within a DatePicker.'); - }); - - it('returns single-mode context from useDatePicker when wrapped in DatePickerProvider', () => { - const { getByTestId } = render( - - - - - - ); - - expect(getByTestId('mode')).toHaveTextContent('single'); - }); - - it('exposes range fields when the provider value is range mode', () => { - const { getByTestId } = render( - - - - - - ); - - expect(getByTestId('mode')).toHaveTextContent('range'); - }); - - it('passes startDate and endDate through to consumers in range mode', () => { - const start = new Date(2026, 3, 10); - const end = new Date(2026, 3, 20); - const { getByTestId } = render( - - - - - - ); - - expect(getByTestId('range-dates')).toHaveTextContent( - `${start.toDateString()}|${end.toDateString()}` - ); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts deleted file mode 100644 index a6227cee87..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; -import type { - DatePickerRangeContextValue, - DatePickerSingleContextValue, -} from '../types'; - -export function createMockSingleContext( - overrides: Partial = {} -): DatePickerSingleContextValue { - return { - mode: 'single', - locale: new Intl.Locale('en-US'), - isCalendarOpen: false, - openCalendar: jest.fn(), - focusCalendar: jest.fn(), - focusGridSignal: false, - gridFocusRequested: false, - clearGridFocusRequest: jest.fn(), - closeCalendar: jest.fn(), - translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, - quickActions: [], - hasError: false, - setHasError: jest.fn(), - selectedDate: new Date(2024, 2, 15), - onSelection: jest.fn(), - ...overrides, - }; -} - -export function createMockRangeContext( - overrides: Partial = {} -): DatePickerRangeContextValue { - return { - mode: 'range', - locale: new Intl.Locale('en-US'), - isCalendarOpen: false, - openCalendar: jest.fn(), - focusCalendar: jest.fn(), - focusGridSignal: false, - gridFocusRequested: false, - clearGridFocusRequest: jest.fn(), - closeCalendar: jest.fn(), - translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, - quickActions: [], - hasError: false, - setHasError: jest.fn(), - startDate: null, - endDate: null, - onRangeSelection: jest.fn(), - activeRangePart: null, - setActiveRangePart: jest.fn(), - ...overrides, - }; -} diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx deleted file mode 100644 index bed328b3c2..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import userEvent from '@testing-library/user-event'; -import { type FC, useCallback, useState } from 'react'; - -import type { DatePartKind } from '../../utils'; -import { type AssignSegmentRef, DatePickerInputSegment } from '..'; -import type { SegmentValues } from '../utils'; - -const noop = () => undefined; - -const noopAssignSegmentRef: AssignSegmentRef = () => undefined; - -const noopFocusSegmentField = () => undefined; - -type HarnessProps = { - field?: DatePartKind; - segment?: SegmentValues; - disabled?: boolean; - error?: boolean; - focusOrOpenCalendarGrid?: () => void; -}; - -/** - * Real `useState` + `setSegments` so functional updaters from ArrowUp / typing run and re-render. - */ -const SegmentHarness: FC = ({ - field = 'month', - segment = { month: '', day: '', year: '' }, - disabled = false, - error = false, - focusOrOpenCalendarGrid = noop, -}) => { - const [segments, setSegments] = useState(segment); - const applySegments = useCallback(() => { - // Parent would commit parsed date; segment tests only need state updates via setSegments. - }, []); - - return ( - - ); -}; - -const renderView = setupRtl(SegmentHarness, {}); - -describe('DatePickerInputSegment', () => { - it.each([ - ['month', 'MM'], - ['day', 'DD'], - ['year', 'YYYY'], - ] as const)('shows placeholder for %s', (field, expected) => { - const { view } = renderView({ field }); - - expect(view.getByRole('spinbutton', { name: field })).toHaveAttribute( - 'aria-valuetext', - expected - ); - }); - - it('sets aria-invalid when error is true', () => { - const { view } = renderView({ error: true }); - - expect(view.getByRole('spinbutton', { name: 'month' })).toHaveAttribute( - 'aria-invalid', - 'true' - ); - }); - - it('sets aria-disabled and tabIndex -1 when disabled', () => { - const { view } = renderView({ disabled: true }); - - const month = view.getByRole('spinbutton', { name: 'month' }); - expect(month).toHaveAttribute('aria-disabled', 'true'); - expect(month).toHaveAttribute('tabIndex', '-1'); - }); - - it('ignores digit input when disabled', async () => { - const user = userEvent.setup(); - const { view } = renderView({ disabled: true }); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('5'); - - expect(month).toHaveAttribute('aria-valuetext', 'MM'); - }); - - it('increments month with ArrowUp from empty', async () => { - const user = userEvent.setup(); - const { view } = renderView({}); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('{ArrowUp}'); - - expect(month).toHaveAttribute('aria-valuetext', '01'); - }); - - it('increments month with ArrowUp from 01', async () => { - const user = userEvent.setup(); - const { view } = renderView({ - segment: { month: '01', day: '', year: '' }, - }); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('{ArrowUp}'); - - expect(month).toHaveAttribute('aria-valuetext', '02'); - }); - - it('decrements month with ArrowDown from empty', async () => { - const user = userEvent.setup(); - const { view } = renderView({}); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('{ArrowDown}'); - - expect(month).toHaveAttribute('aria-valuetext', '12'); - }); - - it('decrements month with ArrowDown from 02', async () => { - const user = userEvent.setup(); - const { view } = renderView({ - segment: { month: '02', day: '', year: '' }, - }); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('{ArrowDown}'); - - expect(month).toHaveAttribute('aria-valuetext', '01'); - }); - - it('calls focusOrOpenCalendarGrid on Alt+ArrowDown', async () => { - const user = userEvent.setup(); - const focusOrOpenCalendarGrid = jest.fn(); - const { view } = renderView({ focusOrOpenCalendarGrid }); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('{Alt>}{ArrowDown}{/Alt}'); - - expect(focusOrOpenCalendarGrid).toHaveBeenCalledTimes(1); - }); - - it('appends typed digits until the segment is full', async () => { - const user = userEvent.setup(); - const { view } = renderView({}); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('03'); - - expect(month).toHaveAttribute('aria-valuetext', '03'); - }); - - it('removes the last digit with Backspace', async () => { - const user = userEvent.setup(); - const { view } = renderView({ - segment: { month: '03', day: '', year: '' }, - }); - - const month = view.getByRole('spinbutton', { name: 'month' }); - await user.click(month); - await user.keyboard('{Backspace}'); - - expect(month).toHaveAttribute('aria-valuetext', '0'); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.test.ts similarity index 99% rename from packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts rename to packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.test.ts index 49d1da3376..22af641068 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.test.ts @@ -1,4 +1,4 @@ -import type { DateFormatLayoutItem } from '../../utils'; +import type { DateFormatLayoutItem } from '../utils'; import { appendSegmentDigit, buildCombinedFromSegments, @@ -12,7 +12,7 @@ import { parseSegmentNumericString, parseSegmentsToDate, spinSegment, -} from '../utils'; +} from './utils'; describe('getDateSegmentsFromDate', () => { it('returns empty strings for null', () => { diff --git a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx deleted file mode 100644 index b32d5d5bd1..0000000000 --- a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent, render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { FC } from 'react'; - -import { DatePickerProvider } from '../../DatePickerContext'; -import { - createMockRangeContext, - createMockSingleContext, -} from '../../DatePickerContext/__tests__/mockContexts'; -import type { - DatePickerRangeContextValue, - DatePickerSingleContextValue, -} from '../../DatePickerContext/types'; -import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; -import { DatePickerInput, type DatePickerInputProps } from '../index'; - -const mockOpenCalendar = jest.fn(); -const mockOnSelection = jest.fn(); -const mockOnRangeSelection = jest.fn(); -const mockSetHasError = jest.fn(); - -/* Single-date harness: pass context overrides (selectedDate, disableDate, - translations, …) and the input `label` flat, e.g. renderInput({ selectedDate: null }). */ -type SingleInputHarnessProps = Partial & - Pick; - -const SingleInputHarness: FC = ({ - label, - ...contextOverrides -}) => ( - - - -); - -const renderInput = setupRtl(SingleInputHarness, { - selectedDate: null, - onSelection: mockOnSelection, - openCalendar: mockOpenCalendar, - setHasError: mockSetHasError, -}); - -/* Single range-input harness: one input tied to a range context, e.g. - renderRangeInput({ startDate, endDate, disableDate }). Defaults to the end part. */ -type RangeInputHarnessProps = Partial & - Pick; - -const RangeInputHarness: FC = ({ - label, - rangePart, - ...contextOverrides -}) => ( - - - -); - -// Disables March 20, 2024 - a date that sits inside the range typed below. -const disableDate = (date: Date) => - date.getFullYear() === 2024 && date.getMonth() === 2 && date.getDate() === 20; - -const renderRangeInput = setupRtl(RangeInputHarness, { - startDate: new Date(2024, 2, 15), - endDate: null, - activeRangePart: 'end', - disableDate, - rangePart: 'end', - onRangeSelection: mockOnRangeSelection, -}); - -/* Range-labels harness: both start and end inputs, for the label tests only. */ -type RangeLabelsHarnessProps = { - startLabel?: string; - endLabel?: string; -}; - -const RangeLabelsHarness: FC = ({ - startLabel, - endLabel, -}) => ( - - - - -); - -const renderRange = setupRtl(RangeLabelsHarness, {}); - -describe('DatePickerInput', () => { - it('throws when rendered without DatePickerProvider', () => { - expect(() => - render( - - - - ) - ).toThrow(/useDatePickerContext must be used within a DatePicker/); - }); - - it('calls openCalendar when the shell is clicked', async () => { - const { view } = renderInput(); - - await userEvent.click(view.getByRole('group')); - - expect(mockOpenCalendar).toHaveBeenCalledTimes(1); - }); - - it('exposes an accessible name from the openCalendarLabel translation', () => { - const { view } = renderInput(); - - view.getByRole('button', { name: 'Open calendar' }); - }); - - it('uses a custom openCalendarLabel translation when provided', () => { - const { view } = renderInput({ - translations: { - ...DEFAULT_DATE_PICKER_TRANSLATIONS, - openCalendarLabel: 'Pick a date', - }, - }); - - view.getByRole('button', { name: 'Pick a date' }); - }); - - it('calls openCalendar when the calendar trigger button is clicked', async () => { - const { view } = renderInput(); - - await userEvent.click(view.getByRole('button', { name: 'Open calendar' })); - - expect(mockOpenCalendar).toHaveBeenCalledTimes(1); - }); - - it('renders default Date label in single date mode', () => { - const { view } = renderInput(); - - view.getByText('Date'); - }); - - it('renders default Start date and End date labels in range mode', () => { - const { view } = renderRange(); - - view.getByText('Start date'); - view.getByText('End date'); - }); - - it('renders a custom label when provided in single date mode', () => { - const { view } = renderInput({ label: 'Ship date' }); - - view.getByText('Ship date'); - }); - - it('renders a custom label when provided in range mode', () => { - const { view } = renderRange({ - startLabel: 'The Beginning', - endLabel: 'The End', - }); - - view.getByText('The Beginning'); - view.getByText('The End'); - }); - - it('syncs hidden input to the context selected date (ISO date-only)', () => { - const { view } = renderInput({ selectedDate: new Date(2024, 2, 15) }); - - const hidden = view.container.querySelector('input[type="hidden"]')!; - expect(hidden).toHaveValue('2024-03-15'); - }); - - it('moves focus between segments with ArrowLeft and ArrowRight', async () => { - const { view } = renderInput(); - - const month = view.getByRole('spinbutton', { name: 'month' }); - const day = view.getByRole('spinbutton', { name: 'day' }); - const year = view.getByRole('spinbutton', { name: 'year' }); - - month.focus(); - await userEvent.keyboard('{ArrowRight}'); - expect(document.activeElement).toBe(day); - - await userEvent.keyboard('{ArrowRight}'); - expect(document.activeElement).toBe(year); - - await userEvent.keyboard('{ArrowLeft}'); - expect(document.activeElement).toBe(day); - }); - - it('updates the hidden input when a full date is typed (auto-advance between segments)', async () => { - const { view } = renderInput(); - - const month = view.getByRole('spinbutton', { name: 'month' }); - const day = view.getByRole('spinbutton', { name: 'day' }); - const year = view.getByRole('spinbutton', { name: 'year' }); - - month.focus(); - await userEvent.keyboard('03'); - expect(document.activeElement).toBe(day); - await userEvent.keyboard('15'); - expect(document.activeElement).toBe(year); - await userEvent.keyboard('2024'); - - const hidden = view.container.querySelector('input[type="hidden"]')!; - expect(hidden).toHaveValue('2024-03-15'); - }); - - it('normalizes and keeps a valid date after blur', async () => { - const { view } = renderInput(); - - const month = view.getByRole('spinbutton', { name: 'month' }); - const year = view.getByRole('spinbutton', { name: 'year' }); - - month.focus(); - await userEvent.keyboard('03'); - await userEvent.keyboard('15'); - await userEvent.keyboard('2024'); - - fireEvent.blur(year, { relatedTarget: document.body }); - - const hidden = view.container.querySelector('input[type="hidden"]')!; - expect(hidden).toHaveValue('2024-03-15'); - }); - - describe('single-date validation', () => { - const typeDate = async ( - view: ReturnType['view'], - { month, day, year }: { month: string; day: string; year: string } - ) => { - view.getByRole('spinbutton', { name: 'month' }).focus(); - await userEvent.keyboard(month); - await userEvent.keyboard(day); - await userEvent.keyboard(year); - }; - - it('shows an invalid-month error and does not commit when the month is out of range', async () => { - const { view } = renderInput(); - - await typeDate(view, { month: '13', day: '15', year: '2024' }); - - view.getByText('Month must be between 1 and 12'); - expect(mockOnSelection).not.toHaveBeenCalled(); - }); - - it('shows a rollover error when the day overflows the month', async () => { - const { view } = renderInput(); - - await typeDate(view, { month: '02', day: '30', year: '2024' }); - - view.getByText('February does not have 30 days'); - expect(mockOnSelection).not.toHaveBeenCalled(); - }); - - it('shows the unavailable error and does not commit when a disabled date is typed', async () => { - const disableDate = (date: Date) => - date.getFullYear() === 2024 && - date.getMonth() === 2 && - date.getDate() === 20; - const { view } = renderInput({ disableDate }); - - await typeDate(view, { month: '03', day: '20', year: '2024' }); - - view.getByText('This date is not available'); - expect(mockOnSelection).not.toHaveBeenCalled(); - }); - - it('surfaces the validation message through an alert region', async () => { - const { view } = renderInput(); - - await typeDate(view, { month: '13', day: '15', year: '2024' }); - - expect(view.getByRole('alert')).toHaveTextContent( - 'Month must be between 1 and 12' - ); - }); - - it('marks the segments as invalid while an error is showing', async () => { - const { view } = renderInput(); - - await typeDate(view, { month: '13', day: '15', year: '2024' }); - - expect(view.getByRole('spinbutton', { name: 'month' })).toHaveAttribute( - 'aria-invalid', - 'true' - ); - }); - - it('commits a valid date and shows no error', async () => { - const { view } = renderInput(); - - await typeDate(view, { month: '03', day: '15', year: '2024' }); - - expect(view.queryByRole('alert')).toBeNull(); - expect(mockOnSelection).toHaveBeenCalledWith(new Date(2024, 2, 15)); - }); - - it('clears a prior error once the entry is corrected', async () => { - const { view } = renderInput(); - - const month = view.getByRole('spinbutton', { name: 'month' }); - month.focus(); - await userEvent.keyboard('13'); - await userEvent.keyboard('15'); - await userEvent.keyboard('2024'); - view.getByText('Month must be between 1 and 12'); - - // Correct the month back to a valid value. - month.focus(); - await userEvent.keyboard('03'); - - expect(view.queryByText('Month must be between 1 and 12')).toBeNull(); - }); - - it('shows an incomplete-date error on blur when the entry is partial', async () => { - const { view } = renderInput(); - - const month = view.getByRole('spinbutton', { name: 'month' }); - month.focus(); - await userEvent.keyboard('03'); - - fireEvent.blur(month, { relatedTarget: document.body }); - - view.getByText('Incomplete date'); - }); - - it('reports the error state to the context via setHasError', async () => { - const { view } = renderInput(); - - await typeDate(view, { month: '13', day: '15', year: '2024' }); - - expect(mockSetHasError).toHaveBeenLastCalledWith(true); - }); - }); - - describe('range disabled-date validation', () => { - it('shows the range error and does not commit when a typed range spans a disabled date', async () => { - const { view } = renderRangeInput(); - - view.getByRole('spinbutton', { name: 'month' }).focus(); - await userEvent.keyboard('03'); - await userEvent.keyboard('25'); - await userEvent.keyboard('2024'); - - view.getByText('This date range contains unavailable dates'); - expect(mockOnRangeSelection).not.toHaveBeenCalled(); - }); - - it('commits and clears the error when the typed range avoids disabled dates', async () => { - const { view } = renderRangeInput(); - - view.getByRole('spinbutton', { name: 'month' }).focus(); - await userEvent.keyboard('03'); - await userEvent.keyboard('18'); - await userEvent.keyboard('2024'); - - expect( - view.queryByText('This date range contains unavailable dates') - ).toBeNull(); - expect(mockOnRangeSelection).toHaveBeenCalledWith( - new Date(2024, 2, 15), - new Date(2024, 2, 18) - ); - }); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/utils/__tests__/validation.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.test.ts similarity index 97% rename from packages/gamut/src/DatePicker/DatePickerInput/utils/__tests__/validation.test.ts rename to packages/gamut/src/DatePicker/DatePickerInput/utils/validation.test.ts index 73080feba8..99b27ba6a0 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/utils/__tests__/validation.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.test.ts @@ -1,10 +1,10 @@ -import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../../utils/translations'; -import type { SegmentValues } from '../../Segment/utils'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; +import type { SegmentValues } from '../Segment/utils'; import { generateErrorMessage, validateDateRange, validateSegments, -} from '../validation'; +} from './validation'; const translations = DEFAULT_DATE_PICKER_TRANSLATIONS; diff --git a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx deleted file mode 100644 index f554fb2c81..0000000000 --- a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { FC, FormEvent, FormEventHandler } from 'react'; - -import { DatePicker } from '../DatePicker'; -import type { DatePickerTranslations } from '../utils/translations'; - -jest.mock('react-use', () => { - const actual = jest.requireActual('react-use'); - return { - ...actual, - useMedia: jest.fn(() => false), - }; -}); - -type SingleHarnessProps = { - selectedDate: Date | null; - onSelected: (date: Date | null) => void; - translations?: DatePickerTranslations; -}; - -const SingleHarness: FC = ({ - selectedDate, - onSelected, - translations, -}) => ( - -); - -const renderSingle = setupRtl(SingleHarness, { - selectedDate: new Date(2024, 2, 1), - onSelected: jest.fn(), -}); - -type RangeHarnessProps = { - startDate: Date | null; - endDate: Date | null; - onStartSelected: (date: Date | null) => void; - onEndSelected: (date: Date | null) => void; -}; - -const RangeHarness: FC = ({ - startDate, - endDate, - onStartSelected, - onEndSelected, -}) => ( - -); - -const renderRange = setupRtl(RangeHarness, { - startDate: null, - endDate: null, - onStartSelected: jest.fn(), - onEndSelected: jest.fn(), -}); - -const composedSetSelected = jest.fn(); - -const ComposedOnlyHarness: FC = () => ( - -
composed
-
-); - -const renderComposedOnly = setupRtl(ComposedOnlyHarness, {}); - -type SingleInFormProps = { - onFormSubmit: FormEventHandler; -} & SingleHarnessProps; - -const SingleInForm: FC = ({ - onFormSubmit, - ...harnessProps -}) => ( -
- - -); - -const renderSingleInForm = setupRtl(SingleInForm, { - onFormSubmit: (e: FormEvent) => { - e.preventDefault(); - }, - selectedDate: new Date(2024, 2, 1), - onSelected: jest.fn(), -}); - -type RangeInFormProps = { - onFormSubmit: FormEventHandler; -} & RangeHarnessProps; - -const RangeInForm: FC = ({ onFormSubmit, ...rest }) => ( -
- - -); - -const renderRangeInForm = setupRtl(RangeInForm, { - onFormSubmit: (e: FormEvent) => { - e.preventDefault(); - }, - startDate: null, - endDate: null, - onStartSelected: jest.fn(), - onEndSelected: jest.fn(), -}); - -describe('DatePicker', () => { - it('does not show the calendar dialog until the input group opens it', async () => { - const user = userEvent.setup(); - const { view } = renderSingle(); - - expect(view.queryByRole('dialog')).not.toBeInTheDocument(); - - await user.click(view.getByRole('group')); - - expect(view.getByRole('dialog')).toBeVisible(); - }); - - it('renders a custom date label when translations override dateLabel', () => { - const { view } = renderSingle({ - translations: { dateLabel: 'Ship date' }, - }); - - view.getByText('Ship date'); - }); - - it('renders two input groups in range mode', () => { - const { view } = renderRange(); - - expect(view.getAllByRole('group')).toHaveLength(2); - }); - - it('associates the field label with the segment shell via aria-labelledby (DatePickerInput)', () => { - const { view } = renderSingle(); - const shell = view.getByRole('group'); - const labelledBy = shell.getAttribute('aria-labelledby'); - expect(labelledBy).toBeTruthy(); - expect(view.container.querySelector(`#${labelledBy}`)).toBeInTheDocument(); - }); - - it('renders only children when the children prop is provided', () => { - const { view } = renderComposedOnly(); - - expect(view.getByTestId('composed')).toHaveTextContent('composed'); - expect(view.queryByText('Date')).not.toBeInTheDocument(); - expect(view.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('closes the calendar on Escape from the input when the popover is open', async () => { - const user = userEvent.setup(); - const { view } = renderSingle(); - - await user.click(view.getByRole('group')); - expect(view.getByRole('dialog')).toBeVisible(); - - const spinbutton = view.getByRole('spinbutton', { name: 'month' }); - spinbutton.focus(); - await user.keyboard('{Escape}'); - - expect(view.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('closes the calendar on Escape from a month nav chevron (not only the day grid)', async () => { - const user = userEvent.setup(); - const { view } = renderSingle(); - - await user.click(view.getByRole('group')); - expect(view.getByRole('dialog')).toBeVisible(); - - view.getByRole('button', { name: 'Last month' }).focus(); - await user.keyboard('{Escape}'); - - expect(view.queryByRole('dialog')).not.toBeInTheDocument(); - }); -}); - -describe('DatePicker inside a form', () => { - it('renders the field inside the form and can open the dialog', async () => { - const user = userEvent.setup(); - const { view } = renderSingleInForm(); - - const form = view.getByTestId('datepicker-form'); - expect(form).toBeInstanceOf(HTMLFormElement); - expect(form.querySelector('[role="group"]')).toBeInTheDocument(); - - await user.click(view.getByRole('group')); - expect(view.getByRole('dialog')).toBeVisible(); - }); - - it('does not submit the form when Escape closes the calendar from the field', async () => { - const user = userEvent.setup(); - const onFormSubmit = jest.fn((e) => e.preventDefault()); - const { view } = renderSingleInForm({ onFormSubmit }); - - await user.click(view.getByRole('group')); - view.getByRole('spinbutton', { name: 'month' }).focus(); - await user.keyboard('{Escape}'); - - expect(view.queryByRole('dialog')).not.toBeInTheDocument(); - expect(onFormSubmit).not.toHaveBeenCalled(); - }); - - it('does not submit the form when Escape closes the calendar from a month chevron', async () => { - const user = userEvent.setup(); - const onFormSubmit = jest.fn((e) => e.preventDefault()); - const { view } = renderSingleInForm({ onFormSubmit }); - - await user.click(view.getByRole('group')); - view.getByRole('button', { name: 'Last month' }).focus(); - await user.keyboard('{Escape}'); - - expect(onFormSubmit).not.toHaveBeenCalled(); - }); - - it('does not submit the form when selecting a day in the grid', async () => { - const user = userEvent.setup(); - const onFormSubmit = jest.fn((e) => e.preventDefault()); - const { view } = renderSingleInForm({ onFormSubmit }); - - await user.click(view.getByRole('group')); - await user.click(view.getByRole('gridcell', { name: /March 20, 2024/i })); - - expect(view.queryByRole('dialog')).not.toBeInTheDocument(); - expect(onFormSubmit).not.toHaveBeenCalled(); - }); - - it('still submits the form when a real submit control is used', async () => { - const user = userEvent.setup(); - const onFormSubmit = jest.fn((e) => e.preventDefault()); - - const SingleInFormWithSubmit: FC = ({ - onFormSubmit: onSubmit, - ...harness - }) => ( -
- - - - ); - - const renderWithButton = setupRtl(SingleInFormWithSubmit, { - selectedDate: new Date(2024, 2, 1), - onSelected: jest.fn(), - }); - - renderWithButton({ - onFormSubmit: (e) => { - e.preventDefault(); - onFormSubmit(e); - }, - }); - await user.click(screen.getByRole('button', { name: 'Save' })); - expect(onFormSubmit).toHaveBeenCalledTimes(1); - }); - - it('exposes a hidden field whose name and value match FormData on submit (default DatePicker passes `name` through)', () => { - const onFormSubmit = jest.fn((e: FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const hidden = form.querySelector( - 'input[type="hidden"]' - )!; - expect(hidden.name).toBeTruthy(); - expect(hidden).toHaveValue('2024-03-01'); - expect(new FormData(form).get(hidden.name)).toBe(hidden.value); - }); - - const { view } = renderSingleInForm({ onFormSubmit }); - fireEvent.submit(view.getByTestId('datepicker-form') as HTMLFormElement); - expect(onFormSubmit).toHaveBeenCalledTimes(1); - }); - - it('sends the selected date as the ISO 8601 value in FormData on submit', () => { - const selected = new Date(2024, 5, 15); - const onFormSubmit = jest.fn((e: FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const hidden = form.querySelector( - 'input[type="hidden"]' - )!; - const key = hidden.name; - const fromFormData = new FormData(form).get(key); - expect(fromFormData).toBe('2024-06-15'); - expect(fromFormData).toBe(hidden.value); - }); - - const { view } = renderSingleInForm({ - onFormSubmit, - selectedDate: selected, - }); - fireEvent.submit(view.getByTestId('datepicker-form') as HTMLFormElement); - expect(onFormSubmit).toHaveBeenCalledTimes(1); - }); - - it('in range mode, has two named hidden fields for start and end', () => { - const { view } = renderRangeInForm(); - const hiddens = view.container.querySelectorAll('input[type="hidden"]'); - expect(hiddens).toHaveLength(2); - const start = hiddens[0] as HTMLInputElement; - const end = hiddens[1] as HTMLInputElement; - expect(start.name).toBeTruthy(); - expect(end.name).toBeTruthy(); - expect(start.name).not.toBe(end.name); - }); - - it('in range mode, does not submit the form when opening and closing the calendar on the start field', async () => { - const user = userEvent.setup(); - const onFormSubmit = jest.fn((e) => e.preventDefault()); - const { view } = renderRangeInForm({ onFormSubmit }); - - const groups = view.getAllByRole('group'); - expect(groups).toHaveLength(2); - await user.click(groups[0]); - expect(view.getByRole('dialog')).toBeVisible(); - - view.getByRole('button', { name: 'Next month' }).focus(); - await user.keyboard('{Escape}'); - - expect(view.queryByRole('dialog')).not.toBeInTheDocument(); - expect(onFormSubmit).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts b/packages/gamut/src/DatePicker/utils/locale.test.ts similarity index 99% rename from packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts rename to packages/gamut/src/DatePicker/utils/locale.test.ts index 26d6e765b3..6c101fab1d 100644 --- a/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts +++ b/packages/gamut/src/DatePicker/utils/locale.test.ts @@ -3,7 +3,7 @@ import { getIsoFirstDayFromLocale, resolveLocale, stringifyLocale, -} from '../locale'; +} from './locale'; describe('getDefaultLocaleTag', () => { it('returns a non-empty BCP 47 tag from the runtime', () => { diff --git a/packages/gamut/src/GridForm/Buttons.stories.tsx b/packages/gamut/src/GridForm/Buttons.stories.tsx new file mode 100644 index 0000000000..423da55a5a --- /dev/null +++ b/packages/gamut/src/GridForm/Buttons.stories.tsx @@ -0,0 +1,237 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef } from 'react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { StrokeButton } from '../Button'; +import { GridForm } from '.'; + +const onSubmit = fn(); +const onCancel = fn(); + +const meta: Meta = { + title: 'Inputs & forms/GridForm/Buttons', + component: GridForm, + parameters: { docs: { propsTable: false } }, + args: { + onSubmit, + fields: [ + { + label: 'Simple text', + name: 'simple-text', + type: 'text', + size: 12, + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const SubmitButtonRight: Story = { + args: { + submit: { + contents: 'Right Submit!?', + position: 'right', + size: 12, + }, + }, + play: async ({ canvas, userEvent }) => { + const [button] = canvas.getAllByRole('button'); + await expect(canvas.getAllByRole('button')).toHaveLength(1); + await expect(button).toHaveTextContent('Right Submit!?'); + await expect(button).toHaveAttribute('type', 'submit'); + + await userEvent.click(button); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }, +}; + +export const SubmitButtonLeft: Story = { + args: { + submit: { + contents: 'Left Submit!?', + position: 'left', + size: 12, + }, + }, +}; + +export const SubmitButtonCenter: Story = { + args: { + submit: { + contents: 'Center Submit!?', + position: 'center', + size: 12, + }, + }, +}; + +export const SubmitButtonStretch: Story = { + args: { + submit: { + contents: 'Stretch Submit!?', + position: 'stretch', + size: 12, + }, + }, +}; + +export const SubmitButtonFill: Story = { + args: { + submit: { + contents: 'Fill Submit!?', + type: 'fill', + size: 12, + }, + }, +}; + +export const SubmitButtonCTA: Story = { + args: { + submit: { + contents: 'CTA Submit!?', + type: 'cta', + size: 12, + }, + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('button', { name: 'CTA Submit!?' }) + ).toHaveAttribute('type', 'submit'); + }, +}; + +export const SubmitButtonInline: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'simple-text', + type: 'text', + size: 6, + }, + ], + submit: { + contents: 'Inline Submit!?', + size: 4, + position: 'right', + }, + }, +}; + +export const CancelButton: Story = { + args: { + cancel: { + children: 'Cancel', + onClick: onCancel, + }, + submit: { + contents: 'Submit!?', + position: 'right', + size: 12, + }, + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.getAllByRole('button')).toHaveLength(2); + + await userEvent.click(canvas.getByRole('button', { name: 'Cancel' })); + await expect(onCancel).toHaveBeenCalledTimes(1); + await expect(onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const Loading: Story = { + args: { + submit: { + contents: 'Loading Submit!?', + loading: true, + size: 12, + }, + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('button', { name: 'Loading' }) + ).toBeDisabled(); + }, +}; + +export const Disabled: Story = { + args: { + validation: 'onChange', + submit: { + contents: 'Disabled Submit!?', + disabled: true, + size: 12, + }, + }, + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Disabled Submit!?' }); + await expect(button).toBeDisabled(); + + await userEvent.type( + canvas.getByRole('textbox', { name: 'Simple text' }), + 'valid' + ); + await expect(button).toBeDisabled(); + }, +}; + +export const CancelLink: Story = { + args: { + cancel: { + children: 'Back to settings', + href: '/settings', + }, + submit: { + contents: 'Save', + position: 'right', + size: 12, + }, + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('link', { name: 'Back to settings' }) + ).toHaveAttribute('href', '/settings'); + await expect(canvas.getAllByRole('button')).toHaveLength(1); + }, +}; + +export const LoadingWhileSubmitting: Story = { + render: function LoadingWhileSubmittingStory(args) { + const finishSubmit = useRef<() => void>(); + + return ( + isSubmitting, + }} + onSubmit={(values) => + new Promise((resolve) => { + onSubmit(values); + finishSubmit.current = resolve; + }) + } + > + finishSubmit.current?.()}> + Finish submitting + + + ); + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Save' })); + await expect( + await canvas.findByRole('button', { name: 'Loading' }) + ).toBeDisabled(); + + await userEvent.click( + canvas.getByRole('button', { name: 'Finish submitting' }) + ); + await canvas.findByRole('button', { name: 'Save' }); + await expect(onSubmit).toHaveBeenCalledTimes(1); + }, +}; diff --git a/packages/gamut/src/GridForm/Fields.stories.tsx b/packages/gamut/src/GridForm/Fields.stories.tsx new file mode 100644 index 0000000000..9f525d3ab6 --- /dev/null +++ b/packages/gamut/src/GridForm/Fields.stories.tsx @@ -0,0 +1,854 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor, within } from 'storybook/test'; + +import { FormGroup, Input } from '../Form'; +import { Text } from '../Typography'; +import { GridForm } from '.'; + +const onSubmit = fn(); + +const meta: Meta = { + title: 'Inputs & forms/GridForm/Fields', + component: GridForm, + parameters: { docs: { propsTable: false } }, + args: { + onSubmit, + submit: { + contents: 'Submit', + size: 4, + position: 'left', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +type Canvas = Parameters>[0]['canvas']; + +const submitButton = (canvas: Canvas) => + canvas.getByRole('button', { name: 'Submit' }); + +export const TextField: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'simple-text', + size: 9, + type: 'text', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: 'Simple text' }); + await expect(input).toHaveAttribute('id', 'simple-text'); + + await userEvent.type(input, 'hello'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'simple-text': 'hello' }, + expect.anything() + ) + ); + }, +}; + +export const DefaultTextField: Story = { + args: { + fields: [ + { + defaultValue: 'yeet', + label: 'Text with default value', + name: 'text-with-default', + size: 9, + type: 'text', + }, + ], + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('textbox', { name: 'Text with default value' }) + ).toHaveValue('yeet'); + }, +}; + +export const PlaceholderTextField: Story = { + args: { + fields: [ + { + label: 'Text with placeholder', + placeholder: 'Your email', + name: 'placeholder', + size: 9, + type: 'email', + }, + ], + }, + play: async ({ canvas }) => { + const input = canvas.getByRole('textbox', { + name: 'Text with placeholder', + }); + await expect(input).toHaveAttribute('placeholder', 'Your email'); + await expect(input).toHaveAttribute('type', 'email'); + }, +}; + +export const TextareaField: Story = { + args: { + fields: [ + { + label: 'Write a paragraph about penguins', + name: 'textarea-input', + size: 9, + type: 'textarea', + }, + ], + }, + play: async ({ canvas }) => { + const textArea = canvas.getByRole('textbox', { + name: 'Write a paragraph about penguins', + }); + await expect(textArea.tagName).toBe('TEXTAREA'); + await expect(textArea).not.toHaveAttribute('rows'); + }, +}; + +export const TextareaRows: Story = { + args: { + fields: [ + { + label: 'Write a longer paragraph', + name: 'textarea-rows', + rows: 8, + size: 9, + type: 'textarea', + }, + ], + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('textbox', { name: 'Write a longer paragraph' }) + ).toHaveAttribute('rows', '8'); + }, +}; + +export const SelectField: Story = { + args: { + fields: [ + { + label: 'Simple select', + name: 'simple-select', + options: ['', 'One fish', 'Two fish', 'Red fish', 'Blue fish'], + size: 9, + type: 'select', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const select = canvas.getByRole('combobox', { name: 'Simple select' }); + await expect(within(select).getAllByRole('option')).toHaveLength(5); + + await userEvent.selectOptions(select, 'Two fish'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'simple-select': 'Two fish' }, + expect.anything() + ) + ); + }, +}; + +export const RadioGroupField: Story = { + args: { + fields: [ + { + label: 'Preferred Modern Artist', + name: 'artist', + options: [ + { + label: 'Cardi B', + value: 'cardi', + }, + { + label: 'Nicki Minaj', + value: 'nicki', + }, + ], + size: 9, + type: 'radio-group', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const group = canvas.getByRole('radiogroup', { + name: 'Preferred Modern Artist', + }); + const radios = within(group).getAllByRole('radio'); + await expect(radios).toHaveLength(2); + await expect(radios[0]).toHaveAttribute('id', 'artist-0'); + await expect(radios[1]).toHaveAttribute('id', 'artist-1'); + + await userEvent.click(canvas.getByRole('radio', { name: 'Nicki Minaj' })); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { artist: 'nicki' }, + expect.anything() + ) + ); + }, +}; + +export const RadioGroupLabelling: Story = { + args: { + fields: [ + { + label: 'Label text', + ariaLabel: 'Overridden group label', + name: 'overridden', + options: [{ label: 'Plain option', value: 'plain' }], + size: 6, + type: 'radio-group', + }, + { + label: Rich label, + ariaLabel: 'Rich label override', + name: 'rich', + options: [ + { + label: Team badge, + value: 'badge', + }, + ], + size: 6, + type: 'radio-group', + }, + { + label: 'Option InfoTips', + name: 'tips', + options: [ + { + label: 'Radio option', + value: 'option1', + infotip: { info: 'helpful information' }, + }, + { + label: 'Labelled radio option', + value: 'option2', + infotip: { + info: 'more information', + ariaLabel: 'Custom InfoTip label', + }, + }, + ], + size: 6, + type: 'radio-group', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + canvas.getByRole('radiogroup', { name: 'Overridden group label' }); + canvas.getByRole('radiogroup', { name: 'Rich label override' }); + canvas.getByRole('radio', { name: 'Team badge' }); + canvas.getByRole('img', { name: 'Team badge' }); + + canvas.getByRole('button', { name: 'Custom InfoTip label' }); + await expect( + canvas.queryByRole('button', { name: 'Labelled radio option' }) + ).toBeNull(); + + const tip = canvas.getByText('helpful information'); + await expect(tip).not.toBeVisible(); + await userEvent.click(canvas.getByRole('button', { name: 'Radio option' })); + await waitFor(() => expect(tip).toBeVisible()); + }, +}; + +export const FileUploadField: Story = { + args: { + fields: [ + { + label: 'Upload a cat image (we support pdf, jpeg, or png files)', + name: 'file-input', + size: 9, + type: 'file', + validation: { + required: true, + validate: (files) => { + const { type } = files.item(0); + const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png']; + if (!allowedTypes.includes(type)) + return 'Please upload a pdf, jpeg, or png file.'; + return true; + }, + }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByLabelText(/Upload a cat image/); + await expect(input).toHaveAttribute('type', 'file'); + + await userEvent.upload( + input, + new File(['meow'], 'cat.txt', { type: 'text/plain' }) + ); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Please upload a pdf, jpeg, or png file.'); + await expect(onSubmit).not.toHaveBeenCalled(); + + await userEvent.upload( + input, + new File(['meow'], 'cat.png', { type: 'image/png' }) + ); + await userEvent.click(submitButton(canvas)); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + await expect(onSubmit.mock.calls[0][0]['file-input'][0].name).toBe( + 'cat.png' + ); + }, +}; + +export const CheckboxField: Story = { + args: { + fields: [ + { + description: 'I agree to the terms', + label: 'Terms', + name: 'terms', + size: 6, + type: 'checkbox', + id: 'my-super-cool-id', + validation: { + required: true, + }, + }, + { + description: 'I agree to the conditions', + label: 'Conditions', + name: 'conditions', + size: 6, + type: 'checkbox', + id: 'my-super-cool-id2', + validation: { + required: true, + }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const terms = canvas.getByRole('checkbox', { + name: 'I agree to the terms', + }); + const conditions = canvas.getByRole('checkbox', { + name: 'I agree to the conditions', + }); + await expect(terms).toHaveAttribute('id', 'my-super-cool-id'); + + await userEvent.click(terms); + await userEvent.click(submitButton(canvas)); + + await userEvent.click(conditions); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { terms: true, conditions: true }, + expect.anything() + ) + ); + await expect(onSubmit).toHaveBeenCalledTimes(1); + }, +}; + +export const CheckboxSpacing: Story = { + args: { + fields: [ + { + description: 'I agree to the terms', + label: 'Terms', + name: 'terms', + size: 6, + type: 'checkbox', + id: 'terms-id', + spacing: 'tight', + validation: { + required: true, + }, + }, + { + description: 'I agree to the conditions', + label: 'Conditions', + name: 'conditions', + size: 6, + type: 'checkbox', + id: 'conditions-id', + spacing: 'tight', + validation: { + required: true, + }, + }, + ], + }, +}; + +export const NestedCheckboxesField: Story = { + args: { + fields: [ + { + label: 'Nested checkboxes', + name: 'nested-checkboxes', + type: 'nested-checkboxes', + defaultValue: ['backend', 'react', 'vue'], + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { + value: 'react', + label: 'React', + options: [ + { value: 'nextjs', label: 'Next.js' }, + { value: 'typescript', label: 'TypeScript' }, + ], + }, + { + value: 'vue', + label: 'Vue.js', + }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], + size: 12, + }, + ], + }, + play: async ({ canvas }) => { + await waitFor(() => expect(canvas.getByLabelText('Next.js')).toBeChecked()); + await expect( + canvas.getByLabelText('Frontend Technologies') + ).toHaveAttribute('aria-checked', 'mixed'); + await expect(canvas.getByLabelText('Backend Technologies')).toBeChecked(); + await expect(canvas.getByLabelText('Angular')).not.toBeChecked(); + }, +}; + +export const CustomInputs: Story = { + args: { + fields: [ + { + render: ({ error, setValue }) => ( + <> + setValue(event.target.value)} + /> + 🕺 + + ), + label: 'Gimme two more swags', + name: 'custom-text-input', + size: 12, + validation: { + required: true, + pattern: { + value: /swag(.*)swag/, + message: 'Still not enough swag, what are you doing... 💢', + }, + }, + type: 'custom', + }, + { + render: ({ error, setValue }) => ( + + setValue(event.target.value)} + /> + + ), + size: 12, + name: 'custom-group-input', + validation: { + required: true, + pattern: { + value: /swag(.*)swag/, + message: 'Still not enough swag, what are you doing... 💢', + }, + }, + type: 'custom-group', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const custom = canvas.getByRole('textbox', { + name: /Gimme two more swags/, + }); + const group = canvas.getByRole('textbox', { name: /updog/ }); + + await userEvent.type(custom, 'swag swag'); + await userEvent.type(group, 'swagswag'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { + 'custom-text-input': 'swag swag', + 'custom-group-input': 'swagswag', + }, + expect.anything() + ) + ); + }, +}; + +export const HiddenInput: Story = { + args: { + fields: [ + { + type: 'hidden', + name: 'secret-stuff', + defaultValue: "I'm invisible!", + }, + { + label: "There's more than one field here!", + name: 'custom-hidden-input', + type: 'email', + size: 12, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'secret-stuff': "I'm invisible!", 'custom-hidden-input': '' }, + expect.anything() + ) + ); + }, +}; + +export const SweetContainer: Story = { + args: { + fields: [ + { + label: 'This is our sticky sweet label', + name: 'sweet-container', + type: 'sweet-container', + }, + { + label: "There's something sticky and sweet here!", + name: 'custom-input', + type: 'email', + size: 12, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + canvas.getByLabelText('This is our sticky sweet label'); + + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'sweet-container': false, 'custom-input': '' }, + expect.anything() + ) + ); + }, +}; + +export const FieldIds: Story = { + args: { + fields: [ + { + id: 'mycoolid', + label: 'Text', + name: 'text', + size: 4, + type: 'text', + }, + { + id: 'swaggy-id', + label: 'Select', + name: 'select', + options: ['aaa', 'bbb'], + size: 4, + type: 'select', + }, + { + id: 'another-dank-id', + description: 'Check me!', + label: 'Checkbox', + name: 'checkbox', + size: 4, + type: 'checkbox', + }, + { + id: 'id-2-the-ego', + label: 'Textarea', + name: 'textarea', + size: 4, + type: 'textarea', + }, + { + id: 'fire-file', + label: 'File', + name: 'file', + size: 4, + type: 'file', + }, + { + label: 'Plain text', + name: 'plain-text', + size: 4, + type: 'text', + }, + ], + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('textbox', { name: /^Text\s/ }) + ).toHaveAttribute('id', 'mycoolid'); + await expect( + canvas.getByRole('combobox', { name: /Select/ }) + ).toHaveAttribute('id', 'swaggy-id'); + await expect( + canvas.getByRole('checkbox', { name: 'Check me!' }) + ).toHaveAttribute('id', 'another-dank-id'); + await expect( + canvas.getByRole('textbox', { name: /Textarea/ }) + ).toHaveAttribute('id', 'id-2-the-ego'); + await expect(canvas.getByLabelText(/^File/)).toHaveAttribute( + 'id', + 'fire-file' + ); + await expect( + canvas.getByRole('textbox', { name: /Plain text/ }) + ).toHaveAttribute('id', 'plain-text'); + }, +}; + +export const InfoTipAutoLabelling: Story = { + args: { + fields: [ + { + label: 'Email address', + name: 'email', + size: 9, + type: 'email', + infotip: { + info: 'We will never share your email with third parties.', + placement: 'floating', + }, + }, + ], + }, + play: ({ canvas }) => { + canvas.getByRole('button', { name: 'Email address' }); + }, +}; + +export const InfoTipWithReactNodeLabel: Story = { + render: (args) => ( + <> + + API Configuration + + + + ), + args: { + fields: [ + { + label: Username (automatic label), + name: 'username', + size: 9, + type: 'text', + infotip: { + info: 'Choose a unique username between 3-20 characters.', + }, + }, + { + label: Password (ariaLabel), + name: 'password', + size: 9, + type: 'password', + infotip: { + info: 'Password must be at least 8 characters with one uppercase letter and one number.', + ariaLabel: 'Password requirements', + }, + }, + { + label: API Key (ariaLabelledby), + name: 'apiKey', + size: 9, + type: 'text', + infotip: { + info: 'You can find your API key in the developer settings dashboard.', + ariaLabelledby: 'api-section-heading', + }, + }, + ], + }, + play: async ({ canvas }) => { + canvas.getByRole('button', { name: /^Username \(automatic label\)/ }); + canvas.getByRole('button', { name: 'Password requirements' }); + await expect( + canvas.queryByRole('button', { name: /Password \(ariaLabel\)/ }) + ).toBeNull(); + canvas.getByRole('button', { name: 'API Configuration' }); + }, +}; + +export const DefaultValues: Story = { + args: { + fields: [ + { + label: 'Fish', + name: 'fish', + options: ['One fish', 'Two fish'], + size: 4, + type: 'select', + }, + { + defaultValue: true, + description: 'Subscribe me', + label: 'Newsletter', + name: 'newsletter', + size: 4, + type: 'checkbox', + }, + { + defaultValue: 'nicki', + label: 'Artist', + name: 'artist', + options: [ + { label: 'Cardi B', value: 'cardi' }, + { label: 'Nicki Minaj', value: 'nicki' }, + ], + size: 4, + type: 'radio-group', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.getByRole('combobox', { name: /Fish/ })).toHaveValue( + 'One fish' + ); + await expect( + canvas.getByRole('checkbox', { name: 'Subscribe me' }) + ).toBeChecked(); + await expect( + canvas.getByRole('radio', { name: 'Nicki Minaj' }) + ).toBeChecked(); + + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { fish: 'One fish', newsletter: true, artist: 'nicki' }, + expect.anything() + ) + ); + }, +}; + +export const SelectObjectOptions: Story = { + args: { + fields: [ + { + label: 'Country', + name: 'country', + options: { us: 'United States', ca: 'Canada', mx: 'Mexico' }, + size: 9, + type: 'select', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const select = canvas.getByRole('combobox', { name: 'Country' }); + await expect(select).toHaveValue('us'); + canvas.getByRole('option', { name: 'United States' }); + + await userEvent.selectOptions(select, 'Canada'); + await expect(select).toHaveValue('ca'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { country: 'ca' }, + expect.anything() + ) + ); + }, +}; + +export const HiddenLabel: Story = { + args: { + fields: [ + { + hideLabel: true, + label: 'Search', + name: 'search', + size: 9, + type: 'text', + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: 'Search' }); + const label = canvas.getByText('Search'); + await expect(label.closest('label')).not.toBeNull(); + const ancestors: HTMLElement[] = []; + for (let el = label.parentElement; el; el = el.parentElement) { + ancestors.push(el); + } + await expect( + ancestors.some((el) => el.getBoundingClientRect().width <= 1) + ).toBe(true); + + await userEvent.type(input, 'penguins'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { search: 'penguins' }, + expect.anything() + ) + ); + }, +}; + +export const SweetContainerTripped: Story = { + args: SweetContainer.args, + play: async ({ canvas, userEvent }) => { + await userEvent.click( + canvas.getByLabelText('This is our sticky sweet label') + ); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'sweet-container': true, 'custom-input': '' }, + expect.anything() + ) + ); + }, +}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/GridFormNestedCheckboxInput.stories.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/GridFormNestedCheckboxInput.stories.tsx new file mode 100644 index 0000000000..947c92b4fe --- /dev/null +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/GridFormNestedCheckboxInput.stories.tsx @@ -0,0 +1,354 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { GridForm } from '../../GridForm'; +import type { NestedGridFormCheckboxOption } from '../../types'; + +const options: NestedGridFormCheckboxOption[] = [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue.js' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { + value: 'node', + label: 'Node.js', + options: [ + { value: 'express', label: 'Express.js' }, + { value: 'fastify', label: 'Fastify' }, + ], + }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java', disabled: true }, + ], + }, + { + value: 'databases', + label: 'Databases', + }, +]; + +const onUpdate = fn(); +const onSubmit = fn(); + +type NestedCheckboxStoryArgs = { + defaultValue?: string[]; + disabled?: boolean; + customError?: string; + options: NestedGridFormCheckboxOption[]; +}; + +const meta: Meta = { + title: 'Inputs & forms/GridForm/NestedCheckboxes', + parameters: { docs: { propsTable: false } }, + args: { options, defaultValue: [] }, + render: ({ defaultValue, disabled, customError, options }) => ( + + ), +}; + +export default meta; +type Story = StoryObj; + +type Canvas = Parameters>[0]['canvas']; + +const expectChecked = (canvas: Canvas, labels: string[]) => + Promise.all( + labels.map((label) => expect(canvas.getByLabelText(label)).toBeChecked()) + ); + +const expectUnchecked = (canvas: Canvas, labels: string[]) => + Promise.all( + labels.map((label) => + expect(canvas.getByLabelText(label)).not.toBeChecked() + ) + ); + +const expectMixed = (canvas: Canvas, labels: string[]) => + Promise.all( + labels.map((label) => + expect(canvas.getByLabelText(label)).toHaveAttribute( + 'aria-checked', + 'mixed' + ) + ) + ); + +const allLabels = [ + 'Frontend Technologies', + 'React', + 'Vue.js', + 'Angular', + 'Backend Technologies', + 'Node.js', + 'Express.js', + 'Fastify', + 'Python', + 'Java', + 'Databases', +]; + +const submit = (canvas: Canvas) => + canvas.getByRole('button', { name: 'Submit Me!?' }); + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getAllByRole('listitem')).toHaveLength(11); + await expectUnchecked(canvas, allLabels); + + const frontend = canvas.getByLabelText('Frontend Technologies'); + await expect(frontend).toHaveAttribute( + 'aria-label', + 'Frontend Technologies' + ); + await expect(frontend).toHaveAttribute('aria-checked', 'false'); + await expect(frontend).toHaveAttribute('aria-required', 'true'); + await expect(canvas.getByLabelText('React')).toHaveAttribute( + 'aria-required', + 'true' + ); + await expect(frontend).toHaveAttribute('id', 'technologies-frontend'); + await expect(canvas.getByLabelText('React')).toHaveAttribute( + 'id', + 'technologies-react' + ); + await expect(canvas.getByLabelText('Express.js')).toHaveAttribute( + 'id', + 'technologies-express' + ); + + await expect(canvas.getByLabelText('Java')).toBeDisabled(); + await expect(canvas.getByLabelText('Vue.js')).toBeEnabled(); + }, +}; + +export const SelectingOptions: Story = { + play: async ({ canvas, userEvent }) => { + const react = canvas.getByLabelText('React'); + await userEvent.click(react); + await expect(react).toBeChecked(); + await expectMixed(canvas, ['Frontend Technologies']); + await expect(onUpdate).toHaveBeenLastCalledWith(['react']); + + await userEvent.click(react); + await expect(onUpdate).toHaveBeenLastCalledWith([]); + + await userEvent.click(canvas.getByLabelText('Frontend Technologies')); + await expectChecked(canvas, [ + 'Frontend Technologies', + 'React', + 'Vue.js', + 'Angular', + ]); + await expect(onUpdate).toHaveBeenLastCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + + await userEvent.click(canvas.getByLabelText('Node.js')); + await expectChecked(canvas, ['Node.js', 'Express.js', 'Fastify']); + await expectMixed(canvas, ['Backend Technologies']); + await expect(onUpdate).toHaveBeenLastCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + 'express', + 'fastify', + 'node', + ]); + }, +}; + +export const WithDefaultValues: Story = { + args: { defaultValue: ['react', 'python'] }, + play: async ({ canvas }) => { + await expectChecked(canvas, ['React', 'Python']); + await expectMixed(canvas, [ + 'Frontend Technologies', + 'Backend Technologies', + ]); + await expectUnchecked(canvas, [ + 'Vue.js', + 'Angular', + 'Node.js', + 'Express.js', + 'Fastify', + 'Java', + 'Databases', + ]); + }, +}; + +export const DeeplyNestedDefaultValues: Story = { + args: { defaultValue: ['express', 'fastify'] }, + play: async ({ canvas }) => { + const node = canvas.getByLabelText('Node.js'); + await expect(node).toBeChecked(); + await expect(node).toHaveProperty('indeterminate', false); + await expect( + canvas.getByLabelText('Backend Technologies') + ).not.toBeChecked(); + await expectMixed(canvas, ['Backend Technologies']); + }, +}; + +export const ParentDefaultValues: Story = { + args: { defaultValue: ['frontend', 'backend'] }, + play: async ({ canvas, userEvent }) => { + await waitFor(() => + expectChecked( + canvas, + allLabels.filter((label) => label !== 'Databases') + ) + ); + + await userEvent.click(canvas.getByLabelText('Python')); + await expect(canvas.getByLabelText('Python')).not.toBeChecked(); + await expectMixed(canvas, ['Backend Technologies']); + await expectChecked(canvas, ['Node.js', 'Express.js', 'Fastify']); + }, +}; + +export const AllChildrenSelected: Story = { + args: { defaultValue: ['react', 'vue', 'angular'] }, + play: async ({ canvas, userEvent }) => { + const frontend = canvas.getByLabelText('Frontend Technologies'); + await expect(frontend).toBeChecked(); + + await userEvent.click(canvas.getByLabelText('React')); + await expect(frontend).not.toBeChecked(); + await expectMixed(canvas, ['Frontend Technologies']); + + await userEvent.click(canvas.getByLabelText('React')); + await expect(frontend).toBeChecked(); + + await userEvent.click(frontend); + await expectUnchecked(canvas, [ + 'Frontend Technologies', + 'React', + 'Vue.js', + 'Angular', + ]); + }, +}; + +export const Disabled: Story = { + args: { disabled: true }, + play: async ({ canvas, userEvent }) => { + await Promise.all( + allLabels.map((label) => + expect(canvas.getByLabelText(label)).toBeDisabled() + ) + ); + + const react = canvas.getByLabelText('React'); + await userEvent.click(react); + await expect(react).not.toBeChecked(); + await expect(onUpdate).not.toHaveBeenCalled(); + }, +}; + +export const Validation: Story = { + play: async ({ canvas, userEvent }) => { + await userEvent.click(submit(canvas)); + await canvas.findByText('Please check at least one option'); + await expect(onSubmit).not.toHaveBeenCalled(); + + await userEvent.click(canvas.getByLabelText('React')); + await userEvent.click(submit(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { technologies: ['react'] }, + expect.anything() + ) + ); + }, +}; + +export const CustomError: Story = { + args: { customError: 'Please select at least one option' }, + play: async ({ canvas }) => { + canvas.getByText('Please select at least one option'); + await Promise.all( + allLabels.map((label) => + expect(canvas.getByLabelText(label)).toHaveAttribute( + 'aria-invalid', + 'true' + ) + ) + ); + }, +}; + +export const FlatOptions: Story = { + args: { + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + }, + play: async ({ canvas }) => { + await expect(canvas.getAllByRole('listitem')).toHaveLength(2); + canvas.getByLabelText('Option 1'); + canvas.getByLabelText('Option 2'); + }, +}; + +export const NumericValues: Story = { + args: { + options: [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as unknown as NestedGridFormCheckboxOption, + ], + }, + play: async ({ canvas }) => { + await expect(canvas.getByLabelText('Parent Option')).toHaveAttribute( + 'id', + 'technologies-1' + ); + await expect(canvas.getByLabelText('Child Option')).toHaveAttribute( + 'id', + 'technologies-2' + ); + }, +}; + +export const EmptyOptions: Story = { + args: { options: [] }, + play: async ({ canvas }) => { + canvas.getByRole('list'); + await expect(canvas.queryAllByRole('listitem')).toHaveLength(0); + }, +}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx deleted file mode 100644 index 8135e7f706..0000000000 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ /dev/null @@ -1,599 +0,0 @@ -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { GridForm } from '../../../GridForm'; -import type { NestedGridFormCheckboxOption } from '../../../types'; - -const mockOptions: NestedGridFormCheckboxOption[] = [ - { - value: 'frontend', - label: 'Frontend Technologies', - options: [ - { value: 'react', label: 'React' }, - { value: 'vue', label: 'Vue.js' }, - { value: 'angular', label: 'Angular' }, - ], - }, - { - value: 'backend', - label: 'Backend Technologies', - options: [ - { - value: 'node', - label: 'Node.js', - options: [ - { value: 'express', label: 'Express.js' }, - { value: 'fastify', label: 'Fastify' }, - ], - }, - { value: 'python', label: 'Python' }, - { value: 'java', label: 'Java', disabled: true }, - ], - }, - { - value: 'databases', - label: 'Databases', - }, -]; - -const mockOnUpdate = jest.fn(); -const mockOnSubmit = jest.fn(); -const TestForm: React.FC<{ - defaultValue?: string[]; - disabled?: boolean; - customError?: string; - options?: NestedGridFormCheckboxOption[]; -}> = ({ defaultValue = [], disabled, customError, options = mockOptions }) => ( - -); - -const renderView = setupRtl(TestForm, {}); - -describe('GridFormNestedCheckboxInput', () => { - describe('rendering', () => { - it('should render all checkbox options in a flat list', () => { - const { view } = renderView(); - - view.getByLabelText('Frontend Technologies'); - view.getByLabelText('Backend Technologies'); - view.getByLabelText('Databases'); - view.getByLabelText('React'); - view.getByLabelText('Vue.js'); - view.getByLabelText('Angular'); - view.getByLabelText('Node.js'); - view.getByLabelText('Python'); - view.getByLabelText('Express.js'); - view.getByLabelText('Fastify'); - }); - - it.each([ - { useLogicalProperties: true, marginLeft: 'marginInlineStart' }, - { useLogicalProperties: false, marginLeft: 'marginLeft' }, - ])( - 'should render checkboxes with proper indentation levels (useLogicalProperties: $useLogicalProperties)', - ({ useLogicalProperties, marginLeft }) => { - render( - - - - ); - - const frontendCheckbox = screen - .getByLabelText('Frontend Technologies') - .closest('li'); - const reactCheckbox = screen.getByLabelText('React').closest('li'); - const nodeCheckbox = screen.getByLabelText('Node.js').closest('li'); - const expressCheckbox = screen - .getByLabelText('Express.js') - .closest('li'); - - expect(frontendCheckbox).toHaveStyle({ [marginLeft]: '0' }); - expect(reactCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); - expect(nodeCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); - expect(expressCheckbox).toHaveStyle({ [marginLeft]: '3rem' }); - } - ); - - it('should render with unique IDs for each checkbox', () => { - const { view } = renderView(); - - expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( - 'id', - 'technologies-frontend' - ); - expect(view.getByLabelText('React')).toHaveAttribute( - 'id', - 'technologies-react' - ); - expect(view.getByLabelText('Express.js')).toHaveAttribute( - 'id', - 'technologies-express' - ); - }); - }); - - describe('default values', () => { - it('should render with options unchecked by default', () => { - const { view } = renderView(); - - expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); - expect(view.getByLabelText('Backend Technologies')).not.toBeChecked(); - expect(view.getByLabelText('React')).not.toBeChecked(); - expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - expect(view.getByLabelText('Node.js')).not.toBeChecked(); - expect(view.getByLabelText('Python')).not.toBeChecked(); - }); - - it('should render with default values checked', () => { - const { view } = renderView({ - defaultValue: ['react', 'python'], - }); - - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - }); - - it('should render parent as indeterminate when some children are selected', () => { - const { view } = renderView({ - defaultValue: ['react', 'vue'], - }); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - - expect(frontendCheckbox.indeterminate).toBe(true); - expect(frontendCheckbox).not.toBeChecked(); - }); - - it('should render parent as checked when all children are selected', () => { - const { view } = renderView({ - defaultValue: ['react', 'vue', 'angular'], - }); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - expect(frontendCheckbox).toBeChecked(); - }); - - it('should render deeply nested parent states correctly', () => { - const { view } = renderView({ - defaultValue: ['express', 'fastify'], - }); - const nodeCheckbox = view.getByLabelText('Node.js') as HTMLInputElement; - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - - expect(nodeCheckbox).toBeChecked(); - expect(nodeCheckbox.indeterminate).toBe(false); - expect(backendCheckbox).not.toBeChecked(); - expect(backendCheckbox.indeterminate).toBe(true); - }); - - it('should automatically check all children when parent is in default values', async () => { - const { view } = renderView({ - defaultValue: ['backend'], - }); - - const backendCheckbox = view.getByLabelText('Backend Technologies'); - expect(backendCheckbox).toBeChecked(); - - expect(view.getByLabelText('Node.js')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - expect(view.getByLabelText('Java')).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - - expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); - expect(view.getByLabelText('React')).not.toBeChecked(); - expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - }); - - it('should handle multiple parent defaults correctly', async () => { - const { view } = renderView({ - defaultValue: ['frontend', 'backend'], - }); - - expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); - expect(view.getByLabelText('Backend Technologies')).toBeChecked(); - - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Vue.js')).toBeChecked(); - expect(view.getByLabelText('Angular')).toBeChecked(); - expect(view.getByLabelText('Node.js')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - expect(view.getByLabelText('Java')).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - }); - - it('should preserve individual child selections in default values', () => { - const { view } = renderView({ - defaultValue: ['react', 'python'], - }); - - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Python')).toBeChecked(); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - - expect(frontendCheckbox.indeterminate).toBe(true); - expect(backendCheckbox.indeterminate).toBe(true); - - expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - expect(view.getByLabelText('Angular')).not.toBeChecked(); - expect(view.getByLabelText('Node.js')).not.toBeChecked(); - expect(view.getByLabelText('Java')).not.toBeChecked(); - expect(view.getByLabelText('Express.js')).not.toBeChecked(); - expect(view.getByLabelText('Fastify')).not.toBeChecked(); - }); - - it('should allow unchecking children that were auto-checked by default parent selection', async () => { - const { view } = renderView({ - defaultValue: ['backend'], - }); - - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - const pythonCheckbox = view.getByLabelText('Python'); - - expect(backendCheckbox).toBeChecked(); - expect(pythonCheckbox).toBeChecked(); - - await act(async () => { - fireEvent.click(pythonCheckbox); - }); - - expect(pythonCheckbox).not.toBeChecked(); - - expect(backendCheckbox.indeterminate).toBe(true); - expect(backendCheckbox).not.toBeChecked(); - - expect(view.getByLabelText('Node.js')).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - }); - }); - - describe('user interactions', () => { - it('should update form value when leaf checkbox is clicked', async () => { - const { view } = renderView(); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(reactCheckbox).toBeChecked(); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - expect(frontendCheckbox.indeterminate).toBe(true); - }); - - it('should select all children when parent checkbox is clicked', async () => { - const { view } = renderView(); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - - await act(async () => { - fireEvent.click(frontendCheckbox); - }); - - expect(frontendCheckbox).toBeChecked(); - expect(view.getByLabelText('React')).toBeChecked(); - expect(view.getByLabelText('Vue.js')).toBeChecked(); - expect(view.getByLabelText('Angular')).toBeChecked(); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'react', - 'vue', - 'angular', - 'frontend', - ]); - }); - - it('should deselect all children when checked parent is clicked', async () => { - const { view } = renderView({ - defaultValue: ['react', 'vue', 'angular'], - }); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - expect(frontendCheckbox).toBeChecked(); - - await act(async () => { - fireEvent.click(frontendCheckbox); - }); - - expect(frontendCheckbox).not.toBeChecked(); - expect(view.getByLabelText('React')).not.toBeChecked(); - expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - expect(view.getByLabelText('Angular')).not.toBeChecked(); - }); - - it('should handle deeply nested selections correctly', async () => { - const { view } = renderView(); - - const nodeCheckbox = view.getByLabelText('Node.js'); - - await act(async () => { - fireEvent.click(nodeCheckbox); - }); - - expect(nodeCheckbox).toBeChecked(); - expect(view.getByLabelText('Express.js')).toBeChecked(); - expect(view.getByLabelText('Fastify')).toBeChecked(); - - const backendCheckbox = view.getByLabelText( - 'Backend Technologies' - ) as HTMLInputElement; - expect(backendCheckbox.indeterminate).toBe(true); - - expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); - }); - - it('should handle individual child deselection affecting parent state', async () => { - const { view } = renderView({ - defaultValue: ['react', 'vue', 'angular'], - }); - - const frontendCheckbox = view.getByLabelText( - 'Frontend Technologies' - ) as HTMLInputElement; - expect(frontendCheckbox).toBeChecked(); - - const reactCheckbox = view.getByLabelText('React'); - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(reactCheckbox).not.toBeChecked(); - expect(frontendCheckbox.indeterminate).toBe(true); - expect(frontendCheckbox).not.toBeChecked(); - }); - }); - - describe('onUpdate callback', () => { - it('should call onUpdate when checkbox values change', async () => { - const { view } = renderView(); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(mockOnUpdate).toHaveBeenCalledWith(['react']); - }); - - it('should call onUpdate with correct values when parent is selected', async () => { - const { view } = renderView(); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - - await act(async () => { - fireEvent.click(frontendCheckbox); - }); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'react', - 'vue', - 'angular', - 'frontend', - ]); - }); - - it('should call onUpdate with empty array when all items are deselected', async () => { - const { view } = renderView({ - defaultValue: ['react'], - }); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - expect(mockOnUpdate).toHaveBeenCalledWith([]); - }); - }); - - describe('disabled state', () => { - it('should render all checkboxes as disabled when disabled prop is true', () => { - const { view } = renderView({ disabled: true }); - - expect(view.getByLabelText('Frontend Technologies')).toBeDisabled(); - expect(view.getByLabelText('React')).toBeDisabled(); - expect(view.getByLabelText('Databases')).toBeDisabled(); - }); - - it('should not respond to clicks when disabled', async () => { - const { view } = renderView({ disabled: true }); - - const reactCheckbox = view.getByLabelText('React'); - expect(reactCheckbox).toBeDisabled(); - expect(reactCheckbox).not.toBeChecked(); - - await userEvent.click(reactCheckbox); - - expect(reactCheckbox).not.toBeChecked(); - expect(mockOnUpdate).not.toHaveBeenCalled(); - }); - - it('should handle individual option disabled state', () => { - const { view } = renderView(); - - expect(view.getByLabelText('Java')).toBeDisabled(); - expect(view.getByLabelText('Vue.js')).not.toBeDisabled(); - }); - }); - - describe('validation', () => { - it('should handle required validation', async () => { - const { view } = renderView(); - - expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( - 'aria-required', - 'true' - ); - expect(view.getByLabelText('React')).toHaveAttribute( - 'aria-required', - 'true' - ); - }); - - it('should display error state when error prop is passed', () => { - const { view } = renderView({ - customError: 'Please select at least one option', - }); - - const checkboxes = view.container.querySelectorAll( - 'input[type="checkbox"]' - ); - checkboxes.forEach((checkbox) => { - expect(checkbox).toHaveAttribute('aria-invalid', 'true'); - }); - }); - - it('should submit successfully when validation passes', async () => { - const { view } = renderView(); - - const reactCheckbox = view.getByLabelText('React'); - - await act(async () => { - fireEvent.click(reactCheckbox); - }); - - const submitButton = view.getByRole('button'); - await act(async () => { - fireEvent.click(submitButton); - }); - - expect(mockOnSubmit).toHaveBeenCalledWith( - { technologies: ['react'] }, - expect.any(Object) - ); - }); - - it('should show validation errors and not submit when validation fails', async () => { - const { view } = renderView(); - - const submitButton = view.getByRole('button'); - await act(async () => { - fireEvent.click(submitButton); - }); - - expect(mockOnSubmit).not.toHaveBeenCalled(); - view.getByText('Please check at least one option'); - }); - }); - - describe('accessibility', () => { - it('should have proper aria attributes', () => { - const { view } = renderView(); - - const checkbox = view.getByLabelText('Frontend Technologies'); - - expect(checkbox).toHaveAttribute('aria-label', 'Frontend Technologies'); - expect(checkbox).toHaveAttribute('aria-checked', 'false'); - }); - - it('should have proper aria-checked states for indeterminate checkboxes', () => { - const { view } = renderView({ - defaultValue: ['react'], - }); - - const frontendCheckbox = view.getByLabelText('Frontend Technologies'); - - expect(frontendCheckbox).toHaveAttribute('aria-checked', 'mixed'); - }); - - it('should use proper list semantics', () => { - const { view } = renderView(); - - const list = view.container.querySelector('ul'); - expect(list).toBeInTheDocument(); - - expect(list?.children).toHaveLength(11); // Total flattened options - Array.from(list?.children || []).forEach((item) => { - // each child of the ul should be an li - expect(item).toBeInstanceOf(HTMLLIElement); - }); - }); - }); - - describe('edge cases', () => { - it('should handle empty options array', () => { - const { view } = renderView({ options: [] }); - - const list = view.container.querySelector('ul'); - expect(list).toBeInTheDocument(); - expect(list?.children).toHaveLength(0); - }); - - it('should handle options without nested children', () => { - const { view } = renderView({ - options: [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, - ], - }); - - view.getByLabelText('Option 1'); - view.getByLabelText('Option 2'); - }); - - it('should handle numeric values correctly', () => { - const { view } = renderView({ - options: [ - { - value: 1, - label: 'Parent Option', - options: [{ value: 2, label: 'Child Option' }], - } as any, // Type assertion for testing numeric values - ], - }); - - expect(view.getByLabelText('Parent Option')).toHaveAttribute( - 'id', - 'technologies-1' - ); - expect(view.getByLabelText('Child Option')).toHaveAttribute( - 'id', - 'technologies-2' - ); - }); - }); -}); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/assertions.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/assertions.tsx deleted file mode 100644 index ce61f13806..0000000000 --- a/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/assertions.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import '@testing-library/jest-dom'; - -import type { setupRtl } from '@skillsoft/gamut-tests'; -import { queryByAttribute } from '@testing-library/dom'; -import { act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import type { getComponent } from './renderers'; - -const getById = queryByAttribute.bind(null, 'id'); - -interface StandardFieldTestsProps - extends Pick, 'defaultFieldProps'> { - renderField: ReturnType; - component: string; - selector: string; -} - -export const itHandlesStandardFieldTests = ({ - renderField, - defaultFieldProps, - component, - selector, -}: StandardFieldTestsProps): void => { - it('renders a field with an id ', async () => { - const { view } = renderField({ - field: { ...defaultFieldProps, id: 'mycoolid', name: 'name' }, - }); - - const testId = - component !== 'GridFormRadioGroupInput' ? 'mycoolid' : 'name-0-mycoolid'; - - expect(getById(view.container, testId)); - }); - - it('renders a field with the id equal to the field name when no id is passed', async () => { - const { view } = renderField({ - field: { ...defaultFieldProps, name: 'name' }, - }); - - const testId = component !== 'GridFormRadioGroupInput' ? 'name' : 'name-0'; - - expect(getById(view.container, testId)); - }); - - it('has the property aria-invalid if error exists', async () => { - const props = { ...defaultFieldProps, validation: { required: true } }; - const { view } = renderField({ field: { ...props }, error: true }); - const field = - component !== 'GridFormFileInput' - ? view.getByRole(selector) - : getById(view.container, selector); - expect(field).toBeInvalid(); - }); - - it('does not have the property aria-invalid if error does not exist', async () => { - const props = { ...defaultFieldProps, validation: { required: true } }; - const { view } = renderField({ field: { ...props } }); - const field = - component !== 'GridFormFileInput' - ? view.getByRole(selector) - : getById(view.container, selector); - expect(field).not.toHaveAttribute('aria-invalid'); - }); -}; - -export const additionalRadioGroupTests = ({ - renderField, - defaultFieldProps, -}: Pick) => { - describe('Radio', () => { - it('accepts JSX for the label', () => { - const { view } = renderField({ - field: { - ...defaultFieldProps, - id: 'id', - options: [ - { - label: Test, - value: '', - }, - ], - name: 'name', - }, - }); - - expect(view.getByRole('img')); - }); - - it('renders an infotip in the radio', async () => { - const info = 'info'; - - const { view } = renderField({ - field: { - ...defaultFieldProps, - id: 'id', - options: [ - { - label: , - value: '', - infotip: { info }, - }, - ], - name: 'name', - }, - }); - - const tip = view.getByText(info); - expect(tip).not.toBeVisible(); - await act(async () => { - await userEvent.click(view.getByRole('button')); - }); - expect(tip).toBeVisible(); - }); - - it('links InfoTip to Radio label via aria-labelledby', () => { - const info = 'helpful information'; - const labelText = 'Radio Option'; - - const { view } = renderField({ - field: { - ...defaultFieldProps, - id: 'id', - options: [ - { - label: labelText, - value: 'option1', - infotip: { info }, - }, - ], - name: 'name', - }, - }); - - view.getByRole('button', { name: labelText }); - }); - - it('allows overriding InfoTip aria-labelledby with explicit ariaLabel', () => { - const info = 'helpful information'; - const labelText = 'Radio Option'; - const ariaLabel = 'Custom InfoTip Label'; - - const { view } = renderField({ - field: { - ...defaultFieldProps, - id: 'id', - options: [ - { - label: labelText, - value: 'option1', - infotip: { info, ariaLabel }, - }, - ], - name: 'name', - }, - }); - - view.getByRole('button', { name: ariaLabel }); - expect( - view.queryByRole('button', { name: labelText }) - ).not.toBeInTheDocument(); - }); - - describe('aria-label', () => { - it('aria-label is set to the label by default', () => { - const { view } = renderField({ - field: { ...defaultFieldProps, label: 'Test Label' }, - }); - - expect(view.getByLabelText('Test Label')); - }); - - it('aria-label is overridden by the ariaLabel prop', () => { - const { view } = renderField({ - field: { - ...defaultFieldProps, - label: 'Test Label', - ariaLabel: 'Overridden Test Label', - }, - }); - - expect(view.getByLabelText('Overridden Test Label')); - }); - - it('aria-label is used when JSX is passed to the label', () => { - const { view } = renderField({ - field: { - ...defaultFieldProps, - label: , - ariaLabel: 'Overridden Test Label', - }, - }); - expect(view.getByLabelText('Overridden Test Label')); - }); - }); - }); -}; - -export const additionalTextAreaTests = ({ - renderField, - defaultFieldProps, -}: Pick) => { - describe('TextArea', () => { - it('does not specify rows by default', () => { - const { view } = renderField({ - field: { - ...defaultFieldProps, - id: 'id', - options: [{ label: , value: '' }], - name: 'name', - }, - }); - const textArea = view.getByRole('textbox'); - expect(textArea).not.toHaveAttribute('rows'); - }); - it('renders other numbers of rows when specified', () => { - const { view } = renderField({ - field: { - ...defaultFieldProps, - id: 'id', - options: [{ label: , value: '' }], - name: 'name', - rows: 8, - }, - }); - const textArea = view.getByRole('textbox'); - expect(textArea).toHaveAttribute('rows', '8'); - }); - }); -}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/renderers.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/renderers.tsx deleted file mode 100644 index c599779fc8..0000000000 --- a/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/renderers.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import type * as React from 'react'; - -import { FormContext } from '../../__fixtures__/helpers'; -import { - stubCheckboxField, - stubFileField, - stubRadioGroupField, - stubSelectField, - stubTextareaField, - stubTextField, -} from '../../__tests__/stubs'; -import type { GridFormInputGroupProps } from '..'; -import { GridFormInputGroup } from '..'; -import { GridFormCheckboxInput } from '../GridFormCheckboxInput'; -import { GridFormFileInput } from '../GridFormFileInput'; -import { GridFormRadioGroupInput } from '../GridFormRadioGroupInput'; -import { GridFormSelectInput } from '../GridFormSelectInput'; -import { GridFormTextArea } from '../GridFormTextArea'; -import { GridFormTextInput } from '../GridFormTextInput'; - -type GridFormFieldComponent = - | typeof GridFormCheckboxInput - | typeof GridFormFileInput - | typeof GridFormRadioGroupInput - | typeof GridFormSelectInput - | typeof GridFormTextArea - | typeof GridFormTextInput; - -interface CreateGridFormFieldSetupProps { - component: T; - defaultFieldProps: React.ComponentProps['field']; -} - -const createGridFormFieldSetup = ({ - component, - defaultFieldProps, -}: CreateGridFormFieldSetupProps) => { - return { - renderField: setupRtl(component, { - field: defaultFieldProps, - register: jest.fn(), - }).options({ wrapper: FormContext }), - defaultFieldProps, - }; -}; - -export const getComponent = (componentName: string) => { - switch (componentName) { - case 'GridFormTextInput': - return createGridFormFieldSetup({ - component: GridFormTextInput, - defaultFieldProps: stubTextField, - }); - case 'GridFormSelectInput': - return createGridFormFieldSetup({ - component: GridFormSelectInput, - defaultFieldProps: stubSelectField, - }); - case 'GridFormTextArea': - return createGridFormFieldSetup({ - component: GridFormTextArea, - defaultFieldProps: stubTextareaField, - }); - case 'GridFormRadioGroupInput': - return createGridFormFieldSetup({ - component: GridFormRadioGroupInput, - defaultFieldProps: stubRadioGroupField, - }); - case 'GridFormFileInput': - return createGridFormFieldSetup({ - component: GridFormFileInput, - defaultFieldProps: stubFileField, - }); - case 'GridFormCheckboxInput': - return createGridFormFieldSetup({ - component: GridFormCheckboxInput, - defaultFieldProps: stubCheckboxField, - }); - default: - throw new Error(`Unknown component name: ${componentName}`); - } -}; - -type GridFormInputGroupTestComponentProps = GridFormInputGroupProps & { - mode?: 'onSubmit' | 'onChange'; - externalLabel?: { id: string; text: string }; -}; - -export const GridFormInputGroupTestComponent: React.FC< - GridFormInputGroupTestComponentProps -> = ({ field, mode = 'onSubmit', externalLabel, ...rest }) => { - return ( - - {externalLabel && {externalLabel.text}} - - - ); -}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputGroup.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputGroup.test.tsx deleted file mode 100644 index ace4f9ea4b..0000000000 --- a/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputGroup.test.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent, queryByAttribute } from '@testing-library/dom'; -import { act } from '@testing-library/react'; - -import { - stubCheckboxField, - stubFileField, - stubHiddenField, - stubRadioGroupField, - stubSelectField, - stubSweetContainerField, - stubTextareaField, - stubTextField, -} from '../../__tests__/stubs'; -import { GridFormInputGroupTestComponent } from '../__fixtures__/renderers'; - -const getById = queryByAttribute.bind(null, 'id'); - -const renderView = setupRtl(GridFormInputGroupTestComponent, { - setValue: jest.fn(), - register: jest.fn().mockReturnValue({ - onChange: jest.fn(), - }), -}); - -describe('GridFormInputGroup', () => { - it('renders error text when an error exists', () => { - const error = 'Oh no!'; - - const { view } = renderView({ field: stubCheckboxField, error }); - - view.getByText(error); - }); - it('renders error text when an custom error exists', () => { - const error = 'Oh no!'; - - const { view } = renderView({ - field: { ...stubCheckboxField, customError: error }, - }); - - view.getByText(error); - }); - - it('renders a checkbox input when the field type is checkbox', () => { - const { view } = renderView({ - field: { ...stubCheckboxField, id: 'mycoolid' }, - }); - - view.getByRole('checkbox'); - }); - - it('renders a custom input when the field type is custom', () => { - const text = 'Hello, world!'; - const { view } = renderView({ - field: { - render: () => text, - name: 'stub-custom', - size: 6, - type: 'custom', - }, - }); - - view.getByText(text); - }); - - it('renders a radio group when the field type is radio-group', () => { - const { view } = renderView({ - field: { ...stubRadioGroupField, id: 'mycoolid' }, - }); - - expect(view.getAllByRole('radio')).toHaveLength(2); - view.getByRole('radiogroup'); - }); - - it('renders a select when the field type is select', () => { - const { view } = renderView({ - field: { ...stubSelectField, id: 'mycoolid' }, - }); - - view.getByRole('combobox'); - }); - - it('renders a text input when the field type is text', () => { - const { view } = renderView({ - field: { ...stubTextField, id: 'mycoolid' }, - }); - - view.getByRole('textbox'); - }); - - it('renders a file input when the field type is file', () => { - const { view } = renderView({ - field: { ...stubFileField, id: 'mycoolid' }, - }); - - expect(getById(view.container, 'mycoolid')).toHaveAttribute('type', 'file'); - }); - - it('renders a textarea when the field type is textarea', () => { - const { view } = renderView({ - field: { ...stubTextareaField, id: 'mycoolid' }, - }); - - expect(view.getByRole('textbox')).toContainHTML('textarea'); - }); - - it('invokes onUpdate when the field type is text and it gets changed', () => { - const onUpdateSpy = jest.fn(); - const newVal = 'foo'; - - const { view } = renderView({ - field: { ...stubTextField, onUpdate: onUpdateSpy }, - }); - - const input = view.getByRole('textbox'); - - act(() => { - fireEvent.change(input, { target: { value: newVal } }); - }); - - expect(onUpdateSpy).toHaveBeenCalledWith(newVal); - }); - - it('invokes onUpdate when the field type is textarea and it gets changed', () => { - const onUpdateSpy = jest.fn(); - const newVal = 'foo'; - - const { view } = renderView({ - field: { ...stubTextareaField, onUpdate: onUpdateSpy }, - }); - - const input = view.getByRole('textbox'); - - act(() => { - fireEvent.change(input, { target: { value: newVal } }); - }); - - expect(onUpdateSpy).toHaveBeenCalledWith(newVal); - }); - - it('invokes onUpdate when the field type is select and it gets changed', async () => { - const onUpdateSpy = jest.fn(); - const newVal = 'bbb'; - - const { view } = renderView({ - field: { ...stubSelectField, onUpdate: onUpdateSpy }, - }); - - const select = view.getByRole('combobox'); - - act(() => { - fireEvent.change(select, { target: { value: newVal } }); - }); - - expect(onUpdateSpy).toHaveBeenCalledWith(newVal); - }); - - it('invokes onUpdate when the field type is checkbox and it gets changed', () => { - const onUpdateSpy = jest.fn(); - const newVal = true; - - const { view } = renderView({ - field: { ...stubCheckboxField, onUpdate: onUpdateSpy }, - }); - - const checkbox = view.getByRole('checkbox'); - - act(() => { - fireEvent.click(checkbox); - }); - - expect(onUpdateSpy).toHaveBeenCalledWith(newVal); - }); - - it('invokes onUpdate when the field type is file and it gets changed', () => { - const onUpdateSpy = jest.fn(); - const newVal = ['I swear this is a file']; - - const { view } = renderView({ - field: { ...stubFileField, onUpdate: onUpdateSpy }, - }); - - const input = view.getByLabelText('Stub File (optional)'); - - act(() => { - fireEvent.change(input, { target: { files: newVal } }); - }); - - expect(view.container).toContainHTML('Column'); - expect(onUpdateSpy).toHaveBeenCalledWith(newVal); - }); - - it('sets aria-live to assertive if isFirstError flag is on', () => { - const { view } = renderView({ - field: { ...stubRadioGroupField, id: 'mycoolid', size: 6 }, - error: 'It broke', - isFirstError: true, - }); - - const error = view.getByRole('alert'); - - expect(error).toHaveAttribute('aria-live', 'assertive'); - }); - - it('sets aria-live to off if isFirstError flag is off', () => { - const { view } = renderView({ - field: { ...stubRadioGroupField, id: 'mycoolid', size: 6 }, - error: 'It broke', - isFirstError: false, - }); - - const error = view.getByRole('status'); - - view.container.getElementsByClassName('css-d7900z-Column e1y0e4q30'); - - expect(error).toHaveAttribute('aria-live', 'off'); - }); - - it('creates column for inputs', () => { - const { view } = renderView({ - field: { ...stubTextField }, - }); - - expect(view.container).toContainHTML('Column'); - }); - - it('does not create a column for sweet container inputs', () => { - const { view } = renderView({ - field: { ...stubSweetContainerField }, - }); - - expect(view.container).not.toContainHTML('Column'); - }); - - it('does not create a column for hidden inputs', () => { - const { view } = renderView({ - field: { ...stubHiddenField }, - }); - expect(view.container).not.toContainHTML('Column'); - }); - - describe('infotip accessibility', () => { - const info = 'helpful information'; - const ariaLabel = 'Custom label'; - const textLabel = 'Stub Text'; - const checkboxLabel = 'Check me!'; - - it('automatically labels InfoTip button by the field label when label is a string', () => { - const { view } = renderView({ - field: { ...stubTextField, infotip: { info } }, - }); - - view.getByRole('button', { name: new RegExp(textLabel) }); - }); - - it('uses explicit ariaLabel when provided', () => { - const { view } = renderView({ - field: { ...stubTextField, infotip: { info, ariaLabel } }, - }); - - view.getByRole('button', { name: ariaLabel }); - }); - - it('uses explicit ariaLabelledby when provided', () => { - const externalLabelId = 'external-label-id'; - const externalLabelText = 'External Label'; - - const { view } = renderView({ - field: { - ...stubTextField, - infotip: { info, ariaLabelledby: externalLabelId }, - }, - externalLabel: { id: externalLabelId, text: externalLabelText }, - }); - - view.getByRole('button', { name: externalLabelText }); - }); - - it('does not automatically label InfoTip when explicit ariaLabel is provided', () => { - const { view } = renderView({ - field: { - ...stubCheckboxField, - label: {checkboxLabel}, - infotip: { info, ariaLabel }, - }, - }); - - view.getByRole('button', { name: ariaLabel }); - expect( - view.queryByRole('button', { name: new RegExp(checkboxLabel) }) - ).toBeNull(); - }); - - it('automatically labels InfoTip by field label with ReactNode label', () => { - const { view } = renderView({ - field: { - ...stubCheckboxField, - label: {checkboxLabel}, - infotip: { info }, - }, - }); - - view.getByRole('button', { name: new RegExp(checkboxLabel) }); - }); - }); -}); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputs.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputs.test.tsx deleted file mode 100644 index ea7f9303a8..0000000000 --- a/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputs.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { cleanup } from '@testing-library/react'; - -import { - additionalRadioGroupTests, - additionalTextAreaTests, - itHandlesStandardFieldTests, -} from '../__fixtures__/assertions'; -import { getComponent } from '../__fixtures__/renderers'; - -describe('GridFormInputs', () => { - afterEach(() => { - cleanup(); - }); - describe.each([ - { component: 'GridFormCheckboxInput', selector: 'checkbox' }, - { component: 'GridFormFileInput', selector: 'stub-file' }, - { component: 'GridFormSelectInput', selector: 'combobox' }, - { component: 'GridFormRadioGroupInput', selector: 'radiogroup' }, - { component: 'GridFormTextArea', selector: 'textbox' }, - { component: 'GridFormTextInput', selector: 'textbox' }, - ])('$component', ({ component, selector }) => { - const { renderField, defaultFieldProps } = getComponent(component); - - itHandlesStandardFieldTests({ - renderField, - defaultFieldProps, - component, - selector, - }); - if (component === 'GridFormRadioGroupInput') - additionalRadioGroupTests({ renderField, defaultFieldProps }); - if (component === 'GridFormTextArea') - additionalTextAreaTests({ renderField, defaultFieldProps }); - }); -}); diff --git a/packages/gamut/src/GridForm/GridFormSections/__fixtures__/renderers.tsx b/packages/gamut/src/GridForm/GridFormSections/__fixtures__/renderers.tsx deleted file mode 100644 index 6e14ef471d..0000000000 --- a/packages/gamut/src/GridForm/GridFormSections/__fixtures__/renderers.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type * as React from 'react'; - -import { FormContext } from '../../__fixtures__/helpers'; -import type { GridFormContentProps } from '../GridFormContent'; -import { GridFormContent } from '../GridFormContent'; -import type { GridFormSectionProps } from '../GridFormSection'; -import { GridFormSection } from '../GridFormSection'; - -type GridFormContentTestComponentProps = GridFormContentProps & { - mode?: 'onSubmit' | 'onChange'; -}; - -type GridFormSectionTestComponentProps = GridFormSectionProps & { - mode?: 'onSubmit' | 'onChange'; -}; - -export const GridFormContentTestComponent: React.FC< - GridFormContentTestComponentProps -> = ({ field, mode = 'onSubmit' }) => { - return ( - - - - ); -}; - -export const GridFormSectionTestComponent: React.FC< - GridFormSectionTestComponentProps -> = ({ fields, mode = 'onSubmit' }) => { - return ( - - - - ); -}; diff --git a/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormContent.test.tsx b/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormContent.test.tsx deleted file mode 100644 index b25444f1ac..0000000000 --- a/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormContent.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act } from '@testing-library/react'; - -import { stubTextField } from '../../__tests__/stubs'; -import { GridFormContentTestComponent } from '../__fixtures__/renderers'; - -const renderView = setupRtl(GridFormContentTestComponent, { - field: stubTextField, - showRequired: false, -}); - -const validStubTextField = { - ...stubTextField, - validation: { - pattern: { - message: 'not enough updog', - value: /updog(.*)updog/, - }, - required: true, - }, -}; - -describe('GridFormContent', () => { - it('renders the label and field', () => { - const { view } = renderView(); - - view.getByLabelText('Stub Text (optional)'); - view.getByRole('textbox', { name: /Stub Text/ }); - }); - - it('gives the field access to form context and validation', async () => { - const { view } = renderView({ - field: validStubTextField, - mode: 'onChange', - }); - - act(() => { - fireEvent.input(view.getByRole('textbox', { name: /Stub Text/ }), { - target: { - value: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }, - }); - }); - expect(await view.findAllByRole('alert')).toHaveLength(1); - }); -}); diff --git a/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormSection.test.tsx b/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormSection.test.tsx deleted file mode 100644 index 00d9d9f498..0000000000 --- a/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormSection.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act } from 'react'; - -import { - stubFileField, - stubSelectField, - stubTextField, -} from '../../__tests__/stubs'; -import type { GridFormTextField } from '../../types'; -import { GridFormSectionTestComponent } from '../__fixtures__/renderers'; - -const fields = [stubTextField, stubSelectField, stubFileField]; - -const validStubTextField = { - ...stubTextField, - validation: { - pattern: { - message: 'not enough updog', - value: /updog(.*)updog/, - }, - required: true, - }, -}; - -const validStubTextTwo: GridFormTextField = { - label: 'Stub Text Again', - name: 'stub-text-again', - size: 6, - type: 'text', - validation: { - pattern: { - message: 'what is it?', - value: /updog/, - }, - required: true, - }, -}; - -const validFields = [validStubTextField, validStubTextTwo]; - -const renderView = setupRtl(GridFormSectionTestComponent, { - fields, - showRequired: false, -}); - -describe('GridFormSections', () => { - it('renders an array of fields', () => { - const { view } = renderView(); - - const textLabel = view.getByLabelText('Stub Text (optional)'); - const textField = view.getByRole('textbox', { name: /Stub Text/ }); - const radioLabel = view.getByLabelText('Stub Select (optional)'); - const radioField = view.getByRole('combobox', { name: /Stub Select/ }); - - expect(textLabel).toBeTruthy(); - expect(textField).toBeTruthy(); - expect(radioLabel).toBeTruthy(); - expect(radioField).toBeTruthy(); - }); - - it('gives all fields access to form context and validation', async () => { - const { view } = renderView({ - fields: validFields, - mode: 'onChange', - }); - - await act(async () => { - fireEvent.input(view.getByRole('textbox', { name: 'Stub Text' }), { - target: { - value: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }, - }); - }); - - await act(async () => { - fireEvent.input(view.getByRole('textbox', { name: 'Stub Text Again' }), { - target: { - value: 'Do you know what that is?', - }, - }); - }); - - await view.findByText('what is it?'); - await view.findByText('not enough updog'); - }); - - it('only first error is an alert', async () => { - const { view } = renderView({ - fields: validFields, - mode: 'onChange', - }); - - await act(async () => { - fireEvent.input(view.getByRole('textbox', { name: 'Stub Text' }), { - target: { - value: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }, - }); - }); - - await act(async () => { - fireEvent.input(view.getByRole('textbox', { name: 'Stub Text Again' }), { - target: { - value: 'Do you know what that is?', - }, - }); - }); - - expect(await view.findAllByRole('alert')).toHaveLength(1); - }); -}); diff --git a/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormSectionTitle.test.tsx b/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormSectionTitle.test.tsx deleted file mode 100644 index 56ee1b4330..0000000000 --- a/packages/gamut/src/GridForm/GridFormSections/__tests__/GridFormSectionTitle.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { matchers } from '@emotion/jest'; -import { theme } from '@skillsoft/gamut-styles'; -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { GridFormSectionTitle } from '../GridFormSectionTitle'; - -// Add the custom matchers provided by '@emotion/jest' -expect.extend(matchers); -const renderView = setupRtl(GridFormSectionTitle, { - title: 'Updog', - numberOfFields: 3, -}); - -describe('GridFormSectionTitle', () => { - it('renders a title that defaults to h2', () => { - const { view } = renderView(); - - expect(view.getByRole('heading', { level: 2 })).toHaveTextContent('Updog'); - }); - - it('renders a title that can be set to other header tags', () => { - const { view } = renderView({ as: 'h3' }); - - expect(view.getByRole('heading', { level: 3 })).toHaveTextContent('Updog'); - }); - - it('renders a title that can be styled with Text props', () => { - const { view } = renderView({ as: 'h3', variant: 'title-xxl' }); - const titleNode = view.getByRole('heading', { level: 3 }); - - expect(titleNode).toHaveStyle({ fontSize: '4rem' }); - }); - - it('renders the proper Column size when layout is set to center', () => { - const { view } = renderView({ 'data-testid': 'columnId' }); - - const column = view.getByTestId('columnId'); - - expect(column).toHaveStyle({ gridColumnEnd: 'span 12' }); - expect(column).toHaveStyle({ gridRowEnd: 'span 1' }); - }); - - it('renders the proper Column size according to number of fields when layout is set to left', () => { - const { view } = renderView({ layout: 'left', 'data-testid': 'columnId' }); - - const column = view.getByTestId('columnId'); - - expect(column).toHaveStyle({ gridRowEnd: 'span 1' }); - - expect(column).toHaveStyleRule('grid-row-end', 'span 3', { - media: theme.breakpoints.md, - }); - - expect(column).toHaveStyle({ gridRowEnd: 'span 1' }); - - expect(column).toHaveStyleRule('grid-row-end', 'span 3', { - media: theme.breakpoints.md, - }); - - expect(column).toHaveStyle({ gridColumnEnd: 'span 12' }); - - expect(column).toHaveStyleRule('grid-column-end', 'span 3', { - media: theme.breakpoints.md, - }); - }); -}); diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.stories.tsx b/packages/gamut/src/GridForm/Layout.stories.tsx similarity index 73% rename from packages/styleguide/src/lib/Organisms/GridForm/Layout.stories.tsx rename to packages/gamut/src/GridForm/Layout.stories.tsx index 06914e1752..b618507cde 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Layout.stories.tsx +++ b/packages/gamut/src/GridForm/Layout.stories.tsx @@ -1,15 +1,17 @@ -import { GridForm, Markdown } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; -import { action } from 'storybook/actions'; +import { expect, fn } from 'storybook/test'; + +import { Markdown } from '../Markdown'; +import { GridForm } from '.'; + +const onSubmit = fn(); const meta: Meta = { + title: 'Inputs & forms/GridForm/Layout', component: GridForm, + parameters: { docs: { propsTable: false } }, args: { - onSubmit: (values) => { - action('Form Submitted')(values); - // eslint-disable-next-line no-console - console.log('Form Submitted', values); - }, + onSubmit, submit: { contents: 'Submit', size: 4, @@ -74,6 +76,11 @@ export const Formatted: Story = { ], requiredTextProps: { color: 'danger', variant: 'title-xs' }, }, + play: ({ canvas }) => { + canvas.getByText('* Required'); + canvas.getByLabelText('Simple select (required)*'); + canvas.getByLabelText('Simple text (optional)'); + }, }; export const HideRequiredText: Story = { @@ -96,6 +103,9 @@ export const HideRequiredText: Story = { ], hideRequiredText: true, }, + play: async ({ canvas }) => { + await expect(canvas.queryByText('* Required')).toBeNull(); + }, }; export const SoloField: Story = { @@ -113,6 +123,10 @@ export const SoloField: Story = { }, ], }, + play: async ({ canvas }) => { + await expect(canvas.queryByText('* Required')).toBeNull(); + canvas.getByLabelText('A field'); + }, }; export const InfoTip: Story = { @@ -194,6 +208,11 @@ export const InfoTip: Story = { }, ], }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('textbox', { name: /Write a paragraph about infotips/ }) + ).toHaveAttribute('rows', '6'); + }, }; export const Sections: Story = { @@ -281,4 +300,65 @@ export const Sections: Story = { ], requiredTextProps: { color: 'primary', fontStyle: 'italic' }, }, + play: async ({ canvas }) => { + canvas.getByRole('heading', { level: 2, name: 'first section' }); + canvas.getByRole('heading', { level: 3, name: 'hi there... again' }); + await expect(canvas.getAllByTestId('form-section-break')).toHaveLength(1); + await expect(canvas.getAllByText('* Required')).toHaveLength(1); + }, +}; + +export const SingleSection: Story = { + args: { + fields: [ + { + title: 'only section', + fields: [ + { + label: 'First', + name: 'first', + size: 6, + type: 'text', + }, + { + label: 'Second', + name: 'second', + size: 6, + type: 'text', + }, + ], + }, + ], + }, + play: async ({ canvas }) => { + canvas.getByRole('heading', { level: 2, name: 'only section' }); + await expect(canvas.queryByTestId('form-section-break')).toBeNull(); + }, +}; + +export const HasSoloField: Story = { + args: { + hasSoloField: true, + fields: [ + { + label: 'Email', + name: 'email', + size: 12, + type: 'email', + validation: { required: 'Please enter your email' }, + }, + { + render: ({ register }) => ( + + ), + name: 'source', + size: 12, + type: 'custom-group', + }, + ], + }, + play: async ({ canvas }) => { + await expect(canvas.queryByText('* Required')).toBeNull(); + canvas.getByLabelText('Email'); + }, }; diff --git a/packages/gamut/src/GridForm/States.stories.tsx b/packages/gamut/src/GridForm/States.stories.tsx new file mode 100644 index 0000000000..8984ccad61 --- /dev/null +++ b/packages/gamut/src/GridForm/States.stories.tsx @@ -0,0 +1,500 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { expect, fn, waitFor, within } from 'storybook/test'; + +import { GridForm } from '.'; + +const onSubmit = fn(); +const onUpdate = { + text: fn(), + textarea: fn(), + select: fn(), + checkbox: fn(), + radio: fn(), + file: fn(), +}; + +const meta: Meta = { + title: 'Inputs & forms/GridForm/States', + component: GridForm, + parameters: { docs: { propsTable: false } }, + args: { + onSubmit, + submit: { + contents: 'Submit', + size: 4, + position: 'left', + }, + hideRequiredText: true, + }, +}; + +export default meta; +type Story = StoryObj; + +type Canvas = Parameters>[0]['canvas']; + +const submitButton = (canvas: Canvas) => + canvas.getByRole('button', { name: 'Submit' }); + +export const DisabledInputs: Story = { + args: { + fields: [ + { + disabled: true, + label: 'Disabled text', + name: 'disabled-text', + type: 'text', + size: 6, + }, + { + label: 'Enabled text', + name: 'enabled-text', + type: 'text', + size: 6, + }, + ], + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('textbox', { name: /Disabled text/ }) + ).toBeDisabled(); + await expect( + canvas.getByRole('textbox', { name: /Enabled text/ }) + ).toBeEnabled(); + }, +}; + +export const DisabledFieldTypes: Story = { + args: { + fields: [ + { + disabled: true, + label: 'Select', + name: 'select', + options: ['aaa', 'bbb'], + size: 4, + type: 'select', + }, + { + disabled: true, + description: 'Check me!', + label: 'Checkbox', + name: 'checkbox', + size: 4, + type: 'checkbox', + }, + { + disabled: true, + label: 'Radio group', + name: 'radio', + options: [ + { label: 'Blue Team', value: 'blue' }, + { label: 'Red Team', value: 'red' }, + ], + size: 4, + type: 'radio-group', + }, + { + disabled: true, + label: 'Textarea', + name: 'textarea', + size: 4, + type: 'textarea', + }, + { + disabled: true, + label: 'File', + name: 'file', + size: 4, + type: 'file', + }, + ], + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('combobox', { name: /Select/ }) + ).toBeDisabled(); + await expect( + canvas.getByRole('checkbox', { name: 'Check me!' }) + ).toBeDisabled(); + await expect( + canvas.getByRole('radio', { name: 'Blue Team' }) + ).toBeDisabled(); + await expect( + canvas.getByRole('radio', { name: 'Red Team' }) + ).toBeDisabled(); + await expect( + canvas.getByRole('textbox', { name: /Textarea/ }) + ).toBeDisabled(); + await expect(canvas.getByLabelText(/^File/)).toBeDisabled(); + }, +}; + +export const OnFieldUpdate: Story = { + render: function OnFieldUpdate(args) { + const [text, setText] = useState(''); + return ( + <> + <>The text value is: {text} + + + ); + }, + play: async ({ canvas, userEvent }) => { + await userEvent.type( + canvas.getByRole('textbox', { name: 'Text with onUpdate' }), + 'hi' + ); + canvas.getByText('The text value is: hi'); + }, +}; + +export const OnUpdateByFieldType: Story = { + args: { + fields: [ + { + label: 'Text', + name: 'text', + size: 6, + type: 'text', + onUpdate: onUpdate.text, + }, + { + label: 'Textarea', + name: 'textarea', + size: 6, + type: 'textarea', + onUpdate: onUpdate.textarea, + }, + { + label: 'Select', + name: 'select', + options: ['aaa', 'bbb'], + size: 6, + type: 'select', + onUpdate: onUpdate.select, + }, + { + description: 'Check me!', + label: 'Checkbox', + name: 'checkbox', + size: 6, + type: 'checkbox', + onUpdate: onUpdate.checkbox, + }, + { + label: 'Radio group', + name: 'radio', + options: [ + { label: 'Blue Team', value: 'blue' }, + { label: 'Red Team', value: 'red' }, + ], + size: 6, + type: 'radio-group', + onUpdate: onUpdate.radio, + }, + { + label: 'File', + name: 'file', + size: 6, + type: 'file', + onUpdate: onUpdate.file, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + await userEvent.type(canvas.getByRole('textbox', { name: /^Text\s/ }), 'a'); + await expect(onUpdate.text).toHaveBeenLastCalledWith('a'); + + await userEvent.type( + canvas.getByRole('textbox', { name: /Textarea/ }), + 'b' + ); + await expect(onUpdate.textarea).toHaveBeenLastCalledWith('b'); + + await userEvent.selectOptions( + canvas.getByRole('combobox', { name: /Select/ }), + 'bbb' + ); + await expect(onUpdate.select).toHaveBeenLastCalledWith('bbb'); + + await userEvent.click(canvas.getByRole('checkbox', { name: 'Check me!' })); + await expect(onUpdate.checkbox).toHaveBeenLastCalledWith(true); + + await userEvent.click(canvas.getByRole('radio', { name: 'Red Team' })); + await expect(onUpdate.radio).toHaveBeenLastCalledWith('red'); + + await userEvent.upload( + canvas.getByLabelText(/^File/), + new File(['hi'], 'notes.txt', { type: 'text/plain' }) + ); + await expect(onUpdate.file).toHaveBeenCalledTimes(1); + await expect(onUpdate.file.mock.calls[0][0][0].name).toBe('notes.txt'); + }, +}; + +export const DisabledFieldsOnSubmit: Story = { + args: { + disableFieldsOnSubmit: true, + fields: [ + { + label: 'Email', + placeholder: 'i will disable on correct submission!', + name: 'disabled-fields-on-submit', + type: 'email', + validation: { + required: 'pls fill this out', + pattern: { + value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,})+$/, + message: 'that is not an email 😔', + }, + }, + size: 9, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: 'Email' }); + + await userEvent.type(input, 'nope'); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('that is not an email 😔'); + await expect(input).toBeEnabled(); + + await userEvent.clear(input); + await userEvent.type(input, 'ada@example.com'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => expect(input).toBeDisabled()); + }, +}; + +export const ResetOnSubmit: Story = { + args: { + fields: [ + { + label: 'Email', + placeholder: 'i will reset on correct submission!', + name: 'reset-on-submit', + type: 'email', + validation: { + required: 'pls fill this out', + pattern: { + value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,})+$/, + message: 'that is not an email 😔', + }, + }, + size: 9, + }, + ], + resetOnSubmit: true, + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: 'Email' }); + + await userEvent.type(input, 'nope'); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('that is not an email 😔'); + await expect(input).toHaveValue('nope'); + + await userEvent.clear(input); + await userEvent.type(input, 'ada@example.com'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'reset-on-submit': 'ada@example.com' }, + expect.anything() + ) + ); + }, +}; + +export const ResetsFieldsToDefaults: Story = { + args: { + resetOnSubmit: true, + fields: [ + { + description: 'Check me!', + label: 'Checkbox', + name: 'checkbox', + size: 4, + type: 'checkbox', + }, + { + label: 'Select', + name: 'select', + options: ['aaa', 'bbb'], + size: 4, + type: 'select', + }, + { + label: 'Text', + name: 'text', + size: 4, + type: 'text', + validation: { required: 'Please enter text' }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const checkbox = canvas.getByRole('checkbox', { name: 'Check me!' }); + const select = canvas.getByRole('combobox', { name: /Select/ }); + const text = canvas.getByRole('textbox', { name: /Text/ }); + + await userEvent.click(checkbox); + await userEvent.selectOptions(select, 'bbb'); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Please enter text'); + await expect(checkbox).toBeChecked(); + await expect(select).toHaveValue('bbb'); + + await userEvent.type(text, 'Hooray!'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { checkbox: true, select: 'bbb', text: 'Hooray!' }, + expect.anything() + ) + ); + await waitFor(() => expect(text).toHaveValue('')); + await expect(checkbox).not.toBeChecked(); + await expect(select).toHaveValue('aaa'); + }, +}; + +export const FormLoading: Story = { + args: { + disableFieldsOnSubmit: true, + fields: [ + { + label: 'Email', + placeholder: + 'i will disable form fields on loading and reset on correct submission!', + name: 'im-new', + type: 'email', + validation: { + required: 'pls fill this out', + pattern: { + value: + /^(?:[a-zA-Z0-9_]+(?:[.-]?[a-zA-Z0-9_]+)*)@(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, + message: 'that is not an email 😔', + }, + }, + size: 9, + }, + ], + resetOnSubmit: true, + }, + render: function FormLoading(args) { + const [loading, setLoading] = useState(false); + + const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const onSubmit = async () => { + setLoading(true); + await wait(2000); + setLoading(false); + }; + + return ( + + ); + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: 'Email' }); + + await userEvent.type(input, 'ada@example.com'); + await userEvent.click(submitButton(canvas)); + + await expect( + await canvas.findByRole('button', { name: 'Loading' }) + ).toBeDisabled(); + await expect(input).toBeDisabled(); + + await waitFor(() => expect(submitButton(canvas)).toBeEnabled(), { + timeout: 4000, + }); + }, +}; + +export const CustomError: Story = { + args: { + fields: [ + { + label: 'Who is the best at bending?', + name: 'custom-error', + size: 9, + type: 'text', + customError: 'NOT Flexo.', + validation: { + required: true, + pattern: { + value: /Bender/, + message: 'Just type Bender...', + }, + }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { + name: 'Who is the best at bending?', + }); + canvas.getByText('NOT Flexo.'); + await expect(input).toBeInvalid(); + + await userEvent.type(input, 'Bender'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'custom-error': 'Bender' }, + expect.anything() + ) + ); + canvas.getByText('NOT Flexo.'); + }, +}; + +export const MarkdownErrors: Story = { + args: { + fields: [ + { + label: 'there is a markdown error here!', + name: 'markdown-error', + type: 'email', + validation: { + required: + 'This is [an example](https://www.youtube.com/watch?v=5IuRzJRrRpQ) error link.', + }, + size: 9, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(submitButton(canvas)); + + const alert = await canvas.findByRole('alert'); + await expect( + within(alert).getByRole('link', { name: 'an example' }) + ).toHaveAttribute('href', 'https://www.youtube.com/watch?v=5IuRzJRrRpQ'); + }, +}; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Usage.stories.tsx b/packages/gamut/src/GridForm/Usage.stories.tsx similarity index 72% rename from packages/styleguide/src/lib/Organisms/GridForm/Usage.stories.tsx rename to packages/gamut/src/GridForm/Usage.stories.tsx index a745c1692a..8be855464a 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Usage.stories.tsx +++ b/packages/gamut/src/GridForm/Usage.stories.tsx @@ -1,10 +1,15 @@ -import { GridForm } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; -import { action } from 'storybook/actions'; +import { expect, fn, waitFor } from 'storybook/test'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; +import { GridForm } from '.'; + +const onSubmit = fn(); + const meta: TypeWithDeepControls> = { + title: 'Inputs & forms/GridForm/Usage', component: GridForm, + parameters: { deepControls: { enabled: true } }, args: { fields: [ { @@ -154,15 +159,12 @@ const meta: TypeWithDeepControls> = { loading: false, type: 'fill', }, - onSubmit: (values) => { - action('Form Submitted')(values); - // eslint-disable-next-line no-console - console.log('Form Submitted', values); - }, + onSubmit, validation: 'onSubmit', resetOnSubmit: true, }, argTypes: { + validation: { control: 'radio' }, 'submit.type': { control: 'radio', options: ['fill', 'cta'], @@ -227,3 +229,74 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; + +export const SubmitsFieldValues: Story = { + args: { + fields: [ + { + description: 'Check me!', + label: 'Stub Checkbox', + name: 'stub-checkbox', + size: 6, + type: 'checkbox', + }, + { + label: 'Stub Select', + name: 'stub-select', + options: ['aaa', 'bbb'], + size: 6, + type: 'select', + }, + { label: 'Stub Text', name: 'stub-text', size: 6, type: 'text' }, + ], + submit: { contents: 'Submit', size: 6 }, + resetOnSubmit: false, + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('checkbox', { name: 'Check me!' })); + await userEvent.selectOptions( + canvas.getByRole('combobox', { name: /Stub Select/ }), + 'bbb' + ); + await userEvent.type( + canvas.getByRole('textbox', { name: /Stub Text/ }), + 'Hooray!' + ); + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { 'stub-checkbox': true, 'stub-select': 'bbb', 'stub-text': 'Hooray!' }, + expect.anything() + ) + ); + }, +}; + +export const SubmitsOnEnter: Story = { + args: { + fields: [ + { label: 'First name', name: 'first', size: 6, type: 'text' }, + { label: 'Last name', name: 'last', size: 6, type: 'text' }, + ], + submit: { contents: 'Submit', size: 6 }, + resetOnSubmit: false, + }, + play: async ({ canvas, userEvent }) => { + await userEvent.type( + canvas.getByRole('textbox', { name: /First name/ }), + 'Ada' + ); + await userEvent.type( + canvas.getByRole('textbox', { name: /Last name/ }), + 'Lovelace{Enter}' + ); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { first: 'Ada', last: 'Lovelace' }, + expect.anything() + ) + ); + }, +}; diff --git a/packages/gamut/src/GridForm/Validation.stories.tsx b/packages/gamut/src/GridForm/Validation.stories.tsx new file mode 100644 index 0000000000..8afd9393c6 --- /dev/null +++ b/packages/gamut/src/GridForm/Validation.stories.tsx @@ -0,0 +1,355 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { GridForm } from '.'; + +const onSubmit = fn(); + +const meta: Meta = { + title: 'Inputs & forms/GridForm/Validation', + component: GridForm, + parameters: { docs: { propsTable: false } }, + args: { + onSubmit, + submit: { contents: 'Submit', size: 4 }, + }, +}; + +export default meta; +type Story = StoryObj; + +type Canvas = Parameters>[0]['canvas']; + +const submitButton = (canvas: Canvas) => + canvas.getByRole('button', { name: 'Submit' }); + +const requiredText = { + label: 'Name', + name: 'name', + size: 6, + type: 'text', + validation: { required: 'Please enter your name' }, +} as const; + +export const OnSubmit: Story = { + args: { + fields: [ + { + description: 'I agree', + label: 'Terms', + name: 'terms', + size: 6, + type: 'checkbox', + validation: { required: 'Please check the box' }, + }, + { + label: 'Team', + name: 'team', + options: ['', 'Blue', 'Red'], + size: 6, + type: 'select', + validation: { required: 'Please pick a team' }, + }, + requiredText, + { + label: 'Bio', + name: 'bio', + size: 6, + type: 'textarea', + validation: { required: 'Please write a bio' }, + }, + { + label: 'Color', + name: 'color', + options: [ + { label: 'Blue', value: 'blue' }, + { label: 'Red', value: 'red' }, + ], + size: 6, + type: 'radio-group', + validation: { required: 'Please pick a color' }, + }, + { + label: 'Avatar', + name: 'avatar', + size: 6, + type: 'file', + validation: { required: 'Please upload an avatar' }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const fields = [ + canvas.getByRole('checkbox', { name: 'I agree' }), + canvas.getByRole('combobox', { name: /Team/ }), + canvas.getByRole('textbox', { name: /Name/ }), + canvas.getByRole('textbox', { name: /Bio/ }), + canvas.getByRole('radiogroup', { name: 'Color' }), + canvas.getByLabelText(/Avatar/), + ]; + + await Promise.all( + fields.map((field) => expect(field).toHaveAttribute('aria-required')) + ); + await Promise.all(fields.map((field) => expect(field).not.toBeInvalid())); + + await userEvent.type(canvas.getByRole('textbox', { name: /Name/ }), 'A'); + await userEvent.clear(canvas.getByRole('textbox', { name: /Name/ })); + await userEvent.tab(); + await expect(canvas.queryByText('Please enter your name')).toBeNull(); + await expect(submitButton(canvas)).toBeEnabled(); + + await userEvent.click(submitButton(canvas)); + + const alert = await canvas.findByRole('alert'); + await expect(alert).toHaveAttribute('aria-live', 'assertive'); + const statuses = canvas.getAllByRole('status'); + await expect(statuses).toHaveLength(5); + await Promise.all( + statuses.map((status) => + expect(status).toHaveAttribute('aria-live', 'off') + ) + ); + await Promise.all( + fields.slice(1).map((field) => expect(field).toBeInvalid()) + ); + await expect(onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const OnChange: Story = { + args: { + validation: 'onChange', + fields: [ + { + ...requiredText, + validation: { + required: 'Please enter your name', + minLength: { value: 3, message: 'Name is too short' }, + }, + }, + { label: 'Nickname', name: 'nickname', size: 6, type: 'text' }, + ], + submit: { + contents: 'Submit', + size: 4, + disabled: ({ isValid }) => !isValid, + }, + }, + play: async ({ canvas, userEvent }) => { + const input = canvas.getByRole('textbox', { name: /^Name/ }); + await expect(input).toHaveAttribute('aria-required'); + await waitFor(() => expect(submitButton(canvas)).toBeDisabled()); + + await userEvent.type(input, 'Al'); + await expect(await canvas.findByRole('alert')).toHaveTextContent( + 'Name is too short' + ); + await expect(submitButton(canvas)).toBeDisabled(); + + await userEvent.type(input, 'an'); + await waitFor(() => expect(canvas.queryByRole('alert')).toBeNull()); + await waitFor(() => expect(submitButton(canvas)).toBeEnabled()); + }, +}; + +export const OnTouched: Story = { + args: { + validation: 'onTouched', + fields: [ + { + ...requiredText, + validation: { + required: 'Please enter your name', + minLength: { value: 3, message: 'Name is too short' }, + }, + }, + { label: 'Nickname', name: 'nickname', size: 6, type: 'text' }, + ], + }, + play: async ({ canvas, userEvent }) => { + await userEvent.type(canvas.getByRole('textbox', { name: /^Name/ }), 'Al'); + await expect(canvas.queryByText('Name is too short')).toBeNull(); + + await userEvent.tab(); + await canvas.findByText('Name is too short'); + }, +}; + +export const Rules: Story = { + args: { + fields: [ + { + label: 'Email', + name: 'email', + size: 6, + type: 'email', + validation: { + required: 'Email is required', + pattern: { + value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: 'Please enter a valid email address', + }, + }, + }, + { + label: 'Message', + name: 'message', + size: 6, + type: 'text', + validation: { + required: 'Message is required', + minLength: { + value: 10, + message: 'Message must be at least 10 characters', + }, + maxLength: { + value: 20, + message: 'Message cannot exceed 20 characters', + }, + }, + }, + { + label: 'Age', + name: 'age', + size: 6, + type: 'number', + validation: { + required: 'Age is required', + min: { value: 18, message: 'Must be at least 18 years old' }, + max: { value: 99, message: 'Age cannot exceed 99' }, + }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + const email = canvas.getByRole('textbox', { name: /Email/ }); + const message = canvas.getByRole('textbox', { name: /Message/ }); + const age = canvas.getByRole('spinbutton', { name: /Age/ }); + + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Email is required'); + canvas.getByText('Message is required'); + canvas.getByText('Age is required'); + + await userEvent.type(email, 'not-an-email'); + await userEvent.type(message, 'short'); + await userEvent.type(age, '12'); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Please enter a valid email address'); + canvas.getByText('Message must be at least 10 characters'); + canvas.getByText('Must be at least 18 years old'); + + await userEvent.type(message, ' and then far too long'); + await userEvent.clear(age); + await userEvent.type(age, '120'); + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Message cannot exceed 20 characters'); + canvas.getByText('Age cannot exceed 99'); + await expect(onSubmit).not.toHaveBeenCalled(); + + await userEvent.clear(email); + await userEvent.type(email, 'ada@example.com'); + await userEvent.clear(message); + await userEvent.type(message, 'Hello there!'); + await userEvent.clear(age); + await userEvent.type(age, '36'); + await userEvent.click(submitButton(canvas)); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { email: 'ada@example.com', message: 'Hello there!', age: '36' }, + expect.anything() + ) + ); + }, +}; + +export const ErrorWiring: Story = { + args: { + fields: [ + requiredText, + { label: 'Nickname', name: 'nickname', size: 6, type: 'text' }, + ], + }, + play: async ({ canvas, userEvent }) => { + /* + * TODO: Assert the input's accessible description is the error message once + * GridForm links errors to fields. GridFormInputGroup's FormError has no id + * and no GridForm input sets aria-describedby. + */ + const input = canvas.getByRole('textbox', { name: /^Name/ }); + + await userEvent.click(submitButton(canvas)); + + await expect(await canvas.findByRole('alert')).toHaveTextContent( + 'Please enter your name' + ); + await expect(input).toBeInvalid(); + }, +}; + +export const ErrorsClearWhenFixed: Story = { + args: { + fields: [ + requiredText, + { + label: 'Color', + name: 'color', + options: [ + { label: 'Blue', value: 'blue' }, + { label: 'Red', value: 'red' }, + ], + size: 6, + type: 'radio-group', + validation: { required: 'Please pick a color' }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + /* + * TODO: After picking a radio, assert its error clears and the group is no + * longer invalid. RadioGroup replaces each Radio's register onChange with + * its own, which calls setValue without revalidating. + */ + const input = canvas.getByRole('textbox', { name: /^Name/ }); + + await userEvent.click(submitButton(canvas)); + await canvas.findByText('Please enter your name'); + canvas.getByText('Please pick a color'); + + await userEvent.type(input, 'Ada'); + await waitFor(() => + expect(canvas.queryByText('Please enter your name')).toBeNull() + ); + await expect(input).not.toBeInvalid(); + }, +}; + +export const FocusesFirstInvalidField: Story = { + args: { + fields: [ + { ...requiredText, defaultValue: 'Ada' }, + { + label: 'Email', + name: 'email', + size: 6, + type: 'email', + validation: { required: 'Please enter your email' }, + }, + { + label: 'Bio', + name: 'bio', + size: 6, + type: 'textarea', + validation: { required: 'Please write a bio' }, + }, + ], + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(submitButton(canvas)); + + await waitFor(() => + expect(canvas.getByRole('textbox', { name: /Email/ })).toHaveFocus() + ); + }, +}; diff --git a/packages/gamut/src/GridForm/__fixtures__/helpers.tsx b/packages/gamut/src/GridForm/__fixtures__/helpers.tsx deleted file mode 100644 index 3e731a6b7a..0000000000 --- a/packages/gamut/src/GridForm/__fixtures__/helpers.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type * as React from 'react'; - -import { ConnectedForm } from '../../ConnectedForm'; -import type { WithChildrenProp } from '../../utils'; - -interface FormContextProps extends WithChildrenProp { - mode?: 'onChange' | 'onSubmit'; -} - -export const FormContext: React.FC = ({ - mode = 'onSubmit', - children, -}) => { - return ( - null}> - {children} - - ); -}; diff --git a/packages/gamut/src/GridForm/__tests__/GridForm.test.tsx b/packages/gamut/src/GridForm/__tests__/GridForm.test.tsx deleted file mode 100644 index 899278d5f3..0000000000 --- a/packages/gamut/src/GridForm/__tests__/GridForm.test.tsx +++ /dev/null @@ -1,587 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import type { queries } from '@testing-library/dom'; -import { fireEvent } from '@testing-library/dom'; -import type { RenderResult } from '@testing-library/react'; -import { act, waitFor } from '@testing-library/react'; - -import { createPromise } from '../../utils'; -import { GridForm } from '../GridForm'; -import type { GridFormField } from '../types'; -import { - stubCheckboxField, - stubFieldCases, - stubHiddenField, - stubSelectField, - stubSelectOptions, - stubSweetContainerField, - stubTextField, -} from './stubs'; - -const fields = [stubCheckboxField, stubSelectField, stubTextField]; - -const validationFields = [ - { ...stubCheckboxField, validation: { required: 'Please check' } }, - { - ...stubSelectField, - validation: { required: 'Please select a value' }, - }, - { - ...stubTextField, - validation: { required: 'Please enter text' }, - }, -]; - -export const getonUpdateAndFields = () => { - const onUpdateCheckbox = jest.fn(); - const onUpdateSelect = jest.fn(); - const onUpdateText = jest.fn(); - const fields = [ - { ...stubCheckboxField, onUpdate: onUpdateCheckbox }, - { - ...stubSelectField, - onUpdate: onUpdateSelect, - }, - { - ...stubTextField, - onUpdate: onUpdateText, - }, - ]; - return { - onUpdateCheckbox, - onUpdateSelect, - onUpdateText, - fields, - }; -}; - -const renderView = setupRtl(GridForm, { - fields, - submit: { type: 'fill', contents: <>Submit, size: 6 }, -}); - -type RenderViewReturn = ReturnType; - -type CaseFindProps = { - field: GridFormField; - getBy: string; - roleOrLabel: string; - nameId: { name: string } | undefined; - id: string; -}; - -const createAndFind = ({ - field, - getBy, - roleOrLabel, - nameId, - id, -}: CaseFindProps) => { - const fields = [{ id, ...field }]; - - const { view } = renderView({ - fields, - }); - switch (getBy) { - case 'byLabelText': - return view.getByLabelText(roleOrLabel); - case 'allByRole': - return view.getAllByRole(roleOrLabel); - case 'byRole': - return view.getByRole(roleOrLabel, nameId); - } -}; - -const asyncRenderView = async ( - ...props: Parameters -): Promise => { - let renderResults: RenderViewReturn; - - await act(async () => { - renderResults = renderView(...props); - }); - - return renderResults!; -}; - -const getBaseCases = (view: RenderResult) => { - const checkboxField = view.getByRole('checkbox', { - name: 'Check me!', - }) as HTMLInputElement; - const selectField = view.getByRole('combobox', { - name: /Stub Select/, - }) as HTMLInputElement; - const textField = view.getByRole('textbox', { - name: /Stub Text/, - }) as HTMLInputElement; - return { checkboxField, selectField, textField }; -}; - -const selectValue = stubSelectOptions[1]; -const textValue = 'Hooray!'; - -const doBaseFormActions = ( - selectField: HTMLInputElement, - textField: HTMLInputElement, - checkboxField: HTMLInputElement -) => { - const newValues = [ - [selectField, selectValue], - [textField, textValue], - ] as const; - - fireEvent.click(checkboxField); - - for (const [selector, value] of newValues) { - fireEvent.change(selector, { - target: { - value, - }, - }); - } -}; - -const baseResults = { - [stubCheckboxField.name]: true, - [stubSelectField.name]: selectValue, - [stubTextField.name]: textValue, -}; - -describe('GridForm', () => { - it('submits the form when all inputs are filled out', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ onSubmit }); - const { checkboxField, selectField, textField } = getBaseCases(view); - - await act(async () => { - doBaseFormActions(selectField, textField, checkboxField); - fireEvent.submit(view.getByRole('button')); - - await api.innerPromise; - }); - - const result = await api.innerPromise; - - expect(result).toEqual(baseResults); - }); - - it('adds required text by default', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - const fields = [ - { ...stubTextField, validation: { required: 'Please enter text' } }, - stubSelectField, - ]; - const { view } = renderView({ fields, onSubmit }); - view.getByText('* Required'); - view.getByLabelText('Stub Text*'); - }); - - it('does not add required text for a single input', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ fields: [stubTextField], onSubmit }); - expect(view.queryByText('* Required')).toBeNull(); - view.getByLabelText('Stub Text'); - }); - - it('only sets aria-live prop on the first validation error in a form', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - const { view } = renderView({ fields: validationFields, onSubmit }); - - await act(async () => { - fireEvent.click(view.getByRole('button')); - }); - - // there should only be a single "assertive" error from the form submission - expect(view.getAllByRole('alert').length).toBe(1); - expect(view.getAllByRole('status').length).toBe(1); - }); - - describe('when "onSubmit" validation is selected', () => { - it('sets aria-required to true', () => { - const fields = [ - { ...stubTextField, validation: { required: 'Please enter text' } }, - ]; - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ fields, onSubmit }); - const textField = view.getByRole('textbox'); - - expect(textField).toHaveAttribute('aria-required'); - }); - - it('never disables the submit button', () => { - const fields = [ - { ...stubTextField, validation: { required: 'Please enter text' } }, - ]; - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ fields, onSubmit }); - const submitButton = view.getByRole('button'); - - expect(submitButton).not.toBeDisabled(); - }); - }); - - describe('when "onChange" validation is selected', () => { - it('sets aria-required to true ', async () => { - const fields = [ - { ...stubTextField, validation: { required: 'Please enter text' } }, - ]; - - const { view } = await asyncRenderView({ - fields, - validation: 'onChange', - }); - - const textField = view.getByRole('textbox'); - expect(textField).toHaveAttribute('aria-required'); - }); - - it('disables the submit button when required fields are incomplete', async () => { - const fields = [ - { ...stubTextField, validation: { required: 'Please enter text' } }, - ]; - - const { view } = renderView({ - fields, - validation: 'onChange', - submit: { - contents: <>Submit, - size: 6, - disabled: ({ isValid }) => !isValid, - }, - }); - - const textField = view.getByRole('textbox', { name: /Stub Text/ }); - - await act(async () => { - fireEvent.input(textField, { - target: { - value: '', - }, - }); - }); - - const submitButton = view.getByRole('button'); - expect(submitButton).toBeDisabled(); - }); - - it('enables the submit button after the required fields are completed', async () => { - const fields = [ - { ...stubTextField, validation: { required: 'Please enter text' } }, - ]; - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - fields, - onSubmit, - validation: 'onChange', - }); - - const textField = view.getByRole('textbox', { name: /Stub Text/ }); - - await act(async () => { - fireEvent.input(textField, { - target: { - value: 'an arbitrary value', - }, - }); - }); - - const submitButton = view.getByRole('button'); - expect(submitButton).not.toBeDisabled(); - expect(textField).toHaveAttribute('aria-required'); - }); - - it('keeps the submit button disabled when overridden and there are no incomplete fields', async () => { - const fields: GridFormField[] = []; - - const { view } = renderView({ - fields, - validation: 'onChange', - submit: { - type: 'fill', - contents: <>Submit, - disabled: true, - size: 6, - }, - }); - - const submitButton = view.getByRole('button'); - expect(submitButton).toBeDisabled(); - }); - }); - - describe('when supplied an id', () => { - it.each(stubFieldCases)( - `passes custom id to the %s`, - async ( - type: any, - field: GridFormField, - getBy: string, - roleOrLabel: string, - nameId: { name: string } | undefined, - id: string - ) => { - const renderedField = createAndFind({ - field, - getBy, - roleOrLabel, - nameId, - id, - }); - if (Array.isArray(renderedField)) { - renderedField.forEach((element, index) => { - expect(element).toHaveAttribute( - 'id', - `${field.name}-${index}-${id}` - ); - }); - } else { - expect(renderedField).toHaveAttribute('id', id); - } - } - ); - }); - - it('submits hidden input value', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - fields: [stubHiddenField], - onSubmit, - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - - await api.innerPromise; - }); - - const result = await api.innerPromise; - - expect(result).toEqual({ - [stubHiddenField.name]: stubHiddenField.defaultValue, - }); - }); - - it('submits sweet container input value', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - fields: [stubSweetContainerField], - onSubmit, - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - - await api.innerPromise; - }); - - const result = await api.innerPromise; - - expect(result).toEqual({ - [stubSweetContainerField.name]: stubSweetContainerField.defaultValue, - }); - }); - - describe('Section Break', () => { - it('renders a section break when there are multiple sections', () => { - const { view } = renderView({ - fields: [ - { - fields: [stubCheckboxField], - title: 'Test Title', - }, - { - fields: [stubTextField], - title: 'Other Test Title', - }, - ], - }); - const sectionBreaks = view.getAllByTestId('form-section-break'); - expect(sectionBreaks.length).toEqual(1); - }); - - it('renders a section break when there are multiple sections with subfields', () => { - const { view } = renderView({ - fields: [ - { - fields: [stubCheckboxField], - title: 'Test Title', - }, - { - fields: [stubTextField, stubCheckboxField], - title: 'Other Test Title', - }, - ], - }); - const sectionBreaks = view.getAllByTestId('form-section-break'); - expect(sectionBreaks.length).toEqual(1); - }); - - it('does NOT render a section break if there is only one section', () => { - const { view } = renderView({ - fields: [ - { - fields: [stubCheckboxField], - title: 'Test Title', - }, - ], - }); - const sectionBreaks = view.queryByTestId('form-section-break'); - expect(sectionBreaks).not.toBeInTheDocument(); - }); - - it('does NOT render a section break if there is only one section with multiple subfields', () => { - const { view } = renderView({ - fields: [ - { - fields: [stubCheckboxField, stubTextField], - title: 'Test Title', - }, - ], - }); - const sectionBreaks = view.queryByTestId('form-section-break'); - expect(sectionBreaks).not.toBeInTheDocument(); - }); - }); - - describe('Cancel button', () => { - it('renders a cancel and submit button when "cancel" props are provided', async () => { - const cancelOnClick = jest.fn(); - - const { view } = renderView({ - cancel: { children: 'Cancel', onClick: cancelOnClick }, - }); - - const buttons = view.getAllByRole('button'); - const cancelButton = view.getByText('Cancel'); - - await act(async () => { - fireEvent.click(cancelButton); - }); - - expect(cancelButton); - expect(buttons.length).toEqual(2); - expect(cancelOnClick).toHaveBeenCalled(); - }); - - it('renders only a submit when "cancel" props are not provided', () => { - const { view } = renderView(); - - const buttons = view.getAllByRole('button'); - - expect(buttons.length).toEqual(1); - expect(buttons[0]).toHaveTextContent('Submit'); - }); - }); - - describe('disableFieldsOnSubmit', () => { - it('disables fields when form is successfully submitted', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = await asyncRenderView({ - onSubmit, - disableFieldsOnSubmit: true, - }); - const { checkboxField, selectField, textField } = getBaseCases(view); - - await act(async () => { - doBaseFormActions(selectField, textField, checkboxField); - fireEvent.submit(view.getByRole('button')); - }); - - expect(checkboxField).toBeDisabled(); - expect(selectField).toBeDisabled(); - expect(textField).toBeDisabled(); - }); - - it('does not disable fields when form has a validation error', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - fields: validationFields, - onSubmit, - disableFieldsOnSubmit: true, - }); - - const { checkboxField, selectField, textField } = getBaseCases(view); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - expect(checkboxField).not.toBeDisabled(); - expect(selectField).not.toBeDisabled(); - expect(textField).not.toBeDisabled(); - }); - }); - - describe('resetOnSubmit', () => { - // to-do: reset fields is working on the component but not here, fun. - - it('resets fields when form is successfully submitted', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = await asyncRenderView({ onSubmit, resetOnSubmit: true }); - const { checkboxField, selectField, textField } = getBaseCases(view); - - await act(async () => { - doBaseFormActions(selectField, textField, checkboxField); - fireEvent.click(view.getByRole('button')); - }); - const firstResult = await api.innerPromise; - expect(firstResult).toEqual(baseResults); - - await waitFor(() => { - expect(checkboxField.checked).toEqual(false); - expect(selectField.value).toEqual('aaa'); - expect(textField.value).toEqual(''); - }); - }); - - it('does not reset fields when form is has a validation error', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderView({ - fields: validationFields, - onSubmit, - resetOnSubmit: true, - }); - - const { checkboxField, selectField, textField } = getBaseCases(view); - - await act(async () => { - fireEvent.input(textField, { - target: { - value: 'an arbitrary value', - }, - }); - }); - - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); - - expect(checkboxField.checked).toEqual(false); - expect(selectField.value).toEqual('aaa'); - expect(textField.value).toEqual('an arbitrary value'); - }); - }); -}); diff --git a/packages/gamut/src/GridForm/__tests__/stubs.ts b/packages/gamut/src/GridForm/__tests__/stubs.ts deleted file mode 100644 index d1c4e5ef37..0000000000 --- a/packages/gamut/src/GridForm/__tests__/stubs.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - GridFormCheckboxField, - GridFormFileField, - GridFormHiddenField, - GridFormRadioGroupField, - GridFormRadioOption, - GridFormSelectField, - GridFormSweetContainerField, - GridFormTextAreaField, - GridFormTextField, -} from '../types'; - -export const stubCheckboxField: GridFormCheckboxField = { - description: 'Check me!', - label: 'Check me!', - name: 'stub-checkbox', - size: 6, - type: 'checkbox', -}; - -export const stubRadioGroupOptions: GridFormRadioOption[] = [ - { - label: 'Blue Team', - value: 'blue', - }, - { - label: 'Red Team', - value: 'red', - }, -]; - -export const stubRadioGroupField: GridFormRadioGroupField = { - label: 'Stub Radio Group', - options: stubRadioGroupOptions, - name: 'stub-radio-group', - size: 6, - rowspan: 2, - type: 'radio-group', -}; - -export const stubSelectOptions = ['aaa', 'bbb']; - -export const stubSelectField: GridFormSelectField = { - label: 'Stub Select', - options: stubSelectOptions, - name: 'stub-select', - size: 6, - type: 'select', -}; - -export const stubTextField: GridFormTextField = { - label: 'Stub Text', - name: 'stub-text', - size: 6, - type: 'text', -}; - -export const stubFileField: GridFormFileField = { - label: 'Stub File', - name: 'stub-file', - size: 6, - type: 'file', -}; - -export const stubTextareaField: GridFormTextAreaField = { - label: 'Stub Textarea', - name: 'stub-textarea', - size: 6, - type: 'textarea', -}; - -export const stubHiddenField: GridFormHiddenField = { - name: 'secret-field', - type: 'hidden', - defaultValue: "you can't see me!", -}; - -export const stubSweetContainerField: GridFormSweetContainerField = { - name: 'sweet_container', - type: 'sweet-container', - label: 'Check this input if you are an automated machine, and not a human', - defaultValue: false, -}; - -export const stubFieldCases = [ - [ - 'text field', - stubTextField, - 'byRole', - 'textbox', - { name: 'Stub Text' }, - 'mycoolid', - ], - [ - 'select field', - stubSelectField, - 'byRole', - 'combobox', - { name: 'Stub Select' }, - 'swaggy-id', - ], - [ - 'checkbox field', - stubCheckboxField, - 'byRole', - 'checkbox', - { name: 'Check me!' }, - 'another-dank-id', - ], - [ - 'textarea field', - stubTextareaField, - 'byRole', - 'textbox', - { name: 'Stub Textarea' }, - 'id-2-the-ego', - ], - [ - 'radio group field', - stubRadioGroupField, - 'allByRole', - 'radio', - undefined, - 'and-another-one', - ], - [ - 'file field', - stubFileField, - 'byLabelText', - 'Stub File', - undefined, - 'fire-file', - ], -]; diff --git a/packages/gamut/src/List/Header/ListHeaderCol.tsx b/packages/gamut/src/List/Header/ListHeaderCol.tsx deleted file mode 100644 index e7c4dd29df..0000000000 --- a/packages/gamut/src/List/Header/ListHeaderCol.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ComponentProps } from 'react'; -import { forwardRef } from 'react'; - -import { ColEl } from '../elements'; -import { useListContext } from '../ListProvider'; -import type { PublicListProps } from '../types'; - -export interface ListColProps - extends PublicListProps> {} - -export const ListCol = forwardRef( - ({ type, ...rest }, ref) => { - const { scrollable, ...activeVariants } = useListContext(); - const sticky = type === 'header' && scrollable; - - return ( - - ); - } -); diff --git a/packages/styleguide/src/lib/Organisms/ListsAndTables/List/List.stories.tsx b/packages/gamut/src/List/List.stories.tsx similarity index 60% rename from packages/styleguide/src/lib/Organisms/ListsAndTables/List/List.stories.tsx rename to packages/gamut/src/List/List.stories.tsx index 2024d945d8..b8e949ded8 100644 --- a/packages/styleguide/src/lib/Organisms/ListsAndTables/List/List.stories.tsx +++ b/packages/gamut/src/List/List.stories.tsx @@ -1,15 +1,3 @@ -import { - Box, - FillButton, - FlexBox, - IconButton, - List, - ListCol, - ListRow, - TableHeader, - Text, - TextButton, -} from '@skillsoft/gamut'; import { HouseEntranceIcon, MiniDeleteIcon, @@ -21,17 +9,21 @@ import { import { Keyhole } from '@skillsoft/gamut-illustrations'; import { Background } from '@skillsoft/gamut-styles'; import type { Meta, StoryObj } from '@storybook/react'; -import type { ComponentType } from 'react'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; +import { expect, waitFor } from 'storybook/test'; -import { DisableContainerQueryExample, simpleRows } from '../examples'; -import { listStoryRows as rows } from './listStoryData'; +import { Box, FlexBox } from '../Box'; +import { FillButton, IconButton, TextButton } from '../Button'; +import { Text } from '../Typography'; +import { List, ListCol, ListRow, TableHeader } from '.'; const meta: Meta = { - title: 'Organisms/Lists & Tables/List/List', + title: 'Data display/List/List', component: List, subcomponents: { - ListRow: ListRow as ComponentType, - TableHeader: TableHeader as ComponentType, + ListRow, + TableHeader, }, args: {}, }; @@ -39,6 +31,81 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const rows = [ + { name: 'Jean Luc Picard', role: 'Captain', ship: 'USS Enterprise' }, + { name: 'Wesley Crusher', role: 'Deus Ex Machina', ship: 'USS Enterprise' }, + { + name: 'Geordie LaForge', + role: 'Chief Engineer / Rascal', + ship: 'Borg Cube', + }, + { + name: 'Data', + role: 'Lt. Commander / Scamp', + ship: 'He is a ship', + }, +]; + +const DisableContainerQueryExample = ({ + defaultComponent, + disabledComponent, +}: { + defaultComponent: ReactNode; + disabledComponent: ReactNode; +}) => ( + + + + Default (Container Queries Enabled) + + + {defaultComponent} + + + Container queries apply responsive behavior based on the List's + container width + + + + + With Container Queries Disabled + + + {disabledComponent} + + + Built in responsive behavior is disabled - custom responsive behavior is + required + + + +); + +const ScrollToTopExample = (args: Story['args']) => { + const [crew, setCrew] = useState(rows); + const repeatedCrew = [1, 2, 3].flatMap((copy) => + crew.map((member) => ({ ...member, key: `${copy}-${member.name}` })) + ); + + return ( + <> + setCrew([...crew].reverse())}> + Reverse crew + + + {repeatedCrew.map(({ key, name, ship }) => ( + + + {name} + + {ship} + + ))} + + + ); +}; + export const Default: Story = { args: { spacing: 'condensed', @@ -57,6 +124,16 @@ export const Default: Story = { ); }, + play: async ({ canvas }) => { + const list = canvas.getByRole('list'); + await expect(list.tagName).toBe('UL'); + await expect(canvas.getAllByRole('listitem')).toHaveLength(rows.length); + + const scrollContainer = list.parentElement!; + await expect(scrollContainer.getBoundingClientRect().width).toBe( + scrollContainer.parentElement!.getBoundingClientRect().width + ); + }, }; export const NormalSpacing: Story = { @@ -232,6 +309,18 @@ export const OrderedList: Story = { ); }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('list').tagName).toBe('OL'); + + const items = canvas.getAllByRole('listitem'); + await expect(items).toHaveLength(rows.length); + for (const item of items) { + await expect(item).not.toHaveTextContent('1.'); + await expect(getComputedStyle(item, '::before').content).toContain( + 'counters' + ); + } + }, }; export const NormalSpacingGuide: Story = { @@ -477,6 +566,180 @@ export const SpaceSystemProps: Story = { ); }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('list')).toHaveStyle({ padding: '16px' }); + }, +}; + +export const WrapperWidth: Story = { + args: { + id: 'wrapper-width-list', + spacing: 'condensed', + wrapperWidth: '500px', + }, + render: (args) => { + return ( + + {rows.map(({ name, ship }) => ( + + + {name} + + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + await expect( + canvas.getByTestId('scrollable-wrapper-width-list') + ).toHaveStyle({ maxWidth: '500px', width: '500px' }); + }, +}; + +export const Empty: Story = { + args: { + emptyMessage: No crew members found, + }, + render: (args) => , + play: async ({ canvas }) => { + canvas.getByText('No crew members found'); + await expect(canvas.queryByRole('list')).toBeNull(); + await expect(canvas.queryByRole('listitem')).toBeNull(); + }, +}; + +export const Loading: Story = { + args: { loading: true, spacing: 'condensed' }, + render: (args) => { + return ( + + {rows.map(({ name, ship }) => ( + + + {name} + + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + canvas.getByRole('list'); + await expect(canvas.getAllByRole('listitem')).toHaveLength(rows.length); + }, +}; + +export const ScrollShadow: Story = { + args: { + as: 'table', + id: 'scroll-shadow-list', + scrollable: true, + shadow: true, + }, + parameters: { + a11y: { + config: { + rules: [{ id: 'scrollable-region-focusable', enabled: false }], + }, + }, + }, + render: (args) => { + return ( + + + {rows.map(({ name, role, ship }) => ( + + + {name} + + {role} + {ship} + + ))} + + + ); + }, + play: async ({ canvas }) => { + /* + * TODO: the scroll wrapper isn't focusable when rows have no focusable content. + * The shadow also never shows for ul/ol lists, which scroll an inner box instead of the wrapper. + */ + const wrapper = canvas.getByTestId('scrollable-scroll-shadow-list') + .parentElement!; + await waitFor(() => + expect(getComputedStyle(wrapper).backgroundSize).toBe('124px 100%') + ); + + wrapper.scrollLeft = wrapper.scrollWidth; + await waitFor(() => + expect(getComputedStyle(wrapper).backgroundSize).toBe('0px 100%') + ); + }, +}; + +export const ScrollToTopOnUpdate: Story = { + args: { + height: 200, + id: 'scroll-to-top-list', + scrollToTopOnUpdate: true, + spacing: 'condensed', + }, + parameters: { + a11y: { + config: { + rules: [{ id: 'scrollable-region-focusable', enabled: false }], + }, + }, + }, + render: (args) => , + play: async ({ canvas, userEvent }) => { + /* + * TODO: scrollToTopOnUpdate scrolls the inner box, not the wrapper that actually scrolls. + * The scroll wrapper also isn't focusable when rows have no focusable content. + */ + const wrapper = canvas.getByTestId('scrollable-scroll-to-top-list') + .parentElement!; + wrapper.scrollTop = 150; + await waitFor(() => expect(wrapper.scrollTop).toBe(150)); + + await userEvent.click(canvas.getByRole('button', { name: 'Reverse crew' })); + await expect(canvas.getAllByRole('listitem')[0]).toHaveTextContent('Data'); + }, +}; + +export const RowBreakpoint: Story = { + args: { rowBreakpoint: 'sm', spacing: 'condensed' }, + render: (args) => { + return ( + + + + + Default breakpoint + + Row layout at 900px + + + + + + Small breakpoint + + Stacked layout at 900px + + + + ); + }, + play: async ({ canvas }) => { + // TODO: rowBreakpoint sm/md also flip on viewport width, so a 900px container on a wide screen doesn't stack + const [defaultRow] = canvas.getAllByRole('listitem'); + await expect(getComputedStyle(defaultRow).display).toBe('flex'); + }, }; export const DisableContainerQuery: Story = { @@ -484,7 +747,7 @@ export const DisableContainerQuery: Story = { render: () => { const defaultComponent = ( - {simpleRows.map(({ name, ship }) => ( + {rows.slice(0, 2).map(({ name, ship }) => ( {name} @@ -500,7 +763,7 @@ export const DisableContainerQuery: Story = { const disabledComponent = ( - {simpleRows.map(({ name, ship }) => ( + {rows.slice(0, 2).map(({ name, ship }) => ( {name} @@ -516,10 +779,18 @@ export const DisableContainerQuery: Story = { return ( ); }, + play: async ({ canvas }) => { + const [defaultList, disabledList] = canvas.getAllByRole('list'); + await expect( + getComputedStyle(defaultList.parentElement!.parentElement!).containerType + ).toBe('inline-size'); + await expect( + getComputedStyle(disabledList.parentElement!.parentElement!).containerType + ).toBe('normal'); + }, }; diff --git a/packages/gamut/src/List/ListCol.stories.tsx b/packages/gamut/src/List/ListCol.stories.tsx new file mode 100644 index 0000000000..e454c787f6 --- /dev/null +++ b/packages/gamut/src/List/ListCol.stories.tsx @@ -0,0 +1,584 @@ +import { + MiniDeleteIcon, + MiniKebabMenuIcon, + StarIcon, + StreakIcon, + TrophyIcon, +} from '@skillsoft/gamut-icons'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, waitFor } from 'storybook/test'; + +import { Box } from '../Box'; +import { FillButton, IconButton } from '../Button'; +import { Text } from '../Typography'; +import { List, ListCol, ListRow, TableHeader } from '.'; + +const meta: Meta = { + title: 'Data display/List/ListCol', + component: ListCol, + args: {}, +}; + +export default meta; +type ListCompositionStory = StoryObj; + +const rows = [ + { name: 'Jean Luc Picard', role: 'Captain', ship: 'USS Enterprise' }, + { name: 'Wesley Crusher', role: 'Deus Ex Machina', ship: 'USS Enterprise' }, + { + name: 'Geordie LaForge', + role: 'Chief Engineer / Rascal', + ship: 'Borg Cube', + }, + { + name: 'Data', + role: 'Lt. Commander / Scamp', + ship: 'He is a ship', + }, +]; + +const sizes = ['content', 'sm', 'md', 'lg', 'xl'] as const; + +const columnWidth = (cellContent: HTMLElement) => + cellContent.parentElement!.getBoundingClientRect().width; + +const ScrollingRows = () => ( + <> + {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + + + {name} + + + + + {role} + + + + + {ship} + + + + + + 87% + + + + + + 48% + + + + + + 66% + + + + Engage + + + + + + + ))} + +); + +export const ColumnSizing: ListCompositionStory = { + render: (args) => { + return ( + + {sizes.map((size: 'content' | 'sm' | 'md' | 'lg' | 'xl') => ( + + + + {size} + + + + ))} + + ); + }, + play: async ({ canvas }) => { + await expect(columnWidth(canvas.getByText('sm'))).toBe(96); + await expect(columnWidth(canvas.getByText('md'))).toBe(160); + await expect(columnWidth(canvas.getByText('lg'))).toBe(192); + await expect(columnWidth(canvas.getByText('xl'))).toBe(320); + await expect(columnWidth(canvas.getByText('content'))).toBeLessThan(96); + }, +}; + +export const FillingEmptySpace: ListCompositionStory = { + render: (args) => { + return ( + + + + + Medium + + + + + Medium - Fill + + + + + Medium + + + + + Medium - Fill + + + + + Medium + + + + + ); + }, + play: async ({ canvas }) => { + const [fixed] = canvas.getAllByText('Medium'); + const fills = canvas.getAllByText('Medium - Fill'); + await expect(columnWidth(fills[0])).toBeGreaterThan(columnWidth(fixed)); + await expect(columnWidth(fills[0])).toBe(columnWidth(fills[1])); + }, +}; + +export const Justification: ListCompositionStory = { + render: (args) => { + return ( + + + + Left + + + Right + + + + ); + }, + play: async ({ canvas }) => { + await expect(canvas.getByText('Left')).toHaveStyle({ + justifyContent: 'flex-start', + }); + await expect(canvas.getByText('Right')).toHaveStyle({ + justifyContent: 'flex-end', + }); + }, +}; + +export const HorizontalScrolling: ListCompositionStory = { + args: { spacing: 'condensed', scrollable: true }, + render: (args) => { + return ( + + + + ); + }, + play: async ({ canvas }) => { + const headerCells = canvas.getAllByTestId('header-container'); + await expect(headerCells).toHaveLength(rows.length); + for (const headerCell of headerCells) { + await expect(headerCell).toHaveStyle({ position: 'sticky' }); + } + await expect(headerCells[0]).toHaveTextContent('Jean Luc Picard'); + await expect(canvas.getByText('Captain').parentElement).not.toHaveStyle({ + position: 'sticky', + }); + }, +}; + +export const HorizontalScrollingInContainer: ListCompositionStory = { + args: { id: 'scrolling-list', spacing: 'condensed', scrollable: true }, + render: (args) => { + return ( + + + + + + ); + }, + play: async ({ canvas }) => { + const wrapper = canvas.getByTestId('scrollable-scrolling-list'); + await expect(wrapper.scrollWidth).toBeGreaterThan(wrapper.clientWidth); + + const headerCell = canvas.getAllByTestId('header-container')[0]; + const contentCell = canvas.getByText('Captain'); + const headerLeft = headerCell.getBoundingClientRect().left; + const contentLeft = contentCell.getBoundingClientRect().left; + + wrapper.scrollLeft = 200; + await waitFor(() => + expect(contentCell.getBoundingClientRect().left).toBe(contentLeft - 200) + ); + await expect( + Math.abs(headerCell.getBoundingClientRect().left - headerLeft) + ).toBeLessThanOrEqual(1); + }, +}; + +export const ResponsiveAnatomy: ListCompositionStory = { + parameters: { + docs: { inlineStories: false, iframeHeight: 180 }, + }, + render: (args) => { + return ( + + + + + Header + + + + + Content + + + + + Content + + + + + Controls + + + + + ); + }, +}; + +export const Responsive: ListCompositionStory = { + args: { as: 'ol' }, + parameters: { + docs: { + inlineStories: false, + iframeHeight: 180, + }, + }, + render: (args) => { + return ( + + + + + Ordered List Header + + + + + Content + + + + + Content + + + + + + + + + ); + }, +}; + +export const ResponsiveInNarrowContainer: ListCompositionStory = { + render: (args) => { + return ( + + + + + + Header + + + + + Content + + + + + Controls + + + + + + ); + }, + play: async ({ canvas }) => { + await expect(getComputedStyle(canvas.getByRole('listitem')).display).toBe( + 'grid' + ); + + const header = canvas.getByText('Header').getBoundingClientRect(); + const content = canvas.getByText('Content').getBoundingClientRect(); + const controls = canvas.getByText('Controls').getBoundingClientRect(); + await expect(controls.top).toBe(header.top); + await expect(controls.left).toBeGreaterThan(header.left); + await expect(content.top).toBeGreaterThanOrEqual(header.bottom); + }, +}; + +export const ResponsiveOrderedInNarrowContainer: ListCompositionStory = { + args: { as: 'ol' }, + render: (args) => { + return ( + + + {rows.map(({ name, role }) => ( + + + {name} + + {role} + + ))} + + + ); + }, + play: async ({ canvas }) => { + const [firstRow] = canvas.getAllByRole('listitem'); + await expect(getComputedStyle(firstRow, '::before').display).toBe('none'); + await expect( + getComputedStyle(canvas.getByText('Jean Luc Picard'), '::before').display + ).toBe('flex'); + }, +}; + +export const SortableHeaders: ListCompositionStory = { + render: () => { + return ( + + + Name + + + Role + + + Ship + + + } + spacing="condensed" + variant="table" + > + {rows.map(({ name, role, ship }) => ( + + + {name} + + {role} + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole('columnheader', { name: 'Name' }) + ).toHaveAttribute('aria-sort', 'ascending'); + await expect( + canvas.getByRole('columnheader', { name: 'Role' }) + ).toHaveAttribute('aria-sort', 'none'); + await expect( + canvas.getByRole('columnheader', { name: 'Ship' }) + ).not.toHaveAttribute('aria-sort'); + }, +}; + +export const SortableScrollableHeaders: ListCompositionStory = { + parameters: { + a11y: { + config: { + rules: [{ id: 'scrollable-region-focusable', enabled: false }], + }, + }, + }, + render: () => { + return ( + + + Name + + + Role + + + Ship + + + } + scrollable + spacing="condensed" + variant="table" + > + {rows.map(({ name, role, ship }) => ( + + + {name} + + {role} + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + // TODO: the scroll wrapper isn't focusable when rows have no focusable content + const nameHeader = canvas.getByRole('columnheader', { name: 'Name' }); + await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'); + await expect(nameHeader).toHaveAttribute('data-testid', 'header-container'); + await expect(canvas.getByText('Name')).not.toHaveAttribute('aria-sort'); + await expect( + canvas.getByRole('columnheader', { name: 'Role' }) + ).toHaveAttribute('aria-sort', 'none'); + }, +}; + +export const HeaderColumnCells: ListCompositionStory = { + render: () => { + return ( + + + + Name + + + Role + + + Ship + + + {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + + {name} + + {role} + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + // TODO: TableHeader as a List child renders its thead inside List's tbody + canvas.getByRole('table'); + for (const name of ['Name', 'Role', 'Ship']) { + canvas.getByRole('columnheader', { name }); + } + await expect(canvas.getByText('Jean Luc Picard').tagName).toBe('TH'); + await expect(canvas.getByText('Captain').tagName).toBe('TD'); + }, +}; + +export const Playground: ListCompositionStory = { + args: { + spacing: 'condensed', + variant: 'table', + }, + render: (args) => { + return ( + + + + Name + + + Role + + + Ship + + + {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + + {name} + + {role} + {ship} + + ))} + + ); + }, +}; diff --git a/packages/gamut/src/List/ListRow.stories.tsx b/packages/gamut/src/List/ListRow.stories.tsx new file mode 100644 index 0000000000..bb45024e8a --- /dev/null +++ b/packages/gamut/src/List/ListRow.stories.tsx @@ -0,0 +1,479 @@ +import styled from '@emotion/styled'; +import { + ArrowChevronDownIcon, + StarIcon, + StopSignIcon, + StreakIcon, + TrophyIcon, +} from '@skillsoft/gamut-icons'; +import { Background, css } from '@skillsoft/gamut-styles'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { expect, fn, waitFor } from 'storybook/test'; + +import { Rotation } from '../Animation'; +import { Box, FlexBox } from '../Box'; +import { TextButton } from '../Button'; +import { ExpandControl } from '../DataList'; +import { Text } from '../Typography'; +import { List, ListCol, ListRow } from '.'; + +const onDangerousRowClick = fn(); +const onClickableRowClick = fn(); + +const meta: Meta = { + title: 'Data display/List/ListRow', + component: ListRow, + subcomponents: { ListCol }, + args: {}, +}; + +export default meta; +type Story = StoryObj; +type ListCompositionStory = StoryObj; + +const rows = [ + { name: 'Jean Luc Picard', role: 'Captain', ship: 'USS Enterprise' }, + { name: 'Wesley Crusher', role: 'Deus Ex Machina', ship: 'USS Enterprise' }, + { + name: 'Geordie LaForge', + role: 'Chief Engineer / Rascal', + ship: 'Borg Cube', + }, + { + name: 'Data', + role: 'Lt. Commander / Scamp', + ship: 'He is a ship', + }, +]; + +const DangerousListRow = styled(ListRow)( + css({ + bg: 'background-error', + color: 'feedback-error', + fontWeight: 'bold', + borderRadius: 'sm', + }) +); + +const BorderedListRow = styled(ListRow)( + css({ + border: 1, + borderColor: 'primary', + borderRadius: 'md', + }) +); + +interface ExpandableRowProps { + name: string; + role: string; + ship: string; + rowKey: string; +} + +const ExpandedRow: React.FC> = ({ + name, + role, + ship, +}) => ( + + + Oh, were you expecting to find out more about{' '} + + {name} + + ,{' '} + + {role} + {' '} + of the{' '} + + {ship} + + ? I am very sorry but that is{' '} + + highly classified + {' '} + information. + + +); + +const ExpandedColumns: React.FC> = ({ + name, + role, + ship, +}) => ( + <> + + + + {ship} + + {name} + + + + + + Rank + + {role} + + + + + + 87% + + + + + + 48% + + + + + + 66% + + + +); + +const ExpandableButtonClickRow: React.FC = ({ + name, + role, + ship, + rowKey, +}) => { + const [isExpanded, setExpanded] = useState(false); + + return ( + } + > + + + Hail + setExpanded(!isExpanded)} + /> + + + ); +}; + +const ExpandableRowClick: React.FC = ({ + name, + role, + ship, + rowKey, +}) => { + const [isExpanded, setExpanded] = useState(false); + + return ( + } + onClick={() => setExpanded(!isExpanded)} + > + + + + + + + + + + ); +}; + +const ExpandableTableRow: React.FC = ({ + name, + role, + ship, + rowKey, +}) => { + const [isExpanded, setExpanded] = useState(false); + + return ( + ( + + {name} serves on the {ship}. + + )} + > + + {name} + + + {role} + + + setExpanded(!isExpanded)} + /> + + + ); +}; + +export const PlainStyled: Story = { + render: (args) => { + return ( + + + A List item + Some normal content + + + A dangerous styled item + More styled content + + + + + + Another List item + Standard content + + + A Custom Bordered Item + With custom border + + + ); + }, + play: async ({ canvas, userEvent }) => { + onDangerousRowClick.mockClear(); + await expect(canvas.getAllByRole('listitem')).toHaveLength(4); + + const dangerousRow = canvas.getByRole('button', { + name: /A dangerous styled item/, + }); + await expect(dangerousRow).not.toHaveAttribute('aria-expanded'); + await userEvent.click(dangerousRow); + await expect(onDangerousRowClick).toHaveBeenCalledTimes(1); + + await userEvent.keyboard('{Enter}'); + await expect(onDangerousRowClick).toHaveBeenCalledTimes(2); + }, +}; + +export const ExpandableButton: ListCompositionStory = { + render: (args) => { + return ( + + + + {rows.map( + ({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + ) + )} + + + + ); + }, + play: async ({ canvas, userEvent }) => { + await expect(canvas.queryByRole('region')).toBeNull(); + + const expandButton = canvas.getByRole('button', { + name: 'Expand Jean Luc Picard Row', + }); + await expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(expandButton); + await expect(expandButton).toHaveAttribute('aria-expanded', 'true'); + await expect(canvas.getByRole('region')).toHaveTextContent( + 'Jean Luc Picard' + ); + await expect(canvas.getAllByRole('region')).toHaveLength(1); + + const secondExpandButton = canvas.getByRole('button', { + name: 'Expand Wesley Crusher Row', + }); + await userEvent.click(secondExpandButton); + await expect(canvas.getAllByRole('region')).toHaveLength(2); + + await userEvent.click(expandButton); + await expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + await waitFor(() => expect(canvas.getAllByRole('region')).toHaveLength(1)); + await expect(canvas.getByRole('region')).toHaveTextContent( + 'Wesley Crusher' + ); + await expect(secondExpandButton).toHaveAttribute('aria-expanded', 'true'); + }, +}; + +export const ExpandableClickableRow: ListCompositionStory = { + render: (args) => { + return ( + + + + {rows.map( + ({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + ) + )} + + + + ); + }, + play: async ({ canvas, userEvent }) => { + // TODO: clickable rows don't activate with Space, only Enter + const rowButtons = canvas.getAllByRole('button'); + await expect(rowButtons).toHaveLength(rows.length); + const [firstRow, secondRow] = rowButtons; + await expect(firstRow).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(firstRow); + await expect(firstRow).toHaveAttribute('aria-expanded', 'true'); + await expect(canvas.getByRole('region')).toHaveTextContent( + 'Jean Luc Picard' + ); + + await userEvent.click(firstRow); + await expect(firstRow).toHaveAttribute('aria-expanded', 'false'); + await waitFor(() => expect(canvas.queryByRole('region')).toBeNull()); + + await userEvent.tab(); + await expect(secondRow).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + await expect(secondRow).toHaveAttribute('aria-expanded', 'true'); + await expect(canvas.getByRole('region')).toHaveTextContent( + 'Wesley Crusher' + ); + }, +}; + +export const ExpandableTableRows: ListCompositionStory = { + args: { as: 'table', spacing: 'condensed', variant: 'table' }, + render: (args) => { + return ( + + {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + ))} + + ); + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click( + canvas.getByRole('button', { name: 'Expand Jean Luc Picard Row' }) + ); + + const region = canvas.getByRole('region'); + await expect(region).toHaveTextContent('Jean Luc Picard'); + await expect(region.closest('td')).not.toBeNull(); + await expect(region.closest('tr')).toHaveTextContent('Captain'); + await expect(canvas.getAllByRole('row')).toHaveLength(rows.length); + }, +}; + +export const ExpandedRowOptions: ListCompositionStory = { + args: { spacing: 'condensed' }, + render: (args) => { + return ( + + {rows.slice(0, 2).map(({ name, role, ship }, i) => ( + ( + + {name} serves on the {ship}. + + )} + > + + {name} + + {role} + + ))} + + ); + }, + play: async ({ canvas }) => { + canvas.getByRole('region', { name: 'Jean Luc Picard details' }); + canvas.getByRole('region', { name: 'Wesley Crusher details' }); + + const [keptSpacingRow, defaultRow] = canvas.getAllByRole('listitem'); + await expect(getComputedStyle(keptSpacingRow).columnGap).not.toBe('normal'); + await expect(getComputedStyle(defaultRow).columnGap).toBe('normal'); + }, +}; + +export const ClickableRowsInOrderedList: ListCompositionStory = { + args: { as: 'ol', spacing: 'condensed' }, + render: (args) => { + return ( + + {rows.slice(0, 3).map(({ name, role }, i) => ( + + + {name} + + {role} + + ))} + + ); + }, + play: async ({ canvas }) => { + const [numberedRow, clickableRow] = canvas.getAllByRole('listitem'); + await expect(getComputedStyle(numberedRow, '::before').content).toContain( + 'counters' + ); + await expect(getComputedStyle(clickableRow, '::before').content).toBe( + 'none' + ); + canvas.getByRole('button', { name: /Wesley Crusher/ }); + }, +}; diff --git a/packages/gamut/src/List/TableHeader.stories.tsx b/packages/gamut/src/List/TableHeader.stories.tsx new file mode 100644 index 0000000000..011a43424e --- /dev/null +++ b/packages/gamut/src/List/TableHeader.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, waitFor } from 'storybook/test'; + +import { List, ListCol, ListRow, TableHeader } from '.'; + +const meta: Meta = { + title: 'Data display/List/TableHeader', + component: TableHeader, + subcomponents: { ListCol }, + args: {}, +}; + +export default meta; +type ListCompositionStory = StoryObj; + +const rows = [ + { name: 'Jean Luc Picard', role: 'Captain', ship: 'USS Enterprise' }, + { name: 'Wesley Crusher', role: 'Deus Ex Machina', ship: 'USS Enterprise' }, + { + name: 'Geordie LaForge', + role: 'Chief Engineer / Rascal', + ship: 'Borg Cube', + }, + { + name: 'Data', + role: 'Lt. Commander / Scamp', + ship: 'He is a ship', + }, +]; + +export const WithTableHeader: ListCompositionStory = { + args: { spacing: 'condensed' }, + render: (args) => { + return ( + + + + Name + + + Role + + + Ship + + + {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + + {name} + + {role} + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + // TODO: TableHeader as a List child renders its thead inside List's tbody + canvas.getByRole('table'); + await expect(canvas.getAllByRole('row')).toHaveLength(rows.length + 1); + for (const name of ['Name', 'Role', 'Ship']) { + canvas.getByRole('columnheader', { name }); + } + await expect(canvas.getByText('Name').closest('thead')).toHaveStyle({ + position: 'sticky', + }); + }, +}; + +export const WithHeaderProp: ListCompositionStory = { + args: { spacing: 'condensed' }, + render: (args) => { + return ( + + + Name + + + Role + + + Ship + + + } + variant="table" + > + {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( + + + {name} + + {role} + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + // TODO: body cells with type="header" are exposed as column headers, not row headers + const table = canvas.getByRole('table'); + const [headerGroup, bodyGroup] = canvas.getAllByRole('rowgroup'); + await expect(headerGroup.tagName).toBe('THEAD'); + await expect(bodyGroup.tagName).toBe('TBODY'); + await expect(headerGroup.parentElement).toBe(table); + await expect(bodyGroup.parentElement).toBe(table); + for (const name of ['Name', 'Role', 'Ship']) { + canvas.getByRole('columnheader', { name }); + } + }, +}; + +export const StickyOnVerticalScroll: ListCompositionStory = { + args: { + height: 160, + id: 'sticky-header-list', + scrollable: true, + spacing: 'condensed', + }, + parameters: { + a11y: { + config: { + rules: [{ id: 'scrollable-region-focusable', enabled: false }], + }, + }, + }, + render: (args) => { + return ( + + + Name + + + Role + + + Ship + + + } + variant="table" + > + {[...rows, ...rows].map(({ name, role, ship }, i) => ( + + + {name} + + {role} + {ship} + + ))} + + ); + }, + play: async ({ canvas }) => { + // TODO: the scroll wrapper isn't focusable when rows have no focusable content + const wrapper = canvas.getByTestId('scrollable-sticky-header-list') + .parentElement!; + const headerGroup = canvas.getAllByRole('rowgroup')[0]; + await expect(wrapper.scrollHeight).toBeGreaterThan(wrapper.clientHeight); + + wrapper.scrollTop = 100; + await waitFor(() => expect(wrapper.scrollTop).toBe(100)); + await expect(headerGroup.getBoundingClientRect().top).toBe( + wrapper.getBoundingClientRect().top + ); + }, +}; diff --git a/packages/gamut/src/List/__tests__/List.test.tsx b/packages/gamut/src/List/__tests__/List.test.tsx deleted file mode 100644 index 834c5f0cc3..0000000000 --- a/packages/gamut/src/List/__tests__/List.test.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { matchers } from '@emotion/jest'; -import { theme } from '@skillsoft/gamut-styles'; -import { MockGamutProvider, setupRtl } from '@skillsoft/gamut-tests'; -import { render } from '@testing-library/react'; - -import type { ListProps } from '../List'; -import { List } from '../List'; -import { ListCol } from '../ListCol'; -import { ListRow } from '../ListRow'; - -// Add the custom matchers provided by '@emotion/jest' -expect.extend(matchers); - -// NOTE: We have removed the query styling tests here, they are to be replaced with visual tests: GM-1240 - -const renderView = setupRtl(List, { - children: ( - - Hello - - ), - id: 'list-el', -}); - -describe('List', () => { - it('renders a default list by default', () => { - const { view } = renderView(); - - const listEl = view.container.querySelector('ul'); - const rowEl = view.getByRole('listitem'); - - expect(listEl).toHaveStyle({ borderRadius: '2px' }); - expect(rowEl).not.toHaveStyle({ minWidth: 'min-content' }); - }); - it('renders numbering for ordered lists ', () => { - const { view } = renderView({ as: 'ol' }); - - const listEl = view.container.querySelector('ol'); - const rowEl = view.getByRole('listitem'); - - expect(listEl).toBeTruthy(); - expect(rowEl).not.toHaveTextContent('1.'); - }); - it('configures rows with the correct variants', () => { - const { view } = renderView(); - - const rowEl = view.getByRole('listitem'); - - expect(rowEl).toHaveStyle({ borderTop: 'none' }); - expect(rowEl).toHaveStyle({ columnGap: theme.spacing[40] }); - }); - - // Note: Only testing one mode here since variant() caches styles after first render. - // RTL: will set to logical properties after switching to opt-out - it('configures columns with the correct variants', () => { - const useLogicalProperties = false; - const paddingLeft = useLogicalProperties - ? 'paddingInlineStart' - : 'paddingLeft'; - const paddingRight = useLogicalProperties - ? 'paddingInlineEnd' - : 'paddingRight'; - - const { view } = renderView(); - const colEl = view.getByText('Hello'); - - expect(colEl).not.toHaveStyle({ py: 16 }); - expect(colEl).toHaveStyle({ [paddingLeft]: theme.spacing[8] }); - expect(colEl).toHaveStyle({ [paddingRight]: theme.spacing[8] }); - expect(colEl).not.toHaveStyle({ position: 'sticky' }); - }); - - it('fixes the row header column when scrollable - but not other columns', () => { - const { view } = renderView({ - scrollable: true, - children: ( - - Hello - Content - - ), - }); - - const headerEl = view.getByTestId('header-container'); - const contentEl = view.getByText('Content'); - - expect(headerEl).toHaveStyle({ position: 'sticky' }); - expect(contentEl).not.toHaveStyle({ position: 'sticky' }); - }); - - it('renders ListRow with a button when it has an onClick', () => { - const onClick = jest.fn(); - const { view } = renderView({ - children: ( - - Hello - Content - - ), - }); - - view.getByRole('button').click(); - expect(onClick).toHaveBeenCalled(); - }); - - it('renders ListRow with expanded content when expanded is true', () => { - const { view } = renderView({ - children: ( -
Surprise!
} - > - Hello - Content -
- ), - }); - - view.getByText('Surprise!'); - }); - - it('does not render ListRow with expanded content when expanded is false', () => { - const { view } = renderView({ - children: ( -
Surprise!
} - > - Hello - Content -
- ), - }); - expect(view.queryByText('Surprise!')).toBeNull(); - }); - - describe.each([ - { - useLogicalProperties: true, - widthProp: 'inlineSize', - maxWidthProp: 'maxInlineSize', - }, - { - useLogicalProperties: false, - widthProp: 'width', - maxWidthProp: 'maxWidth', - }, - ])( - 'wrapperWidth prop (useLogicalProperties: $useLogicalProperties)', - ({ useLogicalProperties, widthProp, maxWidthProp }) => { - const defaultChildren = ( - - Hello - - ); - - const renderWithLogicalProps = (overrides?: Partial) => - render( - - - {overrides?.children ?? defaultChildren} - - - ); - - it('applies wrapperWidth to the table container when provided', () => { - const view = renderWithLogicalProps({ wrapperWidth: '500px' }); - - const tableContainer = view.container.querySelector( - '[data-testid="scrollable-list-el"]' - ); - expect(tableContainer).toHaveStyle({ - [maxWidthProp]: '500px', - [widthProp]: '500px', - }); - }); - - it('uses inherit width when wrapperWidth is not provided', () => { - const view = renderWithLogicalProps(); - - const tableContainer = view.container.querySelector( - '[data-testid="scrollable-list-el"]' - ); - expect(tableContainer).toHaveStyle({ - [maxWidthProp]: '100%', - [widthProp]: 'inherit', - }); - }); - } - ); - - it('applies container query styles by default', () => { - const { view } = renderView(); - - const wrapper = view.container.querySelector('#list-el'); - expect(wrapper).toHaveStyleRule('container-type', 'inline-size'); - }); - - it('disables container queries when disableContainerQuery is true', () => { - const { view } = renderView({ disableContainerQuery: true }); - - const wrapper = view.container.querySelector('#list-el'); - expect(wrapper).toHaveStyleRule('container-type', 'normal'); - }); -}); diff --git a/packages/gamut/src/Markdown/Markdown.stories.tsx b/packages/gamut/src/Markdown/Markdown.stories.tsx new file mode 100644 index 0000000000..29a71fb4ab --- /dev/null +++ b/packages/gamut/src/Markdown/Markdown.stories.tsx @@ -0,0 +1,739 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { HTMLProps, ReactNode } from 'react'; +import { expect, fn, waitFor, within } from 'storybook/test'; + +import { Text } from '../Typography'; +import type { TextProps } from '../Typography/Text'; +import { Iframe, MarkdownVideo } from '../Video'; +import { Markdown } from '.'; +import exampleMarkdown from './example.md?raw'; + +const onCustomTagRender = fn(); + +const meta: Meta = { + title: 'Data display/Markdown', + component: Markdown, + args: {}, +}; + +export default meta; +type Story = StoryObj; + +const youtubeIframe = ``; +const videoSrc = + 'https://static-assets.codecademy.com/skillshorts/introduction-to-quantum-computing/01-Introduction-to-Quantum-Computing.mp4'; +const captionsSrc = 'https://files.vidstack.io/sprite-fight/subs/english.vtt'; + +export const Default: Story = { + args: { + text: ` +## Hello World +This is markdown + `, + spacing: 'tight', + }, + play: async ({ canvas, canvasElement }) => { + await expect( + canvas.getByRole('heading', { level: 2, name: 'Hello World' }) + ).toHaveAttribute('id', 'heading-hello-world'); + canvas.getByText('This is markdown'); + await expect(canvasElement.firstElementChild?.tagName).toBe('DIV'); + }, +}; + +export const FullExample: Story = { + args: { + iframeOverride: { component: Iframe }, + text: exampleMarkdown, + videoOverride: { component: MarkdownVideo }, + }, +}; + +export const BasicElements: Story = { + args: { + text: ` +# Heading 1 + +## Heading 2 + +\`\`\`js +const jsCode = () => {}; +\`\`\` +`, + }, + play: async ({ canvas, canvasElement }) => { + await expect( + canvas.getByRole('heading', { level: 1, name: 'Heading 1' }) + ).toHaveAttribute('id', 'heading-heading-1'); + await expect( + canvas.getByRole('heading', { level: 2, name: 'Heading 2' }) + ).toHaveAttribute('id', 'heading-heading-2'); + await expect(canvasElement.querySelectorAll('code')).toHaveLength(1); + }, +}; + +export const WithoutHeaderIds: Story = { + args: { + headerIds: false, + text: ` +# Heading 1 + +## Heading 2 +`, + }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('heading', { level: 1 })).not.toHaveAttribute( + 'id' + ); + await expect(canvas.getByRole('heading', { level: 2 })).not.toHaveAttribute( + 'id' + ); + }, +}; + +export const HtmlContent: Story = { + args: { + text: ` +

Heading 1

+ +

Heading 2

+ +
# Cool
+ +Placeholder +`, + ...{ 'data-testid': 'markdown-wrapper' }, + }, + play: async ({ canvas, canvasElement }) => { + canvas.getByRole('heading', { level: 1, name: 'Heading 1' }); + canvas.getByRole('heading', { level: 2, name: 'Heading 2' }); + await expect( + canvasElement.querySelectorAll('div.narrative-table-container') + ).toHaveLength(1); + canvas.getByRole('img', { name: 'Placeholder' }); + canvas.getByTestId('markdown-wrapper'); + }, +}; + +const tableMarkdown = ` +| Tables | Are | Cool | +|----------|:-------------:|------:| +| col 1 is | left-aligned | $1600 | +| col 2 is | centered | $12 | +| col 3 is | right-aligned | $1 | +`; + +export const Table: Story = { + args: { text: tableMarkdown }, + play: async ({ canvas }) => { + const scrollContainer = canvas.getByRole('table').parentElement!; + await expect(getComputedStyle(scrollContainer).overflow).toBe('auto'); + await expect(canvas.getAllByRole('row')).toHaveLength(4); + }, +}; + +export const WithoutTableOverride: Story = { + args: { skipDefaultOverrides: { table: true }, text: tableMarkdown }, + play: async ({ canvas }) => { + const parent = canvas.getByRole('table').parentElement!; + await expect(getComputedStyle(parent).overflow).toBe('visible'); + }, +}; + +export const Links: Story = { + args: { onAnchorClick: fn((event) => event.preventDefault()) }, + render: (args) => ( + + ), + play: async ({ args, canvas, userEvent }) => { + const external = canvas.getByRole('link', { name: 'External' }); + await expect(external).toHaveAttribute('target', '_blank'); + await expect(external).toHaveAttribute('rel', 'noopener'); + + const sameOrigin = canvas.getByRole('link', { name: 'Same origin' }); + await expect(sameOrigin).toHaveAttribute('target', '_blank'); + await expect(sameOrigin).not.toHaveAttribute('rel'); + + await expect( + canvas.getByRole('link', { name: 'Relative' }) + ).not.toHaveAttribute('rel'); + + const inPage = canvas.getByRole('link', { name: 'In page' }); + await expect(inPage).not.toHaveAttribute('target'); + + await expect(canvas.getByRole('link', { name: 'Invalid' })).toHaveAttribute( + 'href', + 'www.codecademy.com' + ); + canvas.getByText('Empty'); + + await userEvent.click(inPage); + await expect(args.onAnchorClick).toHaveBeenCalledTimes(1); + }, +}; + +const elements = { + CodeBlock: { + component: (props: TextProps) => , + }, + CustomElement: { + component: ({ title }: { title: string }) => { + return ( + + {title} + + ); + }, + allowedAttributes: ['title'], + }, +}; + +export const Overrides: Story = { + args: { + overrides: elements, + text: ` +## Hello World + +Span inside a custom code block element + + +`, + }, + play: ({ canvas }) => { + canvas.getByRole('heading', { level: 2, name: 'Hello World' }); + canvas.getByText('Span inside a custom code block element'); + canvas.getByRole('heading', { level: 3, name: 'A Custom Component' }); + }, +}; + +const codeBlockOverride = { + component: (props: HTMLProps) => ( + custom codeblock + ), +}; + +const codeMarkdown = ` +\`\`\`js +var test = true; +\`\`\` + +\`var test = false;\` +`; + +export const CodeBlockOverride: Story = { + args: { + overrides: { CodeBlock: codeBlockOverride }, + text: codeMarkdown, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText('custom codeblock')).toHaveAttribute( + 'language', + 'js' + ); + canvas.getByText('var test = false;'); + }, +}; + +export const CodeBlockAndCodeOverrides: Story = { + args: { + overrides: { + CodeBlock: codeBlockOverride, + code: { + component: (props: HTMLProps) => ( + custom code snippet + ), + }, + }, + text: codeMarkdown, + }, + play: ({ canvas }) => { + // TODO: a `code` override resets code's allowed attributes, so the CodeBlock loses `language` + canvas.getByText('custom codeblock'); + canvas.getByText('custom code snippet'); + }, +}; + +export const CustomTagAttributes: Story = { + args: { + overrides: { + TestComponent: { + component: (props: object) => { + onCustomTagRender(props); + return attr-testing-component; + }, + allowedAttributes: ['name', 'isCodeBlock', 'isWebBrowser'], + }, + }, + text: ` +# Heading + + +`, + }, + play: async ({ canvas, canvasElement }) => { + canvas.getByText('attr-testing-component'); + await expect(onCustomTagRender).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my name', + isCodeBlock: true, + isWebBrowser: true, + }) + ); + await expect(canvasElement.querySelector('p > strong')).toBeNull(); + }, +}; + +const tags = { + h3: { + component: (props: TextProps) => ( + + ), + }, +}; + +export const HeaderOverride: Story = { + args: { overrides: tags, text: '### Smaller H3' }, + play: async ({ canvas }) => { + await expect( + getComputedStyle(canvas.getByRole('heading', { name: 'Smaller H3' })) + .fontSize + ).toBe('20px'); + }, +}; + +export const LinkOverride: Story = { + args: { + text: '[This was a link](https://codecademy.com), Now it is not.', + overrides: { + a: { + component: (props) => , + }, + }, + }, + play: async ({ canvas }) => { + await expect(canvas.queryByRole('link')).toBeNull(); + canvas.getByText('This was a link'); + }, +}; + +export const VideoOverride: Story = { + args: { + iframeOverride: { component: Iframe }, + text: youtubeIframe, + videoOverride: { component: MarkdownVideo }, + }, + play: async ({ canvasElement }) => { + await waitFor(() => + expect(canvasElement.querySelector('youtube-video')).toHaveAttribute( + 'src', + 'https://www.youtube.com/embed/zhDwjnYZiCo' + ) + ); + await waitFor( + () => + expect( + canvasElement + .querySelector('youtube-video') + ?.shadowRoot?.querySelector('iframe') + ).toHaveAttribute('title', 'Ghibli Coffee Shop'), + { timeout: 5000 } + ); + await expect(canvasElement.querySelector(':scope iframe')).toBeNull(); + }, +}; + +export const WithoutVideoOverride: Story = { + args: { + text: youtubeIframe, + }, + play: async ({ canvas, canvasElement }) => { + await expect(canvas.getByTitle('Ghibli Coffee Shop').tagName).toBe( + 'IFRAME' + ); + await expect(canvasElement.querySelector('youtube-video')).toBeNull(); + }, +}; + +export const VimeoOverride: Story = { + args: { + iframeOverride: { component: Iframe }, + text: ``, + }, + parameters: { + a11y: { config: { rules: [{ id: 'frame-title', enabled: false }] } }, + }, + play: async ({ canvasElement }) => { + // TODO: Video doesn't title Vimeo's provider iframe when its shadow root attaches late, so it has no accessible name + await waitFor(() => + expect(canvasElement.querySelector('vimeo-video')).toHaveAttribute( + 'src', + 'https://player.vimeo.com/video/188237476' + ) + ); + await expect(canvasElement.querySelector(':scope iframe')).toBeNull(); + }, +}; + +export const VideoTagOverride: Story = { + args: { + text: ``, + videoOverride: { component: MarkdownVideo }, + }, + play: async ({ canvas, canvasElement }) => { + const player = await canvas.findByRole('region', { + name: 'Video Player - Clip', + }); + await waitFor(() => + expect(player.querySelector('video')).toHaveAttribute('src', videoSrc) + ); + await waitFor(() => expect(player).toHaveAttribute('data-can-play')); + await expect( + canvasElement.querySelector('p [data-media-player]') + ).toBeNull(); + }, +}; + +export const VideoSourceOverride: Story = { + args: { + text: ` + +`, + videoOverride: { component: MarkdownVideo }, + }, + play: async ({ canvas }) => { + const player = await canvas.findByRole('region', { name: 'Video Player' }); + await waitFor(() => + expect(player.querySelector('source')).toHaveAttribute('src', videoSrc) + ); + await waitFor(() => expect(player).toHaveAttribute('data-can-play')); + }, +}; + +export const WithoutVideoTagOverride: Story = { + args: { + text: ``, + }, + play: async ({ canvas, canvasElement }) => { + await expect(canvasElement.querySelector('video')).toHaveAttribute( + 'src', + videoSrc + ); + await expect(canvas.queryByRole('region')).toBeNull(); + }, +}; + +export const Checkboxes: Story = { + args: { + text: ` +- [ ] checkbox +- [x] default checked checkbox +an element +- [ ] third checkbox +`, + }, + play: async ({ canvas, userEvent }) => { + const checkboxes = canvas.getAllByRole('checkbox'); + await expect(checkboxes).toHaveLength(3); + await expect(checkboxes[0]).not.toBeChecked(); + await expect(checkboxes[1]).toBeChecked(); + + await userEvent.click(canvas.getByLabelText('checkbox')); + await expect(checkboxes[0]).toBeChecked(); + + const item = checkboxes[0].closest('li')!; + await expect(getComputedStyle(item, '::before').display).toBe('none'); + }, +}; + +const lorem = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + +export const Details: Story = { + args: { + text: ` +
+ ${lorem} +
+ +
+ View More + ${lorem} +
+ +
First Child Summary${lorem}
+`, + }, + play: async ({ canvas, canvasElement, userEvent }) => { + await expect(canvas.getAllByTestId('gamut-md-details')).toHaveLength(3); + await expect(canvas.getAllByText('Details')).toHaveLength(1); + canvas.getByText('View More'); + canvas.getByText('First Child Summary'); + + const [details] = canvasElement.querySelectorAll('details'); + await expect(details).not.toHaveAttribute('open'); + await userEvent.click(within(details).getByText('Details')); + await expect(details).toHaveAttribute('open'); + }, +}; + +export const SmartPunctuation: Story = { + args: { + inline: true, + text: 'Text with a — in the middle and `code with a — in`', + }, + play: ({ canvas }) => { + canvas.getByText(/Text with a — in the middle/); + canvas.getByText('code with a — in'); + }, +}; + +export const InlineMarkdown: Story = { + args: { text: `this is an inline markdown component`, inline: true }, + render: (args) => { + return ( + <> + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. + + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa + qui officia deserunt mollit anim id est laborum. + + ); + }, + play: async ({ canvas }) => { + await expect( + canvas.getByText('this is an inline markdown component').tagName + ).toBe('SPAN'); + }, +}; + +export const Sanitization: Story = { + args: { + text: ` + + + + +

Click handler paragraph

+ +Sanitized image + +[Script link](javascript:alert(1)) + +Raw script link + +
Form text
+ +Object text + +
Div text
+`, + }, + play: async ({ canvas, canvasElement }) => { + await expect(canvasElement.querySelector('script, style')).toBeNull(); + await expect( + canvas.getByText('Click handler paragraph') + ).not.toHaveAttribute('onclick'); + await expect( + canvas.getByRole('img', { name: 'Sanitized image' }) + ).not.toHaveAttribute('onerror'); + + await expect(canvas.queryByRole('link')).toBeNull(); + await expect(canvas.getByText('Script link')).not.toHaveAttribute('href'); + await expect(canvas.getByText('Raw script link')).not.toHaveAttribute( + 'href' + ); + + await expect(canvasElement.querySelector('form, input, object')).toBeNull(); + await expect(canvas.getByText('Div text')).toHaveAttribute( + 'class', + expect.not.stringMatching(/evil|other/) + ); + await expect(canvas.getByText('Div text')).toHaveClass( + 'narrative-table-container' + ); + }, +}; + +export const WithoutDefaultOverrides: Story = { + args: { + skipDefaultOverrides: { a: true, checkbox: true, details: true }, + text: ` +[External](http://example.com) + +
+ Details body +
+ +- [ ] checkbox +- [x] checked +`, + }, + parameters: { + a11y: { config: { rules: [{ id: 'label', enabled: false }] } }, + }, + play: async ({ canvas, canvasElement }) => { + // TODO: with the checkbox override skipped, task-list checkboxes render as enabled inputs with no label + const link = canvas.getByRole('link', { name: 'External' }); + await expect(link).not.toHaveAttribute('target'); + await expect(link).not.toHaveAttribute('rel'); + + await expect(canvas.queryByTestId('gamut-md-details')).toBeNull(); + await expect(canvasElement.querySelector('summary')).toBeNull(); + canvas.getByText('Details body'); + + await expect(canvas.queryByTestId('gamut-md-checkbox')).toBeNull(); + const checkboxes = canvas.getAllByRole('checkbox'); + await expect(checkboxes).toHaveLength(2); + await expect(checkboxes[1]).toBeChecked(); + await expect( + getComputedStyle(checkboxes[0].closest('li')!, '::before').display + ).not.toBe('none'); + }, +}; + +export const DetailsOpen: Story = { + args: { + text: ` +
+ Open by default + ${lorem} +
+`, + }, + play: async ({ canvas, canvasElement, userEvent }) => { + // TODO: the sanitizer config has no `details` entry, so `open` is stripped and authored details always start closed + const details = canvasElement.querySelector('details')!; + + await userEvent.click(canvas.getByText('Open by default')); + await expect(details).toHaveAttribute('open'); + }, +}; + +export const WithoutPlayerEmbed: Story = { + args: { + iframeOverride: { component: Iframe }, + text: ``, + }, + tags: ['!test'], + play: async ({ canvas, canvasElement }) => { + // TODO: Unmounting a vidstack YouTube player before it loads throws unhandled "provider destroyed" rejections, so this story is excluded from test runs + await canvas.findByRole('region', { name: 'Video Player - No embed' }); + await expect(canvasElement.querySelector('youtube-video')).toBeNull(); + }, +}; + +export const YoutubeVideoWithCaptions: Story = { + args: { + text: ` + +`, + videoOverride: { component: MarkdownVideo }, + }, + tags: ['!test'], + play: async ({ canvas, canvasElement }) => { + // TODO: Unmounting a vidstack YouTube player before it loads throws unhandled "provider destroyed" rejections, so this story is excluded from test runs + await canvas.findByRole('region', { + name: 'Video Player - Captioned YouTube', + }); + await expect(canvasElement.querySelector('youtube-video')).toBeNull(); + }, +}; + +export const VideoWithCaptions: Story = { + args: { + text: ` + +`, + videoOverride: { component: MarkdownVideo }, + }, + play: async ({ canvas }) => { + const player = await canvas.findByRole('region', { + name: 'Video Player - Captioned', + }); + await waitFor(() => + expect(player.querySelector('track')).toHaveAttribute('src', captionsSrc) + ); + const track = player.querySelector('track')!; + await expect(track).toHaveAttribute('kind', 'captions'); + await expect(track).toHaveAttribute('srclang', 'en'); + await expect(track).toHaveAttribute('label', 'English'); + await waitFor(() => expect(player).toHaveAttribute('data-can-play')); + }, +}; + +export const EmptyText: Story = { + args: { text: '' }, + play: async ({ canvasElement }) => { + await expect(canvasElement.firstElementChild?.childElementCount).toBe(0); + }, +}; + +export const WithClassName: Story = { + args: { className: 'custom-markdown', text: 'Styled by a consumer class' }, + play: async ({ canvas }) => { + await expect( + canvas.getByText('Styled by a consumer class').closest('.custom-markdown') + ).not.toBeNull(); + }, +}; + +export const InlineIgnoresBlockSyntax: Story = { + args: { inline: true, text: '# Not a heading with **bold** text' }, + play: async ({ canvas }) => { + await expect(canvas.queryByRole('heading')).toBeNull(); + canvas.getByText(/# Not a heading with/); + await expect(canvas.getByText('bold').tagName).toBe('STRONG'); + }, +}; + +export const ProcessNodeOverrides: Story = { + args: { + overrides: { + em: { + processNode: (_node, props) => { + const { key, children } = props as { + key?: string; + children?: ReactNode; + }; + return {children}; + }, + }, + highlight: { + shouldProcessNode: (node) => node.name === 'strong', + component: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }, + }, + text: 'Some _emphasis_ and some **strong** text', + }, + play: async ({ canvas, canvasElement }) => { + await expect(canvas.getByText('emphasis').tagName).toBe('MARK'); + await expect(canvasElement.querySelector('em, strong')).toBeNull(); + await expect(canvas.getByText('strong').tagName).toBe('SPAN'); + }, +}; diff --git a/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx b/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx deleted file mode 100644 index ef6609c453..0000000000 --- a/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx +++ /dev/null @@ -1,485 +0,0 @@ -/* eslint-disable jsx-a11y/no-distracting-elements */ - -import { setupRtl } from '@skillsoft/gamut-tests'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type * as React from 'react'; - -import { Iframe, MarkdownVideo } from '../../Video'; -import { Markdown } from '../index'; - -const mockTitle = 'a fake youtube'; - -jest.mock('react-player', () => ({ - __esModule: true, - default: () => -`; - -const youtubeMarkdown = ` - -`; - -const vimeoMarkdown = ` - -`; - -const videoMarkdown = ` -