From c07a288daac0cc39e8bc8e78b5e214cb3c45a2f8 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Mon, 6 Mar 2023 10:13:43 +0100 Subject: [PATCH] feat: Add table component (#207) * feat: Add table component * style: Lint --- .../components/Accordion/Accordion.test.tsx | 13 +- .../Accordion/AccordionHeader.test.tsx | 24 +- .../src/components/Accordion/icons/index.ts | 2 +- .../src/components/Button/Button.test.tsx | 75 +++--- .../react/src/components/Button/Button.tsx | 2 +- .../CheckboxGroup/CheckboxGroup.test.tsx | 5 +- .../CheckboxGroup/CheckboxGroup.tsx | 4 +- .../react/src/components/List/List.test.tsx | 5 +- packages/react/src/components/List/List.tsx | 9 +- .../react/src/components/List/ListItem.tsx | 6 +- .../src/components/RadioGroup/RadioGroup.tsx | 1 - .../src/components/Select/Select.test.tsx | 66 +++-- .../react/src/components/Select/utils.test.ts | 6 +- packages/react/src/components/Select/utils.ts | 18 +- .../react/src/components/Table/ExampleData.ts | 82 +++++++ .../Table/ResponsiveTable.stories.mdx | 77 ++++++ .../src/components/Table/ResponsiveTable.tsx | 226 ++++++++++++++++++ .../react/src/components/Table/SortIcon.tsx | 17 ++ .../src/components/Table/Table.module.css | 11 + .../src/components/Table/Table.stories.mdx | 70 ++++++ .../components/Table/Table.stories.module.css | 4 + .../react/src/components/Table/Table.test.tsx | 61 +++++ packages/react/src/components/Table/Table.tsx | 35 +++ .../src/components/Table/TableBody.module.css | 7 + .../react/src/components/Table/TableBody.tsx | 28 +++ .../src/components/Table/TableCell.module.css | 77 ++++++ .../react/src/components/Table/TableCell.tsx | 124 ++++++++++ .../components/Table/TableFooter.module.css | 7 + .../src/components/Table/TableFooter.tsx | 28 +++ .../components/Table/TableHeader.module.css | 7 + .../src/components/Table/TableHeader.tsx | 28 +++ .../src/components/Table/TableRow.module.css | 16 ++ .../react/src/components/Table/TableRow.tsx | 57 +++++ packages/react/src/components/Table/index.ts | 18 ++ packages/react/src/components/Table/utils.ts | 48 ++++ .../src/components/TextArea/TextArea.test.tsx | 21 +- .../components/TextField/TextField.test.tsx | 64 +++-- .../CheckboxRadioTemplate.test.tsx | 5 +- .../CheckboxRadioTemplate.tsx | 39 +-- .../_CheckboxRadioTemplate/index.ts | 4 +- .../components/_InputWrapper/ErrorIcon.tsx | 16 +- .../components/_InputWrapper/Icon.test.tsx | 9 +- .../src/components/_InputWrapper/Icon.tsx | 5 +- .../_InputWrapper/InputWrapper.test.tsx | 42 ++-- .../components/_InputWrapper/InputWrapper.tsx | 14 +- .../components/_InputWrapper/SearchIcon.tsx | 16 +- packages/react/src/components/index.ts | 22 ++ packages/react/src/hooks/index.ts | 1 + .../react/src/hooks/useMediaQuery.test.ts | 57 +++++ packages/react/src/hooks/useMediaQuery.ts | 21 ++ .../react/src/utils/compareFunctions.test.ts | 36 +-- packages/react/src/utils/compareFunctions.ts | 13 +- packages/react/src/utils/sortUtils.test.ts | 39 +-- packages/react/src/utils/sortUtils.ts | 37 +-- packages/react/src/utils/stringUtils.test.ts | 29 ++- packages/react/src/utils/stringUtils.ts | 24 +- 56 files changed, 1480 insertions(+), 298 deletions(-) create mode 100644 packages/react/src/components/Table/ExampleData.ts create mode 100644 packages/react/src/components/Table/ResponsiveTable.stories.mdx create mode 100644 packages/react/src/components/Table/ResponsiveTable.tsx create mode 100644 packages/react/src/components/Table/SortIcon.tsx create mode 100644 packages/react/src/components/Table/Table.module.css create mode 100644 packages/react/src/components/Table/Table.stories.mdx create mode 100644 packages/react/src/components/Table/Table.stories.module.css create mode 100644 packages/react/src/components/Table/Table.test.tsx create mode 100644 packages/react/src/components/Table/Table.tsx create mode 100644 packages/react/src/components/Table/TableBody.module.css create mode 100644 packages/react/src/components/Table/TableBody.tsx create mode 100644 packages/react/src/components/Table/TableCell.module.css create mode 100644 packages/react/src/components/Table/TableCell.tsx create mode 100644 packages/react/src/components/Table/TableFooter.module.css create mode 100644 packages/react/src/components/Table/TableFooter.tsx create mode 100644 packages/react/src/components/Table/TableHeader.module.css create mode 100644 packages/react/src/components/Table/TableHeader.tsx create mode 100644 packages/react/src/components/Table/TableRow.module.css create mode 100644 packages/react/src/components/Table/TableRow.tsx create mode 100644 packages/react/src/components/Table/index.ts create mode 100644 packages/react/src/components/Table/utils.ts create mode 100644 packages/react/src/hooks/useMediaQuery.test.ts create mode 100644 packages/react/src/hooks/useMediaQuery.ts diff --git a/packages/react/src/components/Accordion/Accordion.test.tsx b/packages/react/src/components/Accordion/Accordion.test.tsx index ef3ea16516..93cafc57bc 100644 --- a/packages/react/src/components/Accordion/Accordion.test.tsx +++ b/packages/react/src/components/Accordion/Accordion.test.tsx @@ -19,8 +19,8 @@ const defaultProps: AccordionProps = { ), open: false, - onClick: jest.fn() -} + onClick: jest.fn(), +}; // Mocks: jest.mock('./icons', () => ({ @@ -29,7 +29,12 @@ jest.mock('./icons', () => ({ })); const render = (props: Partial = {}) => - renderRtl(); + renderRtl( + , + ); const user = userEvent.setup(); @@ -82,6 +87,6 @@ describe('Accordion', () => { otherVariants.forEach((v) => { expect(screen.queryByTestId(v)).not.toBeInTheDocument(); }); - } + }, ); }); diff --git a/packages/react/src/components/Accordion/AccordionHeader.test.tsx b/packages/react/src/components/Accordion/AccordionHeader.test.tsx index 2e168ac9c5..9fe7b54100 100644 --- a/packages/react/src/components/Accordion/AccordionHeader.test.tsx +++ b/packages/react/src/components/Accordion/AccordionHeader.test.tsx @@ -12,15 +12,19 @@ const defaultProps: AccordionHeaderProps = { children: header, }; -const renderWithContext = (props: Partial = {}) => render( - - - AccordionContent - -); +const renderWithContext = (props: Partial = {}) => + render( + + + AccordionContent + , + ); describe('AccordionHeader', () => { it('Shows subtitle when "subtitle" prop is set', () => { @@ -32,7 +36,7 @@ describe('AccordionHeader', () => { it('Does not show subtitle when "subtitle" prop is not set', () => { renderWithContext({ subtitle: undefined }); expect( - screen.queryByTestId('accordion-header-subtitle') + screen.queryByTestId('accordion-header-subtitle'), ).not.toBeInTheDocument(); }); }); diff --git a/packages/react/src/components/Accordion/icons/index.ts b/packages/react/src/components/Accordion/icons/index.ts index 6b787afbc7..e421c0a893 100644 --- a/packages/react/src/components/Accordion/icons/index.ts +++ b/packages/react/src/components/Accordion/icons/index.ts @@ -1,2 +1,2 @@ export { Arrow } from './Arrow'; -export { CircleArrow } from './CircleArrow'; \ No newline at end of file +export { CircleArrow } from './CircleArrow'; diff --git a/packages/react/src/components/Button/Button.test.tsx b/packages/react/src/components/Button/Button.test.tsx index 8004eeecea..5be718229a 100644 --- a/packages/react/src/components/Button/Button.test.tsx +++ b/packages/react/src/components/Button/Button.test.tsx @@ -20,39 +20,48 @@ describe('Button', () => { expect(button.classList).not.toContain('cancel'); }); - it.each(Object.values(ButtonVariant))(`should render a button with correct classname when variant is %s`, (variant) => { - render({ variant }); - const otherVariants = Object.values(ButtonVariant).filter( - (v) => v !== variant, - ); - - const button = screen.getByRole('button'); - - expect(button.classList).toContain(variant); - otherVariants.forEach((v) => expect(button.classList).not.toContain(v)); - }); - - it.each(Object.values(ButtonColor))(`should render a button with correct classname when color is %s`, (color) => { - render({ color }); - const otherVariants = Object.values(ButtonColor).filter( - (c) => c !== color, - ); - - const button = screen.getByRole('button'); - - expect(button.classList).toContain(color); - otherVariants.forEach((c) => expect(button.classList).not.toContain(c)); - }); - - it.each(Object.values(ButtonSize))(`should render a button with correct classname when size is %s`, (size) => { - render({ size }); - const otherVariants = Object.values(ButtonSize).filter((s) => s !== size); - - const button = screen.getByRole('button'); - - expect(button.classList).toContain(size); - otherVariants.forEach((s) => expect(button.classList).not.toContain(s)); - }); + it.each(Object.values(ButtonVariant))( + `should render a button with correct classname when variant is %s`, + (variant) => { + render({ variant }); + const otherVariants = Object.values(ButtonVariant).filter( + (v) => v !== variant, + ); + + const button = screen.getByRole('button'); + + expect(button.classList).toContain(variant); + otherVariants.forEach((v) => expect(button.classList).not.toContain(v)); + }, + ); + + it.each(Object.values(ButtonColor))( + `should render a button with correct classname when color is %s`, + (color) => { + render({ color }); + const otherVariants = Object.values(ButtonColor).filter( + (c) => c !== color, + ); + + const button = screen.getByRole('button'); + + expect(button.classList).toContain(color); + otherVariants.forEach((c) => expect(button.classList).not.toContain(c)); + }, + ); + + it.each(Object.values(ButtonSize))( + `should render a button with correct classname when size is %s`, + (size) => { + render({ size }); + const otherVariants = Object.values(ButtonSize).filter((s) => s !== size); + + const button = screen.getByRole('button'); + + expect(button.classList).toContain(size); + otherVariants.forEach((s) => expect(button.classList).not.toContain(s)); + }, + ); it('should render an icon on the left side of text when given an existing iconName and no iconPlacement', () => { render({ icon: , children: 'Button text' }); diff --git a/packages/react/src/components/Button/Button.tsx b/packages/react/src/components/Button/Button.tsx index 225ed0d1c8..39d12148c3 100644 --- a/packages/react/src/components/Button/Button.tsx +++ b/packages/react/src/components/Button/Button.tsx @@ -1,8 +1,8 @@ +import type { ReactNode } from 'react'; import React, { forwardRef, type ButtonHTMLAttributes, type PropsWithChildren, - ReactNode, } from 'react'; import cn from 'classnames'; diff --git a/packages/react/src/components/CheckboxGroup/CheckboxGroup.test.tsx b/packages/react/src/components/CheckboxGroup/CheckboxGroup.test.tsx index e82789574e..f461c85377 100644 --- a/packages/react/src/components/CheckboxGroup/CheckboxGroup.test.tsx +++ b/packages/react/src/components/CheckboxGroup/CheckboxGroup.test.tsx @@ -2,9 +2,8 @@ import React from 'react'; import { act, render as renderRtl, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CheckboxGroup, CheckboxGroupItem } from './'; - -import type { CheckboxGroupProps } from './'; +import { CheckboxGroup } from './'; +import type { CheckboxGroupProps, CheckboxGroupItem } from './'; const user = userEvent.setup(); diff --git a/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx b/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx index e65b2b681f..b94747b84e 100644 --- a/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx +++ b/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx @@ -1,11 +1,11 @@ -import React, { ReactNode, useReducer } from 'react'; +import type { ReactNode } from 'react'; +import React, { useReducer } from 'react'; import cn from 'classnames'; import { Checkbox } from '../Checkbox'; import { FieldSet, FieldSetSize } from '../FieldSet'; import { areItemsUnique, arraysEqual } from '../../utils/arrayUtils'; import { usePrevious, useUpdate } from '../../hooks'; - import type { CheckboxProps } from '../Checkbox'; import classes from './CheckboxGroup.module.css'; diff --git a/packages/react/src/components/List/List.test.tsx b/packages/react/src/components/List/List.test.tsx index 1637ca53b9..bc4a413821 100644 --- a/packages/react/src/components/List/List.test.tsx +++ b/packages/react/src/components/List/List.test.tsx @@ -1,10 +1,9 @@ import React from 'react'; import { render as renderRtl, screen } from '@testing-library/react'; -import type { ListProps } from './List'; +import type { ListProps, ListBorderStyle } from './List'; import { List } from './List'; import { ListItem } from './ListItem'; -import type { ListBorderStyle } from "./List"; const render = (props: Partial = {}) => { const allProps: ListProps = { @@ -26,7 +25,7 @@ describe('List', () => { const list = screen.getByRole('list'); expect(list).toHaveClass('solid'); expect(list).not.toHaveClass('dashed'); - } + }, ); it('Renders a list with dashed border when "borderStyle" is dashed', () => { diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index 9dce1864db..39fe38120c 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -9,11 +9,6 @@ export interface ListProps { borderStyle?: ListBorderStyle; } -export const List = ({ - children, - borderStyle = 'solid', -}: ListProps) => ( -
    - {children} -
+export const List = ({ children, borderStyle = 'solid' }: ListProps) => ( +
    {children}
); diff --git a/packages/react/src/components/List/ListItem.tsx b/packages/react/src/components/List/ListItem.tsx index fcedf64d1c..a00fa699ee 100644 --- a/packages/react/src/components/List/ListItem.tsx +++ b/packages/react/src/components/List/ListItem.tsx @@ -6,8 +6,6 @@ export interface ListItemProps { children?: React.ReactNode; } -export const ListItem = ({ children }: ListItemProps) => ( -
  • - {children} -
  • +export const ListItem = ({ children }: ListItemProps) => ( +
  • {children}
  • ); diff --git a/packages/react/src/components/RadioGroup/RadioGroup.tsx b/packages/react/src/components/RadioGroup/RadioGroup.tsx index 478916e4ee..86b96de4f7 100644 --- a/packages/react/src/components/RadioGroup/RadioGroup.tsx +++ b/packages/react/src/components/RadioGroup/RadioGroup.tsx @@ -5,7 +5,6 @@ import { RadioButton, RadioButtonSize } from '../RadioButton'; import { FieldSet, FieldSetSize } from '../FieldSet'; import { usePrevious, useUpdate } from '../../hooks'; import { areItemsUnique } from '../../utils/arrayUtils'; - import type { RadioButtonProps } from '../RadioButton'; import classes from './RadioGroup.module.css'; diff --git a/packages/react/src/components/Select/Select.test.tsx b/packages/react/src/components/Select/Select.test.tsx index 0c38ce3156..f1e40c5c87 100644 --- a/packages/react/src/components/Select/Select.test.tsx +++ b/packages/react/src/components/Select/Select.test.tsx @@ -18,7 +18,9 @@ const singleSelectOptions: SingleSelectOption[] = [ { label: 'Test 3', value: 'test3' }, ]; -const multiSelectOptions: Required>[] = [ +const multiSelectOptions: Required< + Omit +>[] = [ { label: 'Test 1', value: 'test1', deleteButtonLabel: 'Delete test 1' }, { label: 'Test 2', value: 'test2', deleteButtonLabel: 'Delete test 2' }, { label: 'Test 3', value: 'test3', deleteButtonLabel: 'Delete test 3' }, @@ -40,14 +42,16 @@ const sortedOptions: SingleSelectOption[] = [ ]; const optionSearch = jest.fn((_o, _k) => sortedOptions); jest.mock('./utils', () => ({ - optionSearch: (options: SingleSelectOption[] | MultiSelectOption[], keyword: string) => optionSearch(options, keyword), + optionSearch: ( + options: SingleSelectOption[] | MultiSelectOption[], + keyword: string, + ) => optionSearch(options, keyword), })); describe('Select', () => { afterEach(jest.clearAllMocks); describe('Single select', () => { - it('Renders a select box', () => { renderSingleSelect(); expect(getCombobox()).toBeTruthy(); @@ -228,10 +232,15 @@ describe('Select', () => { it('Rerenders with new selected value when the "value" property changes', async () => { const selectedValue = singleSelectOptions[0].value; - const newValueIndex = 2 + const newValueIndex = 2; const newValue = singleSelectOptions[newValueIndex].value; const { rerender } = renderSingleSelect({ value: selectedValue }); - rerender(, + ); expectSelectedValue(singleSelectOptions[newValueIndex]); }); @@ -264,10 +273,13 @@ describe('Select', () => { const selectedOption = singleSelectOptions[selectedOptionIndex]; renderSingleSelect({ value: selectedOption.value }); await act(() => user.type(screen.getByRole('textbox'), 'a')); - expect(screen.queryByText( - (content, element) => element?.tagName.toLowerCase() === 'span' - && content === selectedOption.label - )).toBeFalsy(); + expect( + screen.queryByText( + (content, element) => + element?.tagName.toLowerCase() === 'span' && + content === selectedOption.label, + ), + ).toBeFalsy(); }); it('Calls optionSearch function when user types in the search field', async () => { @@ -280,7 +292,9 @@ describe('Select', () => { it('Sorts options and selects the first when user types in the search field', async () => { renderSingleSelect(); await act(() => user.type(screen.getByRole('textbox'), 'a')); - expect(screen.getAllByRole('option')[0]).toHaveValue(sortedOptions[0].value); + expect(screen.getAllByRole('option')[0]).toHaveValue( + sortedOptions[0].value, + ); expect(getCombobox()).toHaveValue(sortedOptions[0].value); }); @@ -289,11 +303,13 @@ describe('Select', () => { await act(() => user.type(screen.getByRole('textbox'), 'a')); await act(() => user.keyboard('{ArrowDown}')); expect(screen.getByRole('textbox')).toHaveValue(''); - expect(screen.getAllByRole('option')[0]).toHaveValue(sortedOptions[0].value); + expect(screen.getAllByRole('option')[0]).toHaveValue( + sortedOptions[0].value, + ); }); it('Does not reset keyword while user is writing', async () => { - renderSingleSelect() + renderSingleSelect(); const keyword = 'abc'; await act(() => user.type(screen.getByRole('textbox'), keyword)); expect(screen.getByRole('textbox')).toHaveValue(keyword); @@ -301,10 +317,13 @@ describe('Select', () => { const expectSelectedValue = (option: SingleSelectOption) => { expect(getCombobox()).toHaveValue(option.value); - expect(screen.getByText( - (content, element) => element?.tagName.toLowerCase() === 'span' - && content === option.label - )).toBeInTheDocument(); + expect( + screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'span' && + content === option.label, + ), + ).toBeInTheDocument(); }; }); @@ -622,7 +641,12 @@ describe('Select', () => { const newValueIndices = [1, 2]; const newValues = newValueIndices.map((i) => multiSelectOptions[i].value); const { rerender } = renderMultiSelect({ value: selectedValues }); - rerender(, + ); expectSelectedValues(newValues); expectSelectedOptions((i) => newValueIndices.includes(i)); }); @@ -662,7 +686,9 @@ describe('Select', () => { it('Sorts options and focuses on the first when user types in the search field', async () => { const { container } = renderMultiSelect(); await act(() => user.type(screen.getByRole('textbox'), 'a')); - expect(screen.getAllByRole('option')[0]).toHaveValue(sortedOptions[0].value); + expect(screen.getAllByRole('option')[0]).toHaveValue( + sortedOptions[0].value, + ); expectFocusedOption(container, sortedOptions[0]); }); @@ -671,7 +697,9 @@ describe('Select', () => { await act(() => user.type(screen.getByRole('textbox'), 'a')); await act(() => user.keyboard('{Enter}')); expect(screen.getByRole('textbox')).toHaveValue(''); - expect(screen.getAllByRole('option')[0]).toHaveValue(sortedOptions[0].value); + expect(screen.getAllByRole('option')[0]).toHaveValue( + sortedOptions[0].value, + ); }); it('Does not reset keyword while user is writing', async () => { diff --git a/packages/react/src/components/Select/utils.test.ts b/packages/react/src/components/Select/utils.test.ts index ef4064eed9..a117343257 100644 --- a/packages/react/src/components/Select/utils.test.ts +++ b/packages/react/src/components/Select/utils.test.ts @@ -1,5 +1,5 @@ -import {SingleSelectOption} from "./Select"; -import {optionSearch} from "./utils"; +import type { SingleSelectOption } from './Select'; +import { optionSearch } from './utils'; describe('Select utils', () => { describe('optionSearch', () => { @@ -59,4 +59,4 @@ describe('Select utils', () => { expect(result[3].label).toEqual('Hund'); // No match }); }); -}); \ No newline at end of file +}); diff --git a/packages/react/src/components/Select/utils.ts b/packages/react/src/components/Select/utils.ts index 9ff2a0e288..184ee6e69d 100644 --- a/packages/react/src/components/Select/utils.ts +++ b/packages/react/src/components/Select/utils.ts @@ -1,16 +1,18 @@ -import { MultiSelectOption, SingleSelectOption } from "./Select"; -import { compareMatch, orderByKeywords } from "../../utils"; +import { compareMatch, orderByKeywords } from '../../utils'; + +import type { MultiSelectOption, SingleSelectOption } from './Select'; export const optionSearch = ( options: T[], - search: string + search: string, ): T[] => { const keywordMap = new Map( options.map(({ label, value, keywords }) => [ value, - keywords ? [label, ...keywords] : [label] - ]) + keywords ? [label, ...keywords] : [label], + ]), + ); + return orderByKeywords(keywordMap, compareMatch(search)).map( + (key) => options.find((option) => option.value === key)!, ); - return orderByKeywords(keywordMap, compareMatch(search)) - .map((key) => options.find((option) => option.value === key)!!); -} \ No newline at end of file +}; diff --git a/packages/react/src/components/Table/ExampleData.ts b/packages/react/src/components/Table/ExampleData.ts new file mode 100644 index 0000000000..d8e35ae100 --- /dev/null +++ b/packages/react/src/components/Table/ExampleData.ts @@ -0,0 +1,82 @@ +export interface ExampleTableData { + caseNum: number; + product: string; + status: string; + image: { src: string; alt: string }; +} + +export const exampleRows: ExampleTableData[] = [ + { + caseNum: 20220873, + product: 'Emballasje for snacksprodukter', + status: 'Under behandling', + image: { + src: 'https://search.patentstyret.no/onlinedb_files_ds/Pictures/2022/9/21/317574.png', + alt: 'Potetgullpose', + }, + }, + { + caseNum: 20220590, + product: 'Apparat for rengjøring av sveisesøm', + status: 'Registert', + image: { + src: 'https://search.patentstyret.no/onlinedb_files_ds/Pictures/2022/6/30/313443.jpg', + alt: 'Apparat for rengjøring av sveisesøm', + }, + }, + { + caseNum: 20220827, + product: 'Logo', + status: 'Besluttet gjeldende', + image: { + src: 'https://search.patentstyret.no/onlinedb_files_ds/Pictures/2022/9/17/317418.JPG', + alt: 'Logo', + }, + }, + { + caseNum: 20220582, + product: + 'Modul for handikaprampe, bunnramme til modul for handikaprampe, rekkverk til modul for handikaprampe', + status: 'Registrert', + image: { + src: 'https://search.patentstyret.no/onlinedb_files_ds/Pictures/2022/6/20/313066.jpg', + alt: 'Bilde av handikaprampe', + }, + }, + { + caseNum: 20220408, + product: 'Bil', + status: 'Registert', + image: { + src: 'https://search.patentstyret.no/onlinedb_files_ds/Pictures/2022/5/11/310547.jpg', + alt: 'Bil', + }, + }, + { + caseNum: 20208507, + product: 'Vippesykkel', + status: 'Besluttet gjeldende', + image: { + src: 'https://search.patentstyret.no/Onlinedb_files_tm/Pictures/200208/200208507.jpg', + alt: 'Vippesykkel', + }, + }, + { + caseNum: 20081269, + product: 'SHELL', + status: 'Besluttet gjeldende', + image: { + src: 'https://search.patentstyret.no/Onlinedb_files_tm/Pictures/200431/200812696.jpg', + alt: 'Shell', + }, + }, + { + caseNum: 20110659, + product: 'DNB', + status: 'Registrert', + image: { + src: 'https://search.patentstyret.no/Onlinedb_files_tm/Pictures/200448/201106591_5%20Figurmerker%20og%20bilder(cropped)%20-%201_200523766_0.jpg', + alt: 'Dnb', + }, + }, +]; diff --git a/packages/react/src/components/Table/ResponsiveTable.stories.mdx b/packages/react/src/components/Table/ResponsiveTable.stories.mdx new file mode 100644 index 0000000000..ca06f00a71 --- /dev/null +++ b/packages/react/src/components/Table/ResponsiveTable.stories.mdx @@ -0,0 +1,77 @@ +import {ArgsTable, Canvas, Meta, Story} from "@storybook/addon-docs"; +import {ResponsiveTable} from "./"; +import {TokensTable} from "../../../../../docs-components"; +import {exampleRows} from "./ExampleData"; +import classes from "./Table.stories.module.css"; + + + +export const Template = () => console.log('Change sort of column ' + column + ' to ' + next + ' from ' + previous), + sortable: ['caseNum', 'product'], + currentlySortedColumn: 'caseNum', + currentDirection: 'notActive', + }, + renderCell: { + image: (imageObj) => ( + {imageObj.alt} + ), + }, + rowSelection: { + onSelectionChange: (row) => console.log('Select row:', row), + selectedValue: { + caseNum: 20220408, + product: 'Bil', + status: 'Registert', + image: { + src: 'https://search.patentstyret.no/onlinedb_files_ds/Pictures/2022/5/11/310547.jpg', + alt: 'Bil', + }, + }, + }, + footer: "Lorem ipsum dolor sit amet.", +}}/>; + +# ResponsiveTable + +## Eksempel + + + + {Template.bind({})} + + + + +## Egenskaper + + +## Tokens + \ No newline at end of file diff --git a/packages/react/src/components/Table/ResponsiveTable.tsx b/packages/react/src/components/Table/ResponsiveTable.tsx new file mode 100644 index 0000000000..4a236e6770 --- /dev/null +++ b/packages/react/src/components/Table/ResponsiveTable.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import * as tokens from '@altinn/figma-design-tokens'; + +import { useMediaQuery } from '../../hooks'; +import { RadioButton } from '../RadioButton'; + +import type { SortProps, SortDirection } from './utils'; +import { Table } from './Table'; +import { TableHeader } from './TableHeader'; +import { TableRow } from './TableRow'; +import { TableCell } from './TableCell'; +import { TableBody } from './TableBody'; +import { TableFooter } from './TableFooter'; +import classes from './TableCell.module.css'; + +export interface ResponsiveTableConfig { + rows: T[]; + headers: { [Col in keyof T]: string | JSX.Element }; + showColumnsMobile?: (keyof T)[]; + /** + * Custom per-cell rendering. All cells will render their content directly by default (assumed to be string, number + * or some simple scalar type). If you need to override how each cell is rendered, you can supply a render function here. + */ + renderCell?: { [K in keyof T]?: (cell: T[K]) => JSX.Element }; + /** + * Column sort functionality. If you set this property, you need to specify which columns/headers are sortable, + * the current state for which column is sorted (along with the direction) and a callback to handle sort changes. + */ + columnSort?: { + onSortChange: (props: SortProps & { column: keyof T }) => void; + sortable: (keyof T)[]; + currentlySortedColumn: keyof T | undefined; + currentDirection: SortDirection | undefined; + }; + /** + * Row selection functionality. If you set this property, you'll enable selecting individual rows. You have to + * handle the selection state yourself, and re-render this component with a new selectedValue whenever it changes. + */ + rowSelection?: { + onSelectionChange: (row: T) => void; + selectedValue: T | undefined; + }; + /** + * Renders some content into a footer row spanning all columns. Usually used for rendering + * a Pagination component. + */ + footer?: JSX.Element; +} + +export interface ResponsiveTableProps { + config: ResponsiveTableConfig; +} + +export function ResponsiveTable({ config }: ResponsiveTableProps) { + const isMobile = useMediaQuery(`(max-width: ${tokens.BreakpointsSm})`); + + return isMobile ? ( + + ) : ( + + ); +} + +function MobileTable({ config }: ResponsiveTableProps) { + const { rows, headers, showColumnsMobile, renderCell, rowSelection, footer } = + config; + + const selectedRowJson = JSON.stringify(rowSelection?.selectedValue || null); + const columns = Object.keys(headers) as (keyof T)[]; + const numColumns = rowSelection ? 2 : 1; + + return ( + + rowSelection?.onSelectionChange(selectedValue) + } + selectedValue={rowSelection?.selectedValue} + > + + {rows.map((row) => { + const value = JSON.stringify(row); + return ( + + {rowSelection && ( + + rowSelection.onSelectionChange(row)} + value={value} + checked={value === selectedRowJson} + label={value} + hideLabel={true} + /> + + )} + + {columns.map((column) => { + if ( + showColumnsMobile && + !showColumnsMobile.includes(column) + ) { + return; + } + + const renderFunc = renderCell && renderCell[column]; + const content = renderFunc + ? renderFunc(row[column]) + : (row[column] as string); + + return ( + <> +
    {headers[column]}
    +
    {content}
    + + ); + })} +
    +
    + ); + })} +
    + {footer && ( + + + {footer} + + + )} +
    + ); +} + +function LaptopTable({ config }: ResponsiveTableProps) { + const { rows, headers, renderCell, columnSort, rowSelection, footer } = + config; + + const selectedRowJson = JSON.stringify(rowSelection?.selectedValue || null); + const columns = Object.keys(headers) as (keyof T)[]; + const numColumns = rowSelection + ? Object.keys(headers).length + 1 + : Object.keys(headers).length; + + return ( + + rowSelection?.onSelectionChange(selectedValue) + } + selectedValue={rowSelection?.selectedValue} + > + + + {rowSelection && } + {columns.map((column) => ( + { + columnSort && + columnSort.onSortChange({ column, next, previous }); + }} + sortDirection={ + columnSort + ? columnSort.currentlySortedColumn === column + ? columnSort.currentDirection + : columnSort.sortable.includes(column) + ? 'notActive' + : 'notSortable' + : 'notSortable' + } + > + {headers[column]} + + ))} + + + + {rows.map((row) => { + const value = JSON.stringify(row); + return ( + + {rowSelection && ( + + rowSelection.onSelectionChange(row)} + value={value} + checked={value === selectedRowJson} + label={value} + hideLabel={true} + > + + )} + {columns.map((column) => { + const renderFunc = renderCell && renderCell[column]; + return ( + + {renderFunc + ? renderFunc(row[column]) + : (row[column] as string)} + + ); + })} + + ); + })} + + {footer && ( + + + {footer} + + + )} +
    + ); +} diff --git a/packages/react/src/components/Table/SortIcon.tsx b/packages/react/src/components/Table/SortIcon.tsx new file mode 100644 index 0000000000..f2137b98d4 --- /dev/null +++ b/packages/react/src/components/Table/SortIcon.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from 'react'; +import React from 'react'; + +export const SortIcon = (props: SVGProps) => ( + + + +); diff --git a/packages/react/src/components/Table/Table.module.css b/packages/react/src/components/Table/Table.module.css new file mode 100644 index 0000000000..3a58cd432f --- /dev/null +++ b/packages/react/src/components/Table/Table.module.css @@ -0,0 +1,11 @@ +.table { + align-self: stretch; + background-color: #ffffff; + border-collapse: collapse; + border-spacing: 0; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.12); + flex-grow: 0; + flex: none; + order: 3; + width: 100%; +} diff --git a/packages/react/src/components/Table/Table.stories.mdx b/packages/react/src/components/Table/Table.stories.mdx new file mode 100644 index 0000000000..01c707ed28 --- /dev/null +++ b/packages/react/src/components/Table/Table.stories.mdx @@ -0,0 +1,70 @@ +import {ArgsTable, Canvas, Meta, Story} from "@storybook/addon-docs"; +import {Table, TableBody, TableCell, TableFooter, TableHeader, TableRow} from "./"; +import {TokensTable} from "../../../../../docs-components"; +import {exampleRows} from "./ExampleData"; +import classes from "./Table.stories.module.css"; + + + +export const Template = (args) => ( + + + + Søknadsnr. + Produkt + Status + Bilde + + + + {exampleRows.map((row) => ( + + {row.caseNum} + {row.product} + {row.status} + + {row.image.alt} + + + ))} + + + + + Lorem ipsum dolor sit amet. + + + +
    +); + +# Table + +## Eksempel + + + + {Template.bind({})} + + + + +## Egenskaper + + +## Tokens + \ No newline at end of file diff --git a/packages/react/src/components/Table/Table.stories.module.css b/packages/react/src/components/Table/Table.stories.module.css new file mode 100644 index 0000000000..4e386ebe17 --- /dev/null +++ b/packages/react/src/components/Table/Table.stories.module.css @@ -0,0 +1,4 @@ +.img { + max-height: 45px; + max-width: 45px; +} diff --git a/packages/react/src/components/Table/Table.test.tsx b/packages/react/src/components/Table/Table.test.tsx new file mode 100644 index 0000000000..8b9195cd24 --- /dev/null +++ b/packages/react/src/components/Table/Table.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render as renderRtl, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { TableProps } from './Table'; +import { Table } from './Table'; +import { TableBody } from './TableBody'; +import { TableCell } from './TableCell'; +import { TableHeader } from './TableHeader'; +import { TableRow } from './TableRow'; + +interface TestRow { + fruit: string; +} + +const render = (props: Partial> = {}) => { + const allProps: TableProps = { + children: ( + <> + + + Frukt + + + + + Apple + + + Orange + + + + ), + onChange: jest.fn(), + selectRows: true, + selectedValue: { fruit: '' }, + ...props, + }; + renderRtl(); +}; + +const user = userEvent.setup(); + +describe('Table', () => { + it('Calls onChange with correct selectedValue when TableRow is clicked and selectRows is true', async () => { + const onChange = jest.fn(); + render({ onChange, selectRows: true }); + await user.click(screen.getByText('Apple')); + expect(onChange).toHaveBeenCalledWith({ + selectedValue: { fruit: 'apple' }, + }); + }); + + it('Does not call onChange when when selectRows is false and TableRow is clicked', async () => { + const onChange = jest.fn(); + render({ onChange, selectRows: false }); + await user.click(screen.getByText('Apple')); + expect(onChange).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/react/src/components/Table/Table.tsx b/packages/react/src/components/Table/Table.tsx new file mode 100644 index 0000000000..e2270b102b --- /dev/null +++ b/packages/react/src/components/Table/Table.tsx @@ -0,0 +1,35 @@ +import type { HTMLProps } from 'react'; +import React from 'react'; +import cn from 'classnames'; + +import classes from './Table.module.css'; +import type { ChangeHandler, TableContextType } from './utils'; +import { TableContext } from './utils'; + +export interface TableProps + extends Omit, 'onChange'> { + children?: React.ReactNode; + selectRows?: boolean; + onChange?: ChangeHandler; + selectedValue?: T; +} + +export function Table({ + children, + selectRows = false, + onChange, + selectedValue, + className, + ...tableProps +}: TableProps) { + const context: TableContextType = { selectRows, onChange, selectedValue }; + + return ( +
    + {children} +
    + ); +} diff --git a/packages/react/src/components/Table/TableBody.module.css b/packages/react/src/components/Table/TableBody.module.css new file mode 100644 index 0000000000..a369c81f50 --- /dev/null +++ b/packages/react/src/components/Table/TableBody.module.css @@ -0,0 +1,7 @@ +.tableBody { + align-self: stretch; + background-color: #ffff; + flex-grow: 0; + flex: none; + order: 2; +} diff --git a/packages/react/src/components/Table/TableBody.tsx b/packages/react/src/components/Table/TableBody.tsx new file mode 100644 index 0000000000..77d0923492 --- /dev/null +++ b/packages/react/src/components/Table/TableBody.tsx @@ -0,0 +1,28 @@ +import type { HTMLProps } from 'react'; +import React from 'react'; +import cn from 'classnames'; + +import classes from './TableBody.module.css'; +import { TableRowTypeContext } from './utils'; + +export interface TableBodyProps extends HTMLProps { + children?: React.ReactNode; +} + +export const TableBody = ({ + children, + className, + ...tableBodyProps +}: TableBodyProps) => { + const variantStandard = 'body'; + return ( + + + {children} + + + ); +}; diff --git a/packages/react/src/components/Table/TableCell.module.css b/packages/react/src/components/Table/TableCell.module.css new file mode 100644 index 0000000000..a814b3d68a --- /dev/null +++ b/packages/react/src/components/Table/TableCell.module.css @@ -0,0 +1,77 @@ +.headerTableCell { + background: #f5f5f5; + margin: 20px 0 20px 0; + padding: 8px; + text-align: left; + user-select: none; +} + +.headerTableCellRadiobutton { + background: #f5f5f5; + margin: 10px 0 10px 0; + padding: 8px; + text-align: left; + user-select: none; +} + +.bodyTableCell { + border-bottom: 1px solid #dde3e5; + border-top: 1px solid #dde3e5; + border-top: 1px solid #dde3e5; + margin: 20px 0 20px 0; + max-width: 300px; + text-align: left; +} + +.bodyTableCellRadiobutton { + border-bottom: 1px solid #dde3e5; + border-top: 1px solid #dde3e5; + border-top: 1px solid #dde3e5; + text-align: left; +} + +.image { + max-height: 45px; + max-width: 45px; +} + +.input { + margin: 0 15px 0 15px; +} +.radioButton { + margin: 0 0 0 15px; +} + +.containerSortable { + align-items: center; + cursor: pointer; + display: flex; + flex-direction: row; + justify-content: flex-start; +} + +.icon { + fill: rgba(0, 0, 0, 0.4); + margin-left: -5px; +} + +.iconDesc { + fill: rgb(0, 0, 0); + margin-left: -5px; +} + +.iconAsc { + fill: rgb(0, 0, 0); + margin-left: -5px; + transform: rotate(180deg); +} + +.header { + color: #4b5563; + font-weight: 600; + margin: 10px 10px 10px 0; +} + +.property { + margin: 10px 10px 10px 0; +} diff --git a/packages/react/src/components/Table/TableCell.tsx b/packages/react/src/components/Table/TableCell.tsx new file mode 100644 index 0000000000..38415d5008 --- /dev/null +++ b/packages/react/src/components/Table/TableCell.tsx @@ -0,0 +1,124 @@ +import type { HTMLProps } from 'react'; +import React from 'react'; +import cn from 'classnames'; + +import classes from './TableCell.module.css'; +import type { SortHandler, Variant, SortDirection } from './utils'; +import { useTableRowTypeContext } from './utils'; +import { SortIcon } from './SortIcon'; + +export interface TableCellProps + extends Omit, 'onChange'> { + children?: React.ReactNode; + variant?: string; + onChange?: SortHandler; + sortDirection?: SortDirection; + radiobutton?: boolean; +} + +export function TableCell({ + children, + variant, + onChange, + sortDirection = 'notSortable', + className, + radiobutton = false, // Todo: This only sets a class, but relies on the consumer to provide the actual radiobutton. This should either be handled entirely within this component, or we should give this property a more generic name. + ...tableCellProps +}: TableCellProps) { + const { variantStandard } = useTableRowTypeContext(); + + const isVariant = (checkIf: Variant): boolean => { + if (variant === undefined) { + return variantStandard === checkIf; + } + return variant === checkIf; + }; + + const handleChange = () => { + if ( + onChange != undefined && + sortDirection != undefined && + sortDirection != 'notSortable' + ) { + // Todo: Here we rely on the consumer to sort the rows based on the sortDirection. We should handle this within the component, with an optional possibility to pass in a custom compare function. + onChange({ + next: sortDirection === 'desc' ? 'asc' : 'desc', + previous: sortDirection, + }); + } + }; + + return ( + <> + {isVariant('header') && ( + +
    handleChange()} + onKeyUp={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + handleChange(); + } + }} + role={sortDirection != 'notSortable' ? 'button' : undefined} + tabIndex={sortDirection != 'notSortable' ? 0 : undefined} + > +
    {children}
    + {sortDirection != 'notSortable' && ( + + )} +
    + + )} + {isVariant('body') && ( + +
    + {children} +
    + + )} + {isVariant('footer') && ( + +
    {children}
    + + )} + + ); +} diff --git a/packages/react/src/components/Table/TableFooter.module.css b/packages/react/src/components/Table/TableFooter.module.css new file mode 100644 index 0000000000..de313dda59 --- /dev/null +++ b/packages/react/src/components/Table/TableFooter.module.css @@ -0,0 +1,7 @@ +.tableFooter { + align-self: stretch; + background: #f5f5f5; + flex-grow: 0; + flex: none; + order: 1; +} diff --git a/packages/react/src/components/Table/TableFooter.tsx b/packages/react/src/components/Table/TableFooter.tsx new file mode 100644 index 0000000000..ae7d14b0ff --- /dev/null +++ b/packages/react/src/components/Table/TableFooter.tsx @@ -0,0 +1,28 @@ +import type { HTMLProps } from 'react'; +import React from 'react'; +import cn from 'classnames'; + +import classes from './TableFooter.module.css'; +import { TableRowTypeContext } from './utils'; + +export interface TableFooterProps extends HTMLProps { + children?: React.ReactNode; +} + +export const TableFooter = ({ + children, + className, + ...tableFooterProps +}: TableFooterProps) => { + const variantStandard = 'footer'; + return ( + + + {children} + + + ); +}; diff --git a/packages/react/src/components/Table/TableHeader.module.css b/packages/react/src/components/Table/TableHeader.module.css new file mode 100644 index 0000000000..570d08d724 --- /dev/null +++ b/packages/react/src/components/Table/TableHeader.module.css @@ -0,0 +1,7 @@ +.table-header { + align-self: stretch; + background: #f5f5f5; + flex-grow: 0; + flex: none; + order: 1; +} diff --git a/packages/react/src/components/Table/TableHeader.tsx b/packages/react/src/components/Table/TableHeader.tsx new file mode 100644 index 0000000000..72ccd90a5c --- /dev/null +++ b/packages/react/src/components/Table/TableHeader.tsx @@ -0,0 +1,28 @@ +import type { HTMLProps } from 'react'; +import React from 'react'; +import cn from 'classnames'; + +import classes from './TableHeader.module.css'; +import { TableRowTypeContext } from './utils'; + +export interface TableHeaderProps extends HTMLProps { + children?: React.ReactNode; +} + +export const TableHeader = ({ + children, + className, + ...tableHeaderProps +}: TableHeaderProps) => { + const variantStandard = 'header'; + return ( + + + {children} + + + ); +}; diff --git a/packages/react/src/components/Table/TableRow.module.css b/packages/react/src/components/Table/TableRow.module.css new file mode 100644 index 0000000000..74d55e8bf8 --- /dev/null +++ b/packages/react/src/components/Table/TableRow.module.css @@ -0,0 +1,16 @@ +.tableRow { + height: 60px; + width: 1056px; +} + +.tableRow.selected { + background-color: #e0daf7; + border-bottom: 1px solid #dde3e5; + border-left: 2px solid #011728; + border-top: 1px solid #dde3e5; +} + +.tableRow.body:hover { + background-color: #e3f7ff; + cursor: pointer; +} diff --git a/packages/react/src/components/Table/TableRow.tsx b/packages/react/src/components/Table/TableRow.tsx new file mode 100644 index 0000000000..6dc4e933cc --- /dev/null +++ b/packages/react/src/components/Table/TableRow.tsx @@ -0,0 +1,57 @@ +import type { HTMLProps } from 'react'; +import React from 'react'; +import cn from 'classnames'; + +import classes from './TableRow.module.css'; +import { useTableContext, useTableRowTypeContext } from './utils'; + +export interface TableRowProps + extends Omit< + HTMLProps, + 'onClick' | 'tabIndex' | 'onKeyUp' + > { + children?: React.ReactNode; + rowData?: T; +} + +export function TableRow({ + children, + rowData, + className, + ...tableRowProps +}: TableRowProps) { + const { variantStandard } = useTableRowTypeContext(); + const { onChange, selectedValue, selectRows } = useTableContext(); + const handleClick = () => { + if ( + onChange != undefined && + selectRows && + variantStandard === 'body' && + rowData + ) { + onChange({ selectedValue: rowData }); + } + }; + const isSelected = + selectRows && + typeof rowData !== 'undefined' && + JSON.stringify(rowData) === JSON.stringify(selectedValue); // Todo: Find a cleaner way to define a selected row. + + return ( + + {children} + + ); +} diff --git a/packages/react/src/components/Table/index.ts b/packages/react/src/components/Table/index.ts new file mode 100644 index 0000000000..8489bbe940 --- /dev/null +++ b/packages/react/src/components/Table/index.ts @@ -0,0 +1,18 @@ +export type { ChangeProps, SortDirection, SortProps } from './utils'; +export type { + ResponsiveTableProps, + ResponsiveTableConfig, +} from './ResponsiveTable'; +export type { TableBodyProps } from './TableBody'; +export type { TableCellProps } from './TableCell'; +export type { TableFooterProps } from './TableFooter'; +export type { TableHeaderProps } from './TableHeader'; +export type { TableProps } from './Table'; +export type { TableRowProps } from './TableRow'; +export { ResponsiveTable } from './ResponsiveTable'; +export { Table } from './Table'; +export { TableBody } from './TableBody'; +export { TableCell } from './TableCell'; +export { TableFooter } from './TableFooter'; +export { TableHeader } from './TableHeader'; +export { TableRow } from './TableRow'; diff --git a/packages/react/src/components/Table/utils.ts b/packages/react/src/components/Table/utils.ts new file mode 100644 index 0000000000..cb57ac0eff --- /dev/null +++ b/packages/react/src/components/Table/utils.ts @@ -0,0 +1,48 @@ +import { createContext, useContext } from 'react'; + +export type Variant = 'header' | 'body' | 'footer'; + +export type SortDirection = 'asc' | 'desc' | 'notSortable' | 'notActive'; + +export interface ChangeProps { + selectedValue: T; +} + +export interface SortProps { + next: SortDirection; + previous: SortDirection; +} + +export type ChangeHandler = ({ selectedValue }: ChangeProps) => void; +export type SortHandler = ({ previous }: SortProps) => void; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface TableContextType { + selectRows?: boolean; + selectedValue?: T; + onChange?: ChangeHandler; +} + +export const TableContext = createContext( + // The first parameter is required, but we handle this by throwing an error in useTableContext instead. + undefined as unknown as TableContextType, +); + +export function useTableContext() { + const context = useContext>(TableContext); + if (context === undefined) { + throw new Error('useTableContext must be used within a TableContext'); + } + return context; +} + +export const TableRowTypeContext = createContext({ + variantStandard: 'body', +}); +export const useTableRowTypeContext = () => { + const context = useContext(TableRowTypeContext); + if (context === undefined) { + throw new Error('useTableContext must be used within a TableTypeContext'); + } + return context; +}; diff --git a/packages/react/src/components/TextArea/TextArea.test.tsx b/packages/react/src/components/TextArea/TextArea.test.tsx index 506a1c2018..c40604b4cf 100644 --- a/packages/react/src/components/TextArea/TextArea.test.tsx +++ b/packages/react/src/components/TextArea/TextArea.test.tsx @@ -8,7 +8,6 @@ import type { TextAreaProps } from './TextArea'; const user = userEvent.setup(); describe('TextArea', () => { - it('Triggers onPaste when pasting into input', async () => { const onPaste = jest.fn(); const data = 'Hello world'; @@ -18,11 +17,13 @@ describe('TextArea', () => { await user.paste(data); //fireEvent(element, paste); expect(onPaste).toHaveBeenCalledTimes(1); - expect(onPaste).toHaveBeenCalledWith(expect.objectContaining({ - clipboardData: expect.objectContaining({ - items: [expect.objectContaining({ data })] - }) - })); + expect(onPaste).toHaveBeenCalledWith( + expect.objectContaining({ + clipboardData: expect.objectContaining({ + items: [expect.objectContaining({ data })], + }), + }), + ); }); it('Triggers onBlur event when field loses focus', async () => { @@ -55,4 +56,10 @@ describe('TextArea', () => { }); const render = (props: Partial = {}) => - renderRtl(