diff --git a/change/@fluentui-react-examples-ee4c87f1-4801-40b7-8c39-fbd92ec8ca6d.json b/change/@fluentui-react-examples-ee4c87f1-4801-40b7-8c39-fbd92ec8ca6d.json new file mode 100644 index 00000000000000..8cb7fd57475d23 --- /dev/null +++ b/change/@fluentui-react-examples-ee4c87f1-4801-40b7-8c39-fbd92ec8ca6d.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Add checkbox story for Menu", + "packageName": "@fluentui/react-examples", + "email": "lingfan.gao@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-menu-04281faf-a371-42db-8ee1-6ec9abe65a84.json b/change/@fluentui-react-menu-04281faf-a371-42db-8ee1-6ec9abe65a84.json new file mode 100644 index 00000000000000..854ca452f5545b --- /dev/null +++ b/change/@fluentui-react-menu-04281faf-a371-42db-8ee1-6ec9abe65a84.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add checkbox implementation for menu item", + "packageName": "@fluentui/react-menu", + "email": "lingfan.gao@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/package.json b/package.json index d140ee476cb388..d7feb039f9307c 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,8 @@ "@types/react": "16.9.42", "@types/react-dom": "16.9.10", "eslint": "^7.1.0", + "//": "pretty-format contains typing only supported by TS 3.8+ remove when support in this repo is available", + "@testing-library/dom": "7.22.3", "copy-to-clipboard": "3.2.0" }, "syncpack": { diff --git a/packages/react-examples/.storybook/preview.js b/packages/react-examples/.storybook/preview.js index a8768cb4ca56d8..da72a46a81046e 100644 --- a/packages/react-examples/.storybook/preview.js +++ b/packages/react-examples/.storybook/preview.js @@ -6,7 +6,7 @@ import { withInfo } from '@storybook/addon-info'; import { withA11y } from '@storybook/addon-a11y'; import { withKnobs } from '@storybook/addon-knobs'; import { withPerformance } from 'storybook-addon-performance'; -import { withKeytipLayer, withStrictMode, withCompatThemeProvider } from '@fluentui/storybook'; +import { withKeytipLayer, withStrictMode, withCompatThemeProvider, withFluentProvider } from '@fluentui/storybook'; addDecorator(withPerformance); addDecorator(withInfo()); @@ -29,6 +29,10 @@ if ( addDecorator(withCompatThemeProvider); addDecorator(withStrictMode); } +if (['react-menu'].includes('PACKAGE_NAME')) { + addDecorator(withFluentProvider); + addDecorator(withStrictMode); +} addParameters({ a11y: { diff --git a/packages/react-examples/src/react-menu/MenuList/MenuList.stories.tsx b/packages/react-examples/src/react-menu/MenuList/MenuList.stories.tsx index d1d7ea2fb82c3a..5337520034297d 100644 --- a/packages/react-examples/src/react-menu/MenuList/MenuList.stories.tsx +++ b/packages/react-examples/src/react-menu/MenuList/MenuList.stories.tsx @@ -1,9 +1,7 @@ import * as React from 'react'; -import { MenuList, MenuItem } from '@fluentui/react-menu'; -import { teamsLightTheme } from '@fluentui/react-theme'; -import { FluentProvider } from '@fluentui/react-provider'; -import { CutIcon, PasteIcon, EditIcon } from '@fluentui/react-icons-mdl2'; +import { MenuList, MenuItem, MenuItemCheckbox, MenuItemRadio } from '@fluentui/react-menu'; +import { CutIcon, PasteIcon, EditIcon, AcceptIcon } from '@fluentui/react-icons-mdl2'; import { makeStyles } from '@fluentui/react-make-styles'; const useContainerStyles = makeStyles([ @@ -27,25 +25,69 @@ const Container: React.FC = props => { }; export const MenuListExample = () => ( - + + + Item + Item + Item + + +); + +export const MenuListWithIconsExample = () => ( + + + }>Item + }>Item + }>Item + + +); + +export const MenuListWithCheckboxes = () => { + const checkmark = ; + const [checkedValues, setCheckedValues] = React.useState>({ checkbox: ['2'] }); + const onChange = (e: React.SyntheticEvent, name: string, items: string[]) => { + setCheckedValues(s => ({ ...s, [name]: items })); + }; + + return ( - - Item - Item - Item + + } name="checkbox" value="1" checkmark={checkmark}> + Item + + } name="checkbox" value="2" checkmark={checkmark}> + Item + + } name="checkbox" value="3" checkmark={checkmark}> + Item + - -); + ); +}; -export const MenuListWithIconsExample = () => ( - +export const MenuListWithRadios = () => { + const checkmark = ; + const [checkedValues, setCheckedValues] = React.useState>({ checkbox: ['2'] }); + const onChange = (e: React.SyntheticEvent, name: string, items: string[]) => { + setCheckedValues(s => ({ ...s, [name]: items })); + }; + + return ( - - }>Item - }>Item - }>Item + + } name="checkbox" value="1" checkmark={checkmark}> + Item + + } name="checkbox" value="2" checkmark={checkmark}> + Item + + } name="checkbox" value="3" checkmark={checkmark}> + Item + - -); + ); +}; diff --git a/packages/react-menu/etc/react-menu.api.md b/packages/react-menu/etc/react-menu.api.md index c58325074a53b3..7a9fc42894849a 100644 --- a/packages/react-menu/etc/react-menu.api.md +++ b/packages/react-menu/etc/react-menu.api.md @@ -12,11 +12,57 @@ import { ShorthandProps } from '@fluentui/react-utils'; // @public export const MenuItem: React.ForwardRefExoticComponent>; +// @public +export const MenuItemCheckbox: React.ForwardRefExoticComponent>; + +// Warning: (ae-forgotten-export) The symbol "MenuItemSelectableProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface MenuItemCheckboxProps extends ComponentProps, React.HTMLAttributes, MenuItemProps, MenuItemSelectableProps { + checkmark?: ShorthandProps; + icon?: ShorthandProps; +} + +// @public +export const menuItemCheckboxShorthandProps: string[]; + +// Warning: (ae-forgotten-export) The symbol "MenuItemSelectableState" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface MenuItemCheckboxState extends MenuItemCheckboxProps, MenuItemState, MenuItemSelectableState { + checkmark: ObjectShorthandProps; + icon?: ObjectShorthandProps; + // (undocumented) + ref: React.MutableRefObject; +} + // @public (undocumented) export interface MenuItemProps extends ComponentProps, React.HTMLAttributes { icon?: ShorthandProps; } +// @public +export const MenuItemRadio: React.ForwardRefExoticComponent>; + +// @public (undocumented) +export interface MenuItemRadioProps extends ComponentProps, React.HTMLAttributes, MenuItemSelectableProps { + // (undocumented) + checkmark?: ShorthandProps; + // (undocumented) + icon?: ShorthandProps; +} + +// @public +export const menuItemRadioShorthandProps: string[]; + +// @public (undocumented) +export interface MenuItemRadioState extends MenuItemRadioProps, MenuItemSelectableState { + checkmark: ObjectShorthandProps; + icon?: ObjectShorthandProps; + // (undocumented) + ref: React.MutableRefObject; +} + // @public export const menuItemShorthandProps: string[]; @@ -31,6 +77,8 @@ export const MenuList: React.ForwardRefExoticComponent { + checkedValues?: Record; + onCheckedValueChange?: (e: React.MouseEvent | React.KeyboardEvent, name: string, checkedItems: string[]) => void; } // @public (undocumented) @@ -41,6 +89,12 @@ export interface MenuListState extends MenuListProps { // @public export const renderMenuItem: (state: MenuItemState) => JSX.Element; +// @public +export const renderMenuItemCheckbox: (state: MenuItemCheckboxState) => JSX.Element; + +// @public +export const renderMenuItemRadio: (state: MenuItemRadioState) => JSX.Element; + // @public export const renderMenuList: (state: MenuListState) => JSX.Element; @@ -50,6 +104,12 @@ export const useIconStyles: (selectors: MenuItemState) => string; // @public export const useMenuItem: (props: MenuItemProps, ref: React.Ref, defaultProps?: MenuItemProps | undefined) => MenuItemState; +// @public +export const useMenuItemCheckbox: (props: MenuItemCheckboxProps, ref: React.Ref, defaultProps?: MenuItemCheckboxProps | undefined) => MenuItemCheckboxState; + +// @public +export const useMenuItemRadio: (props: MenuItemRadioProps, ref: React.Ref, defaultProps?: MenuItemRadioProps | undefined) => MenuItemRadioState; + // @public export const useMenuItemStyles: (state: MenuItemState) => void; diff --git a/packages/react-menu/package.json b/packages/react-menu/package.json index 6775678b110335..7d66864919106c 100644 --- a/packages/react-menu/package.json +++ b/packages/react-menu/package.json @@ -26,9 +26,10 @@ "update-snapshots": "just-scripts jest -u" }, "devDependencies": { - "@fluentui/react-conformance": "^1.0.0", "@fluentui/eslint-plugin": "^1.0.0-beta.1", + "@fluentui/react-conformance": "^1.0.0", "@fluentui/scripts": "^1.0.0", + "@testing-library/react": "^10.4.9", "@types/enzyme": "3.10.3", "@types/enzyme-adapter-react-16": "1.0.3", "@types/jest": "~24.9.0", @@ -44,12 +45,14 @@ "react-test-renderer": "^16.3.0" }, "dependencies": { + "@fluentui/keyboard-key": "^0.2.13", "@fluentui/react-hooks": "^8.0.0-beta.11", "@fluentui/react-make-styles": "^0.2.5-0", "@fluentui/react-theme": "^0.3.1", "@fluentui/react-theme-provider": "^1.0.0-beta.22", "@fluentui/react-utils": "^0.3.1-0", "@fluentui/set-version": "^8.0.0-beta.1", + "@testing-library/react-hooks": "^5.0.3", "tslib": "^1.10.0" }, "peerDependencies": { diff --git a/packages/react-menu/src/MenuItemCheckbox.ts b/packages/react-menu/src/MenuItemCheckbox.ts new file mode 100644 index 00000000000000..065d712fca8850 --- /dev/null +++ b/packages/react-menu/src/MenuItemCheckbox.ts @@ -0,0 +1 @@ +export * from './components/MenuItemCheckbox/index'; diff --git a/packages/react-menu/src/MenuItemRadio.ts b/packages/react-menu/src/MenuItemRadio.ts new file mode 100644 index 00000000000000..99e28d486ca724 --- /dev/null +++ b/packages/react-menu/src/MenuItemRadio.ts @@ -0,0 +1 @@ +export * from './components/MenuItemRadio/index'; diff --git a/packages/react-menu/src/components/MenuItem/useMenuItemStyles.ts b/packages/react-menu/src/components/MenuItem/useMenuItemStyles.ts index 510e8262614242..bfb4f026f92893 100644 --- a/packages/react-menu/src/components/MenuItem/useMenuItemStyles.ts +++ b/packages/react-menu/src/components/MenuItem/useMenuItemStyles.ts @@ -19,10 +19,12 @@ export const useRootStyles = makeStyles([ ':hover': { backgroundColor: theme.alias.color.neutral.neutralBackground1Hover, + color: theme.alias.color.neutral.neutralForeground2Hover, }, ':focus': { backgroundColor: theme.alias.color.neutral.neutralBackground1Hover, + color: theme.alias.color.neutral.neutralForeground2Hover, }, }), ], diff --git a/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.test.tsx b/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.test.tsx new file mode 100644 index 00000000000000..e4480a96ba0f4f --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.test.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import { render, fireEvent } from '@testing-library/react'; +import { ReactWrapper } from 'enzyme'; +import { isConformant } from '../../common/isConformant'; +import { MenuItemCheckbox } from './MenuItemCheckbox'; +import { MenuListContext, MenuListProvider } from '../../menuListContext'; + +describe('MenuItemCheckbox conformance', () => { + isConformant({ + asPropHandlesRef: true, + // TODO fix generics in conformance + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + Component: MenuItemCheckbox, + requiredProps: { + name: 'checkbox', + value: '1', + }, + displayName: 'MenuItemCheckbox', + }); + + let wrapper: ReactWrapper | undefined; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } + }); + + /** + * Note: see more visual regression tests for MenuItemCheckbox in /apps/vr-tests. + */ + it('renders a default state', () => { + const component = renderer.create( + + Default MenuItemCheckbox + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('MenuItemCheckbox', () => { + const TestMenuListContext = (props: { children: React.ReactNode; context?: Partial }) => { + const contextValue: MenuListContext = { + checkedValues: {}, + onCheckedValueChange: jest.fn(), + ...(props.context && props.context), + }; + + return {props.children}; + }; + + it('should render checkmark slot if checked', () => { + // Arrange + const checkedValues = { test: ['1'] }; + const checkmark = 'xxx'; + const { getByText } = render( + + + Checkbox + + , + ); + + // Assert + expect(getByText(checkmark)).not.toBeNull(); + }); + + it('should render icon slot', () => { + // Arrange + const icon = 'xxx'; + const { getByText } = render( + + + Checkbox + + , + ); + + // Assert + expect(getByText(icon)).not.toBeNull(); + }); + + it('should set aria-checked value to true if value is checked', () => { + // Arrange + const checkedValues = { test: ['1'] }; + const checkmark = 'xxx'; + const { container } = render( + + + Checkbox + + , + ); + + // Assert + expect(container.querySelector('[role="menuitemcheckbox"]')?.getAttribute('aria-checked')).toEqual('true'); + }); + + it.each([ + ['uncheck', ['1'], []], + ['check', [], ['1']], + ])('should %s checkbox on click', (_, checkedItems, expectedResult) => { + // Arrange + const checkboxName = 'name'; + const checkedValues = { [checkboxName]: checkedItems }; + const spy = jest.fn(); + const { container } = render( + + + Checkbox + + , + ); + + // Act + const menuitem = container.querySelector('[role="menuitemcheckbox"]'); + menuitem && fireEvent.click(menuitem); + + // Assert + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.anything(), checkboxName, [...expectedResult]); + }); +}); diff --git a/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.tsx b/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.tsx new file mode 100644 index 00000000000000..f0cf5e331424db --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { useMenuItemCheckbox } from './useMenuItemCheckbox'; +import { MenuItemCheckboxProps } from './MenuItemCheckbox.types'; +import { renderMenuItemCheckbox } from './renderMenuItemCheckbox'; +import { useMenuItemCheckBoxStyles } from './useMenuItemCheckboxStyles'; + +/** + * Define a styled MenuItemCheckbox, using the `useMenuItemCheckbox` hook. + * {@docCategory MenuItemCheckbox} + */ +export const MenuItemCheckbox = React.forwardRef((props, ref) => { + const state = useMenuItemCheckbox(props, ref); + useMenuItemCheckBoxStyles(state); + + return renderMenuItemCheckbox(state); +}); + +MenuItemCheckbox.displayName = 'MenuItemCheckbox'; diff --git a/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.types.ts b/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.types.ts new file mode 100644 index 00000000000000..962b9cd9222570 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/MenuItemCheckbox.types.ts @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { ComponentProps, ObjectShorthandProps, ShorthandProps } from '@fluentui/react-utils'; +import { MenuItemSelectableProps, MenuItemSelectableState } from '../../selectable/index'; +import { MenuItemProps, MenuItemState } from '../MenuItem/MenuItem.types'; + +/** + * {@docCategory MenuItemCheckbox} + */ +export interface MenuItemCheckboxProps + extends ComponentProps, + React.HTMLAttributes, + MenuItemProps, + MenuItemSelectableProps { + /** + * Icon slot rendered before children content + */ + icon?: ShorthandProps; + + /** + * Slot for the checkmark indicator + */ + checkmark?: ShorthandProps; +} + +/** + * {@docCategory MenuItemCheckbox} + */ +export interface MenuItemCheckboxState extends MenuItemCheckboxProps, MenuItemState, MenuItemSelectableState { + ref: React.MutableRefObject; + + /** + * Icon slot rendered before children content + */ + icon?: ObjectShorthandProps; + + /** + * Slot for the checkmark indicator + */ + checkmark: ObjectShorthandProps; +} diff --git a/packages/react-menu/src/components/MenuItemCheckbox/__snapshots__/MenuItemCheckbox.test.tsx.snap b/packages/react-menu/src/components/MenuItemCheckbox/__snapshots__/MenuItemCheckbox.test.tsx.snap new file mode 100644 index 00000000000000..057e6021816346 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/__snapshots__/MenuItemCheckbox.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuItemCheckbox conformance renders a default state 1`] = ` +
+ Default MenuItemCheckbox +
+`; diff --git a/packages/react-menu/src/components/MenuItemCheckbox/index.ts b/packages/react-menu/src/components/MenuItemCheckbox/index.ts new file mode 100644 index 00000000000000..fd28717014386b --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/index.ts @@ -0,0 +1,4 @@ +export * from './MenuItemCheckbox.types'; +export * from './MenuItemCheckbox'; +export * from './renderMenuItemCheckbox'; +export * from './useMenuItemCheckbox'; diff --git a/packages/react-menu/src/components/MenuItemCheckbox/renderMenuItemCheckbox.tsx b/packages/react-menu/src/components/MenuItemCheckbox/renderMenuItemCheckbox.tsx new file mode 100644 index 00000000000000..7f92d005aee9b1 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/renderMenuItemCheckbox.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utils'; +import { MenuItemCheckboxState } from './MenuItemCheckbox.types'; +import { menuItemCheckboxShorthandProps } from './useMenuItemCheckbox'; + +/** Function that renders the final JSX of the component */ +export const renderMenuItemCheckbox = (state: MenuItemCheckboxState) => { + const { slots, slotProps } = getSlots(state, menuItemCheckboxShorthandProps); + + return ( + + + + {state.children} + + ); +}; diff --git a/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckbox.ts b/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckbox.ts new file mode 100644 index 00000000000000..c727db650e96ae --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckbox.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { makeMergeProps, resolveShorthandProps } from '@fluentui/react-utils'; +import { MenuItemCheckboxProps, MenuItemCheckboxState } from './MenuItemCheckbox.types'; +import { useMenuItemSelectable } from '../../selectable/index'; +import { useMergedRefs } from '@fluentui/react-hooks'; + +/** + * Consts listing which props are shorthand props. + */ +export const menuItemCheckboxShorthandProps = ['icon', 'checkmark']; + +const mergeProps = makeMergeProps({ deepMerge: menuItemCheckboxShorthandProps }); + +/** Returns the props and state required to render the component */ +export const useMenuItemCheckbox = ( + props: MenuItemCheckboxProps, + ref: React.Ref, + defaultProps?: MenuItemCheckboxProps, +): MenuItemCheckboxState => { + const state = mergeProps( + { + ref: useMergedRefs(ref, React.useRef(null)), + icon: { as: 'span' }, + checkmark: { as: 'span' }, + role: 'menuitemcheckbox', + tabIndex: 0, + }, + defaultProps, + resolveShorthandProps(props, menuItemCheckboxShorthandProps), + ); + + useMenuItemSelectable(state, () => { + const newCheckedItems = [...state.checkedItems]; + const index = state.checkedItems.indexOf(state.value); + if (index !== -1) { + newCheckedItems.splice(index, 1); + } else { + newCheckedItems.push(state.value); + } + + return newCheckedItems; + }); + + return state; +}; diff --git a/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckboxStyles.ts b/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckboxStyles.ts new file mode 100644 index 00000000000000..3e6fab28b06cd6 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckboxStyles.ts @@ -0,0 +1,8 @@ +import { useCheckmarkStyles } from '../../selectable/index'; +import { useMenuItemStyles } from '../MenuItem/useMenuItemStyles'; +import { MenuItemCheckboxState } from './MenuItemCheckbox.types'; + +export const useMenuItemCheckBoxStyles = (state: MenuItemCheckboxState) => { + useMenuItemStyles(state); + useCheckmarkStyles(state); +}; diff --git a/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.test.tsx b/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.test.tsx new file mode 100644 index 00000000000000..354916b4d0734a --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.test.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import { render, fireEvent } from '@testing-library/react'; +import { MenuItemRadio } from './MenuItemRadio'; +import { ReactWrapper } from 'enzyme'; +import { isConformant } from '../../common/isConformant'; +import { MenuListContext, MenuListProvider } from '../../menuListContext'; + +describe('MenuItemRadio', () => { + isConformant({ + asPropHandlesRef: true, + // TODO fix generics in conformance + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + Component: MenuItemRadio, + requiredProps: { + name: 'radio', + value: '1', + }, + displayName: 'MenuItemRadio', + }); + + let wrapper: ReactWrapper | undefined; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } + }); + + /** + * Note: see more visual regression tests for MenuItemRadio in /apps/vr-tests. + */ + it('renders a default state', () => { + const component = renderer.create( + + Default MenuItemRadio + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('MenuItemRadio', () => { + const TestMenuListContext = (props: { children: React.ReactNode; context?: Partial }) => { + const contextValue: MenuListContext = { + checkedValues: {}, + onCheckedValueChange: jest.fn(), + ...(props.context && props.context), + }; + + return {props.children}; + }; + + it('should render checkmark slot if checked', () => { + // Arrange + const checkedValues = { test: ['1'] }; + const checkmark = 'xxx'; + const { getByText } = render( + + + Radio + + , + ); + + // Assert + expect(getByText(checkmark)).not.toBeNull(); + }); + + it('should render icon slot', () => { + // Arrange + const icon = 'xxx'; + const { getByText } = render( + + + Radio + + , + ); + + // Assert + expect(getByText(icon)).not.toBeNull(); + }); + + it('should set aria-checked value to true if value is checked', () => { + // Arrange + const checkedValues = { test: ['1'] }; + const { container } = render( + + + Radio + + , + ); + + // Assert + expect(container.querySelector('[role="menuitemradio"]')?.getAttribute('aria-checked')).toEqual('true'); + }); + + it('should check radio on click', () => { + // Arrange + const radioName = 'name'; + const radioValue = '1'; + const checkedValues = { [radioName]: [] }; + const spy = jest.fn(); + const { container } = render( + + + Radio + + , + ); + + // Act + const menuitem = container.querySelector('[role="menuitemradio"]'); + menuitem && fireEvent.click(menuitem); + + // Assert + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.anything(), radioName, [radioValue]); + }); + + it('should uncheck other radio on click', () => { + // Arrange + const radioName = 'name'; + const radioValue = '1'; + const checkedValues = { [radioName]: ['2'] }; + const spy = jest.fn(); + const { container } = render( + + + Radio + + , + ); + + // Act + const menuitem = container.querySelector('[role="menuitemradio"]'); + menuitem && fireEvent.click(menuitem); + + // Assert + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.anything(), radioName, [radioValue]); + }); +}); diff --git a/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.tsx b/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.tsx new file mode 100644 index 00000000000000..b3eed52b5186b6 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { useMenuItemRadio } from './useMenuItemRadio'; +import { MenuItemRadioProps } from './MenuItemRadio.types'; +import { renderMenuItemRadio } from './renderMenuItemRadio'; +import { useMenuItemRadioStyles } from './useMenuItemRadioStyles'; + +/** + * Define a styled MenuItemRadio, using the `useMenuItemRadio` hook. + * {@docCategory MenuItemRadio} + */ +export const MenuItemRadio = React.forwardRef((props, ref) => { + const state = useMenuItemRadio(props, ref); + useMenuItemRadioStyles(state); + + return renderMenuItemRadio(state); +}); + +MenuItemRadio.displayName = 'MenuItemRadio'; diff --git a/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.types.ts b/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.types.ts new file mode 100644 index 00000000000000..29350e6067aea0 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/MenuItemRadio.types.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { ComponentProps, ObjectShorthandProps, ShorthandProps } from '@fluentui/react-utils'; +import { MenuItemSelectableProps, MenuItemSelectableState } from '../../selectable/index'; + +/** + * {@docCategory MenuItemRadio} + */ +export interface MenuItemRadioProps extends ComponentProps, React.HTMLAttributes, MenuItemSelectableProps { + icon?: ShorthandProps; + + checkmark?: ShorthandProps; +} + +/** + * {@docCategory MenuItemRadio} + */ +export interface MenuItemRadioState extends MenuItemRadioProps, MenuItemSelectableState { + ref: React.MutableRefObject; + + /** + * Icon slot rendered before children content + */ + icon?: ObjectShorthandProps; + + /** + * Slot for the checkmark indicator + */ + checkmark: ObjectShorthandProps; +} diff --git a/packages/react-menu/src/components/MenuItemRadio/__snapshots__/MenuItemRadio.test.tsx.snap b/packages/react-menu/src/components/MenuItemRadio/__snapshots__/MenuItemRadio.test.tsx.snap new file mode 100644 index 00000000000000..3c9c3a1ff007d7 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/__snapshots__/MenuItemRadio.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuItemRadio renders a default state 1`] = ` +
+ Default MenuItemRadio +
+`; diff --git a/packages/react-menu/src/components/MenuItemRadio/index.ts b/packages/react-menu/src/components/MenuItemRadio/index.ts new file mode 100644 index 00000000000000..36a208dd52a567 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/index.ts @@ -0,0 +1,4 @@ +export * from './MenuItemRadio.types'; +export * from './MenuItemRadio'; +export * from './renderMenuItemRadio'; +export * from './useMenuItemRadio'; diff --git a/packages/react-menu/src/components/MenuItemRadio/renderMenuItemRadio.tsx b/packages/react-menu/src/components/MenuItemRadio/renderMenuItemRadio.tsx new file mode 100644 index 00000000000000..d7d51de7e7244b --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/renderMenuItemRadio.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utils'; +import { MenuItemRadioState } from './MenuItemRadio.types'; +import { menuItemRadioShorthandProps } from './useMenuItemRadio'; + +/** + * Redefine the render function to add slots. Reuse the menuitemradio structure but add + * slots to children. + */ +export const renderMenuItemRadio = (state: MenuItemRadioState) => { + const { slots, slotProps } = getSlots(state, menuItemRadioShorthandProps); + + return ( + + + + {state.children} + + ); +}; diff --git a/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadio.ts b/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadio.ts new file mode 100644 index 00000000000000..cb37bda39dbbd1 --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadio.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { makeMergeProps, resolveShorthandProps } from '@fluentui/react-utils'; +import { MenuItemRadioProps, MenuItemRadioState } from './MenuItemRadio.types'; +import { useMenuItemSelectable } from '../../selectable/index'; +import { useMergedRefs } from '@fluentui/react-hooks'; + +/** + * Consts listing which props are shorthand props. + */ +export const menuItemRadioShorthandProps = ['icon', 'checkmark']; + +const mergeProps = makeMergeProps({ deepMerge: menuItemRadioShorthandProps }); + +/** + * Given user props, returns state and render function for a MenuItemRadio. + */ +export const useMenuItemRadio = ( + props: MenuItemRadioProps, + ref: React.Ref, + defaultProps?: MenuItemRadioProps, +): MenuItemRadioState => { + const state = mergeProps( + { + ref: useMergedRefs(ref, React.useRef(null)), + icon: { as: 'span' }, + checkmark: { as: 'span' }, + role: 'menuitemradio', + tabIndex: 0, + }, + defaultProps, + resolveShorthandProps(props, menuItemRadioShorthandProps), + ); + + useMenuItemSelectable(state, () => [state.value]); + return state; +}; diff --git a/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadioStyles.ts b/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadioStyles.ts new file mode 100644 index 00000000000000..d6a4d64c0fbacf --- /dev/null +++ b/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadioStyles.ts @@ -0,0 +1,8 @@ +import { useCheckmarkStyles } from '../../selectable/index'; +import { useMenuItemStyles } from '../MenuItem/useMenuItemStyles'; +import { MenuItemRadioState } from './MenuItemRadio.types'; + +export const useMenuItemRadioStyles = (state: MenuItemRadioState) => { + useMenuItemStyles(state); + useCheckmarkStyles(state); +}; diff --git a/packages/react-menu/src/components/MenuList/MenuList.types.ts b/packages/react-menu/src/components/MenuList/MenuList.types.ts index 97593059a04c95..e7ca45f8f9868b 100644 --- a/packages/react-menu/src/components/MenuList/MenuList.types.ts +++ b/packages/react-menu/src/components/MenuList/MenuList.types.ts @@ -1,7 +1,20 @@ import * as React from 'react'; import { ComponentProps } from '@fluentui/react-utils'; -export interface MenuListProps extends ComponentProps, React.HTMLAttributes {} +export interface MenuListProps extends ComponentProps, React.HTMLAttributes { + /** + * Callback when checked items change for value with a name + * + * @param name - the name of the value + * @param checkedItems - the items for this value that are checked + */ + onCheckedValueChange?: (e: React.MouseEvent | React.KeyboardEvent, name: string, checkedItems: string[]) => void; + + /** + * Map of all checked values + */ + checkedValues?: Record; +} export interface MenuListState extends MenuListProps { /** diff --git a/packages/react-menu/src/components/MenuList/renderMenuList.tsx b/packages/react-menu/src/components/MenuList/renderMenuList.tsx index aaff89c75a7a5b..c4ddfed71ff09f 100644 --- a/packages/react-menu/src/components/MenuList/renderMenuList.tsx +++ b/packages/react-menu/src/components/MenuList/renderMenuList.tsx @@ -1,12 +1,18 @@ import * as React from 'react'; import { getSlots } from '@fluentui/react-utils'; import { MenuListState } from './MenuList.types'; +import { MenuListProvider } from '../../menuListContext'; /** * Function that renders the final JSX of the component */ export const renderMenuList = (state: MenuListState) => { const { slots, slotProps } = getSlots(state); + const { onCheckedValueChange, checkedValues } = state; - return {state.children}; + return ( + + {state.children} + + ); }; diff --git a/packages/react-menu/src/index.ts b/packages/react-menu/src/index.ts index 7fdd4bfcbc7927..435ff83f407e7c 100644 --- a/packages/react-menu/src/index.ts +++ b/packages/react-menu/src/index.ts @@ -2,3 +2,5 @@ import './version'; export * from './MenuItem'; export * from './MenuList'; +export * from './MenuItemCheckbox'; +export * from './MenuItemRadio'; diff --git a/packages/react-menu/src/menuListContext.tsx b/packages/react-menu/src/menuListContext.tsx new file mode 100644 index 00000000000000..c71269a5c5d27b --- /dev/null +++ b/packages/react-menu/src/menuListContext.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +const MenuListContext = React.createContext({ + checkedValues: {}, + onCheckedValueChange: () => null, +}); + +// TODO add context selector to reduce the number of rerenders +export interface MenuListContext { + checkedValues?: Record; + onCheckedValueChange?: (e: React.MouseEvent | React.KeyboardEvent, name: string, items: string[]) => void; +} + +export const MenuListProvider = MenuListContext.Provider; + +export const useMenuListContext = () => React.useContext(MenuListContext); diff --git a/packages/react-menu/src/selectable/index.ts b/packages/react-menu/src/selectable/index.ts new file mode 100644 index 00000000000000..2a83872c4bb959 --- /dev/null +++ b/packages/react-menu/src/selectable/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './useMenuItemSelectable'; +export * from './useCheckmarkStyles'; diff --git a/packages/react-menu/src/selectable/types.ts b/packages/react-menu/src/selectable/types.ts new file mode 100644 index 00000000000000..96c227121c8d9a --- /dev/null +++ b/packages/react-menu/src/selectable/types.ts @@ -0,0 +1,38 @@ +import * as React from 'react'; + +/** + * Props for selecatble menu items + */ +export interface MenuItemSelectableProps extends React.HTMLAttributes { + /** + * Follows input convention + * https://www.w3schools.com/jsref/prop_checkbox_name.asp + */ + name: string; + + /** + * Follows input convention + * https://www.w3schools.com/jsref/prop_checkbox_value.asp + */ + value: string; +} + +/** + * State for selectable menu items + */ +export interface MenuItemSelectableState extends MenuItemSelectableProps { + /** + * Checked items for a value with `name` + */ + checkedItems: string[]; + + /** + * Callback when checked items changes for a given value with `name` + */ + onCheckedValueChange: (e: React.MouseEvent | React.KeyboardEvent, name: string, checkedItems: string[]) => void; + + /** + * Selectable is checked + */ + checked: boolean; +} diff --git a/packages/react-menu/src/selectable/useCheckmarkStyles.ts b/packages/react-menu/src/selectable/useCheckmarkStyles.ts new file mode 100644 index 00000000000000..d22c44551547c4 --- /dev/null +++ b/packages/react-menu/src/selectable/useCheckmarkStyles.ts @@ -0,0 +1,38 @@ +import { makeStyles, ax } from '@fluentui/react-make-styles'; +import { ObjectShorthandProps } from '@fluentui/react-utils'; +import { MenuItemSelectableState } from './types'; + +/** + * Style hook for checkmark icons + */ +const useStyles = makeStyles([ + [ + null, + () => ({ + width: '16px', + height: '16px', + marginRight: '9px', + visibility: 'hidden', + }), + ], + [ + state => state.checked, + () => ({ + visibility: 'visible', + }), + ], +]); + +/** + * Applies styles to a checkmark slot for selectable menu items + * + * @param state should contain a `checkmark` slot + */ +export const useCheckmarkStyles = ( + state: MenuItemSelectableState & { checkmark: ObjectShorthandProps }, +) => { + const checkmarkClassName = useStyles(state); + if (state.checkmark) { + state.checkmark.className = ax(checkmarkClassName, state.checkmark.className); + } +}; diff --git a/packages/react-menu/src/selectable/useMenuItemSelectable.test.ts b/packages/react-menu/src/selectable/useMenuItemSelectable.test.ts new file mode 100644 index 00000000000000..6f60882f7d9274 --- /dev/null +++ b/packages/react-menu/src/selectable/useMenuItemSelectable.test.ts @@ -0,0 +1,123 @@ +import { EnterKey, SpacebarKey } from '@fluentui/keyboard-key'; +import { renderHook } from '@testing-library/react-hooks'; +import { MenuItemSelectableState } from './types'; +import { useMenuItemSelectable } from './useMenuItemSelectable'; +import { useMenuListContext } from '../menuListContext'; + +jest.mock('../menuListContext'); + +describe('useMenuItemSelectable', () => { + const createTestState = (options: Partial = {}): MenuItemSelectableState => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + checkmark: {}, + name: 'name', + value: 'value', + onCheckedValueChange: jest.fn(), + ...options, + }); + + const checkedItems = ['1', '2', '3']; + + it.each([ + [['1'], true], + [['2'], false], + [undefined, false], + [[], false], + [['3', '1', '2'], true], + ])('should set checked and aria-checked', (values, expected) => { + // Arrange + const state: MenuItemSelectableState = createTestState({ value: '1', name: 'test' }); + (useMenuListContext as jest.Mock).mockReturnValue({ checkedValues: { test: values } }); + + // Act + renderHook(() => useMenuItemSelectable(state, jest.fn())); + + // Assert + expect(state.checked).toBe(expected); + expect(state['aria-checked']).toBe(expected); + }); + + it('should call onCheckedValueChange if values changed', () => { + // Arrange + const state: MenuItemSelectableState = createTestState(); + (useMenuListContext as jest.Mock).mockReturnValue({ + onCheckedValueChange: jest.fn(), + checkedValues: { [state.name]: [...checkedItems] }, + }); + const newValues = [...checkedItems, 'x']; + + // Act + renderHook(() => useMenuItemSelectable(state, () => newValues)); + if (state.onClick) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + state.onClick({ persist: jest.fn() }); + } + + // Assert + expect(state.onCheckedValueChange).toHaveBeenCalledTimes(1); + expect(state.onCheckedValueChange).toHaveBeenCalledWith(expect.anything(), state.name, newValues); + }); + + it('should not call onCheckedValueChange if values did not change', () => { + // Arrange + const state: MenuItemSelectableState = createTestState(); + (useMenuListContext as jest.Mock).mockReturnValue({ + onCheckedValueChange: jest.fn(), + checkedValues: { [state.name]: [...checkedItems] }, + }); + + // Act + renderHook(() => useMenuItemSelectable(state, () => [...checkedItems])); + if (state.onClick) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + state.onClick({ persist: jest.fn() }); + } + + // Assert + expect(state.onCheckedValueChange).not.toHaveBeenCalled(); + }); + + it.each(['onClick', 'onKeyDown'])('should set %s handler', action => { + // Arrange + const state: MenuItemSelectableState = createTestState({ onKeyDown: undefined, onClick: undefined }); + + // Act + renderHook(() => useMenuItemSelectable(state, jest.fn())); + + // Assert + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const callback = state[action]; + expect(callback).toBeDefined(); + expect(typeof callback).toBe('function'); + }); + + it.each([EnterKey, SpacebarKey])('should toggle selection on %s keydown', keyCode => { + // Arrange + const state: MenuItemSelectableState = createTestState(); + (useMenuListContext as jest.Mock).mockReturnValue({ + onCheckedValueChange: jest.fn(), + checkedValues: { [state.name]: [...checkedItems] }, + }); + const event = { + defaultPrevented: false, + keyCode, + persist: jest.fn(), + }; + + // Act + renderHook(() => useMenuItemSelectable(state, () => [])); + if (state.onKeyDown) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + state.onKeyDown(event); + } + + // Assert + expect(state.onCheckedValueChange).toHaveBeenCalledTimes(1); + expect(state.onCheckedValueChange).toHaveBeenCalledWith(expect.anything(), state.name, []); + }); +}); diff --git a/packages/react-menu/src/selectable/useMenuItemSelectable.ts b/packages/react-menu/src/selectable/useMenuItemSelectable.ts new file mode 100644 index 00000000000000..a7d8686c261275 --- /dev/null +++ b/packages/react-menu/src/selectable/useMenuItemSelectable.ts @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { EnterKey, getCode, SpacebarKey } from '@fluentui/keyboard-key'; +import { useMenuListContext } from '../menuListContext'; +import { MenuItemSelectableState } from './types'; + +/** + * Hook used to mutate state to handle selection logic for selectable menu items + * + * @param state Selectable menu item state + * @param getNewCheckedItems Callback that returns the new checked values for given menu item + */ +export const useMenuItemSelectable = (state: MenuItemSelectableState, getNewCheckedItems: () => string[]) => { + const { onClick: onClickCallback, onKeyDown: onKeyDownCallback } = state; + const { checkedValues: { [state.name]: checkedItems = [] } = {}, onCheckedValueChange } = useMenuListContext(); + + state.checkedItems = checkedItems; + state.onCheckedValueChange = onCheckedValueChange || (() => null); + state.checked = checkedItems.indexOf(state.value) !== -1; + state['aria-checked'] = state.checked; + + const onSelectionChange = (e: React.MouseEvent | React.KeyboardEvent) => { + const newCheckedItems = getNewCheckedItems(); + + if ( + newCheckedItems.length === state.checkedItems.length && + state.checkedItems.every((el, i) => el === newCheckedItems[i]) + ) { + return; + } + + state.onCheckedValueChange(e, state.name, newCheckedItems); + }; + + state.onClick = e => { + if (onClickCallback) { + onClickCallback(e); + } + + onSelectionChange(e); + }; + + state.onKeyDown = e => { + if (onKeyDownCallback) { + onKeyDownCallback(e); + } + + const keyCode = getCode(e); + if (!e.defaultPrevented && (keyCode === EnterKey || keyCode === SpacebarKey)) { + onSelectionChange(e); + } + }; +}; diff --git a/yarn.lock b/yarn.lock index 31a9e22ed11c77..65a0cc1999eb55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1072,18 +1072,18 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/runtime-corejs3@^7.7.4", "@babel/runtime-corejs3@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.10.4.tgz#f29fc1990307c4c57b10dbd6ce667b27159d9e0d" - integrity sha512-BFlgP2SoLO9HJX9WBwN67gHWMBhDX/eDz64Jajd6mR/UAUzqrNMm99d4qHnVaKscAElZoFiPv+JpR/Siud5lXw== +"@babel/runtime-corejs3@^7.10.2", "@babel/runtime-corejs3@^7.7.4", "@babel/runtime-corejs3@^7.8.3": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.13.tgz#53d09813b7c20d616caf258e9325550ff701c039" + integrity sha512-8fSpqYRETHATtNitsCXq8QQbKJP31/KnDl2Wz2Vtui9nKzjss2ysuZtyVsWjBtvkeEFo346gkwjYPab1hvrXkQ== dependencies: core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99" - integrity sha512-UpTN5yUJr9b4EX2CnGNWIvER7Ab83ibv0pcvvHc4UOdrBI5jb8bj+32cCwPX6xu0mt2daFNjYhoi+X7beH0RSw== +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.13.tgz#0a21452352b02542db0ffb928ac2d3ca7cb6d66d" + integrity sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw== dependencies: regenerator-runtime "^0.13.4" @@ -1597,7 +1597,7 @@ source-map "^0.6.1" write-file-atomic "2.4.1" -"@jest/types@^24.8.0", "@jest/types@^24.9.0": +"@jest/types@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== @@ -1606,10 +1606,10 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" -"@jest/types@^25.1.0": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-25.1.0.tgz#b26831916f0d7c381e11dbb5e103a72aed1b4395" - integrity sha512-VpOtt7tCrgvamWZh1reVsGADujKigBUFTi19mlRjqEGsE8qH4r3s+skY33dNdXOwyZIvuftZ5tqdF1IgsMejMA== +"@jest/types@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d" + integrity sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw== dependencies: "@types/istanbul-lib-coverage" "^2.0.0" "@types/istanbul-reports" "^1.1.1" @@ -3628,16 +3628,16 @@ dependencies: defer-to-connect "^1.0.1" -"@testing-library/dom@^7.2.1": - version "7.2.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.2.1.tgz#bb3b31d669bbe0c4939dadd95d69caa3c1d0b372" - integrity sha512-xIGoHlQ2ZiEL1dJIFKNmLDypzYF+4OJTTASRctl/aoIDaS5y/pRVHRigoqvPUV11mdJoR71IIgi/6UviMgyz4g== +"@testing-library/dom@7.22.3", "@testing-library/dom@^7.2.1", "@testing-library/dom@^7.22.3": + version "7.22.3" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.22.3.tgz#12c0b1b97115e7731da6a86b4574eae401cb9ac5" + integrity sha512-IK6/eL1Xza/0goDKrwnBvlM06L+5eL9b1o+hUhX7HslfUvMETh0TYgXEr2LVpsVkHiOhRmUbUyml95KV/VlRNw== dependencies: - "@babel/runtime" "^7.9.2" - "@types/testing-library__dom" "^7.0.0" - aria-query "^4.0.2" - dom-accessibility-api "^0.4.2" - pretty-format "^25.1.0" + "@babel/runtime" "^7.10.3" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + dom-accessibility-api "^0.5.1" + pretty-format "^25.5.0" "@testing-library/jest-dom@^5.1.1": version "5.1.1" @@ -3670,6 +3670,26 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react-hooks@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-5.0.3.tgz#dd0d2048817b013b266d35ca45e3ea48a19fd87e" + integrity sha512-UrnnRc5II7LMH14xsYNm/WRch/67cBafmrSQcyFh0v+UUmSf1uzfB7zn5jQXSettGwOSxJwdQUN7PgkT0w22Lg== + dependencies: + "@babel/runtime" "^7.12.5" + "@types/react" ">=16.9.0" + "@types/react-dom" ">=16.9.0" + "@types/react-test-renderer" ">=16.9.0" + filter-console "^0.1.1" + react-error-boundary "^3.1.0" + +"@testing-library/react@^10.4.9": + version "10.4.9" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-10.4.9.tgz#9faa29c6a1a217bf8bbb96a28bd29d7a847ca150" + integrity sha512-pHZKkqUy0tmiD81afs8xfiuseXfU/N7rAX3iKjeZYje86t9VaB0LrxYVa+OOsvkrveX5jCK3IjajVn2MbePvqA== + dependencies: + "@babel/runtime" "^7.10.3" + "@testing-library/dom" "^7.22.3" + "@textlint/ast-node-types@^4.0.3": version "4.2.5" resolved "https://registry.yarnpkg.com/@textlint/ast-node-types/-/ast-node-types-4.2.5.tgz#ae13981bc8711c98313a6ac1c361194d6bf2d39b" @@ -3715,6 +3735,11 @@ resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.33.tgz#2728669427cdd74a99e53c9f457ca2866a37c52d" integrity sha512-VQgHxyPMTj3hIlq9SY1mctqx+Jj8kpQfoLvDlVSDNOyuYs8JYfkuY3OW/4+dO657yPmNhHpePRx0/Tje5ImNVQ== +"@types/aria-query@^4.2.0": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.1.tgz#78b5433344e2f92e8b306c06a5622c50c245bf6b" + integrity sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg== + "@types/babel__core@^7.1.0": version "7.1.2" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.2.tgz#608c74f55928033fce18b99b213c16be4b3d114f" @@ -4287,7 +4312,7 @@ dependencies: "@types/react" "*" -"@types/react-dom@16.9.10": +"@types/react-dom@16.9.10", "@types/react-dom@>=16.9.0": version "16.9.10" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.10.tgz#4485b0bec3d41f856181b717f45fd7831101156f" integrity sha512-ItatOrnXDMAYpv6G8UCk2VhbYVTjZT9aorLtA/OzDN9XJ2GKcfam68jutoAcILdRjsRUO8qb7AmyObF77Q8QFw== @@ -4353,6 +4378,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@>=16.9.0": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.0.tgz#9be47b375eeb906fced37049e67284a438d56620" + integrity sha512-nvw+F81OmyzpyIE1S0xWpLonLUZCMewslPuA8BtjSKc5XEbn8zEQBXS7KuOLHTNnSOEM2Pum50gHOoZ62tqTRg== + dependencies: + "@types/react" "*" + "@types/react-test-renderer@^16.0.0": version "16.8.2" resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.8.2.tgz#ad544b5571ebfc5f182c320376f1431a2b725c5e" @@ -4382,7 +4414,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@16.9.42", "@types/react@^16": +"@types/react@*", "@types/react@16.9.42", "@types/react@>=16.9.0", "@types/react@^16": version "16.9.42" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.42.tgz#9776508d59c1867bbf9bd7f036dab007fdaa1cb7" integrity sha512-iGy6HwfVfotqJ+PfRZ4eqPHPP5NdPZgQlr0lTs8EfkODRBV9cYy8QMKcC9qPCe1JrESC1Im6SrCFR6tQgg74ag== @@ -4462,13 +4494,6 @@ resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74" integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== -"@types/testing-library__dom@^7.0.0": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@types/testing-library__dom/-/testing-library__dom-7.0.1.tgz#426bef0aa306a603fe071859d4b485941b28aca6" - integrity sha512-WokGRksRJb3Dla6h02/0/NNHTkjsj4S8aJZiwMj/5/UL8VZ1iCe3H8SHzfpmBeH8Vp4SPRT8iC2o9kYULFhDIw== - dependencies: - pretty-format "^25.1.0" - "@types/testing-library__jest-dom@^5.0.0": version "5.0.1" resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.1.tgz#cc7f384535a3d9597e27f58d38a795f5c137cc53" @@ -4646,9 +4671,9 @@ "@types/yargs-parser" "*" "@types/yargs@^15.0.0": - version "15.0.3" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.3.tgz#41453a0bc7ab393e995d1f5451455638edbd2baf" - integrity sha512-XCMQRK6kfpNBixHLyHUsGmXrpEmFFxzMrcnSXFMziHd8CoNJo8l16FkHyQq4x+xbM7E2XL83/O78OD8u+iZTdQ== + version "15.0.13" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.13.tgz#34f7fec8b389d7f3c1fd08026a5763e072d3c6dc" + integrity sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ== dependencies: "@types/yargs-parser" "*" @@ -5576,13 +5601,13 @@ aria-query@^3.0.0: ast-types-flow "0.0.7" commander "^2.11.0" -aria-query@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.0.2.tgz#250687b4ccde1ab86d127da0432ae3552fc7b145" - integrity sha512-S1G1V790fTaigUSM/Gd0NngzEfiMy9uTUfMyHhKhVyy4cH5O/eTuR01ydhGL0z4Za1PXFTRGH3qL8VhUQuEO5w== +aria-query@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" + integrity sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA== dependencies: - "@babel/runtime" "^7.7.4" - "@babel/runtime-corejs3" "^7.7.4" + "@babel/runtime" "^7.10.2" + "@babel/runtime-corejs3" "^7.10.2" arr-diff@^1.0.1: version "1.1.0" @@ -9569,10 +9594,10 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.4.2: - version "0.4.3" - resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.4.3.tgz#93ca9002eb222fd5a343b6e5e6b9cf5929411c4c" - integrity sha512-JZ8iPuEHDQzq6q0k7PKMGbrIdsgBB7TRrtVOUm4nSMCExlg5qQG4KXWTH2k90yggjM4tTumRGwTKJSldMzKyLA== +dom-accessibility-api@^0.5.1: + version "0.5.4" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166" + integrity sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ== dom-converter@^0.2: version "0.2.0" @@ -11316,6 +11341,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +filter-console@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/filter-console/-/filter-console-0.1.1.tgz#6242be28982bba7415bcc6db74a79f4a294fa67c" + integrity sha512-zrXoV1Uaz52DqPs+qEwNJWJFAWZpYJ47UNmpN9q4j+/EYsz85uV0DC9k8tRND5kYmoVzL0W+Y75q4Rg8sRJCdg== + finalhandler@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.0.tgz#ce0b6855b45853e791b2fcc680046d88253dd7f5" @@ -19537,17 +19567,7 @@ pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@^24.8.0: - version "24.8.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.8.0.tgz#8dae7044f58db7cb8be245383b565a963e3c27f2" - integrity sha512-P952T7dkrDEplsR+TuY7q3VXDae5Sr7zmQb12JU/NDQa/3CH7/QW0yvqLcGN6jL+zQFKaoJcPc+yJxMTGmosqw== - dependencies: - "@jest/types" "^24.8.0" - ansi-regex "^4.0.0" - ansi-styles "^3.2.0" - react-is "^16.8.4" - -pretty-format@^24.9.0: +pretty-format@^24.8.0, pretty-format@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== @@ -19557,12 +19577,12 @@ pretty-format@^24.9.0: ansi-styles "^3.2.0" react-is "^16.8.4" -pretty-format@^25.1.0: - version "25.1.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.1.0.tgz#ed869bdaec1356fc5ae45de045e2c8ec7b07b0c8" - integrity sha512-46zLRSGLd02Rp+Lhad9zzuNZ+swunitn8zIpfD2B4OPCRLXbM87RJT2aBLBWYOznNUML/2l/ReMyWNC80PJBUQ== +pretty-format@^25.1.0, pretty-format@^25.5.0: + version "25.5.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" + integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== dependencies: - "@jest/types" "^25.1.0" + "@jest/types" "^25.5.0" ansi-regex "^5.0.0" ansi-styles "^4.0.0" react-is "^16.12.0" @@ -20167,6 +20187,13 @@ react-element-to-jsx-string@^14.0.2: "@base2/pretty-print-object" "1.0.0" is-plain-object "3.0.0" +react-error-boundary@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.0.tgz#9487443df2f9ba1db90d8ab52351814907ea4af3" + integrity sha512-lmPrdi5SLRJR+AeJkqdkGlW/CRkAUvZnETahK58J4xb5wpbfDngasEGu+w0T1iXEhVrYBJZeW+c4V1hILCnMWQ== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-overlay@^6.0.3: version "6.0.4" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.4.tgz#0d165d6d27488e660bc08e57bdabaad741366f7a"