diff --git a/packages/react-templates/src/components/Dropdown/DropdownSimple.tsx b/packages/react-templates/src/components/Dropdown/DropdownSimple.tsx new file mode 100644 index 00000000000..7f2c773e92e --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/DropdownSimple.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { + Dropdown, + DropdownItem, + DropdownList, + DropdownItemProps +} from '@patternfly/react-core/dist/esm/components/Dropdown'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle'; +import { Divider } from '@patternfly/react-core/dist/esm/components/Divider'; +import { OUIAProps } from '@patternfly/react-core/dist/esm/helpers'; + +export interface DropdownSimpleItem extends Omit { + /** Content of the dropdown item. If the isDivider prop is true, this prop will be ignored. */ + content?: React.ReactNode; + /** Unique identifier for the dropdown item, which is used in the dropdown onSelect callback */ + value: string | number; + /** Callback for when the dropdown item is clicked. */ + onClick?: (event?: any) => void; + /** URL to redirect to when the dropdown item is clicked. */ + to?: string; + /** Flag indicating whether the dropdown item should render as a divider. If true, the item will be rendered without + * the dropdown item wrapper. + */ + isDivider?: boolean; +} + +export interface DropdownSimpleProps extends OUIAProps { + /** Initial items of the dropdown. */ + initialItems?: DropdownSimpleItem[]; + /** @hide Forwarded ref */ + innerRef?: React.Ref; + /** Flag indicating the dropdown should be disabled. */ + isDisabled?: boolean; + /** Flag indicated whether the dropdown toggle should take up the full width of its parent. */ + isToggleFullWidth?: boolean; + /** Callback triggered when any dropdown item is clicked. */ + onSelect?: (event?: React.MouseEvent, value?: string | number) => void; + /** Callback triggered when the dropdown toggle opens or closes. */ + onToggle?: (nextIsOpen: boolean) => void; + /** Flag indicating the dropdown toggle should be focused after a dropdown item is clicked. */ + shouldFocusToggleOnSelect?: boolean; + /** Adds an accessible name to the dropdown toggle. Required when the dropdown toggle does not + * have any text content. + */ + toggleAriaLabel?: string; + /** Content of the toggle. */ + toggleContent: React.ReactNode; + /** Variant style of the dropdown toggle. */ + toggleVariant?: 'default' | 'plain' | 'plainText'; +} + +const DropdownSimpleBase: React.FunctionComponent = ({ + innerRef, + initialItems, + onSelect: onSelectProp, + onToggle: onToggleProp, + isDisabled, + toggleAriaLabel, + toggleContent, + isToggleFullWidth, + toggleVariant = 'default', + shouldFocusToggleOnSelect, + ...props +}: DropdownSimpleProps) => { + const [isOpen, setIsOpen] = React.useState(false); + + const onSelect = (event: React.MouseEvent, value: string | number) => { + onSelectProp && onSelectProp(event, value); + setIsOpen(false); + }; + + const onToggle = () => { + onToggleProp && onToggleProp(!isOpen); + setIsOpen(!isOpen); + }; + + const dropdownToggle = (toggleRef: React.Ref) => ( + + {toggleContent} + + ); + + const dropdownSimpleItems = initialItems?.map((item) => { + const { content, onClick, to, value, isDivider, ...itemProps } = item; + + return isDivider ? ( + + ) : ( + + {content} + + ); + }); + + return ( + setIsOpen(isOpen)} + ref={innerRef} + {...props} + > + {dropdownSimpleItems} + + ); +}; + +export const DropdownSimple = React.forwardRef((props: DropdownSimpleProps, ref: React.Ref) => ( + +)); + +DropdownSimple.displayName = 'DropdownSimple'; diff --git a/packages/react-templates/src/components/Dropdown/__tests__/DropdownSimple.test.tsx b/packages/react-templates/src/components/Dropdown/__tests__/DropdownSimple.test.tsx new file mode 100644 index 00000000000..ddd8b37df66 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/__tests__/DropdownSimple.test.tsx @@ -0,0 +1,254 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DropdownSimple } from '../DropdownSimple'; +import styles from '@patternfly/react-styles/css/components/MenuToggle/menu-toggle'; + +describe('Dropdown toggle', () => { + test('Renders dropdown toggle as not disabled when isDisabled is not true', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).not.toBeDisabled(); + }); + + test('Renders dropdown toggle as disabled when isDisabled is true', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toBeDisabled(); + }); + + test('Passes toggleVariant', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toHaveClass(styles.modifiers.plain); + }); + + test('Passes toggleAriaLabel', () => { + render(); + + expect(screen.getByRole('button')).toHaveAccessibleName('Aria label content'); + }); + + test('Calls onToggle with next isOpen state when dropdown toggle is clicked', async () => { + const onToggle = jest.fn(); + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + expect(onToggle).toHaveBeenCalledWith(true); + }); + + test('Does not call onToggle when dropdown toggle is not clicked', async () => { + const onToggle = jest.fn(); + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render( +
+ + +
+ ); + + const btn = screen.getByRole('button', { name: 'Actual' }); + await user.click(btn); + expect(onToggle).not.toHaveBeenCalled(); + }); + + test('Calls toggle onSelect when item is clicked', async () => { + const onSelect = jest.fn(); + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test('Does not call toggle onSelect when item is not clicked', async () => { + const onSelect = jest.fn(); + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + await user.click(toggle); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('Does not pass isToggleFullWidth to menu toggle by default', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).not.toHaveClass(styles.modifiers.fullWidth); + }); + + test('Passes isToggleFullWidth to menu toggle when passed in', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toHaveClass(styles.modifiers.fullWidth); + }); + + test('Does not focus toggle on item select by default', async () => { + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + + expect(toggle).not.toHaveFocus(); + }); + + test('Focuses toggle on item select when shouldFocusToggleOnSelect is true', async () => { + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + + expect(toggle).toHaveFocus(); + }); + + test('Matches snapshot', () => { + const { asFragment } = render(); + + expect(asFragment()).toMatchSnapshot(); + }); +}); + +describe('Dropdown items', () => { + test('Renders with items', async () => { + const items = [ + { content: 'Action', value: 1 }, + { value: 'separator', isDivider: true } + ]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + const dividerItem = screen.getByRole('separator'); + expect(actionItem).toBeInTheDocument(); + expect(dividerItem).toBeInTheDocument(); + }); + + test('Renders with a link item', async () => { + const items = [{ content: 'Link', value: 1, to: '#' }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const linkItem = screen.getByRole('menuitem', { name: 'Link' }); + expect(linkItem.getAttribute('href')).toBe('#'); + }); + + test('Renders with items not disabled by default', async () => { + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + expect(actionItem).not.toBeDisabled(); + }); + + test('Renders with a disabled item', async () => { + const items = [{ content: 'Action', value: 1, isDisabled: true }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + expect(actionItem).toBeDisabled(); + }); + + test('Spreads props on item', async () => { + const items = [{ content: 'Action', value: 1, id: 'Test' }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + expect(actionItem.getAttribute('id')).toBe('Test'); + }); + + test('Calls item onClick when clicked', async () => { + const onClick = jest.fn(); + const items = [{ content: 'Action', value: 1, onClick }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('Does not call item onClick when not clicked', async () => { + const onClick = jest.fn(); + const items = [ + { content: 'Action', value: 1, onClick }, + { content: 'Action 2', value: 2 } + ]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action 2' }); + await user.click(actionItem); + expect(onClick).not.toHaveBeenCalled(); + }); + + test('Does not call item onClick when clicked and item is disabled', async () => { + const onClick = jest.fn(); + const items = [{ content: 'Action', value: 1, onClick, isDisabled: true }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + expect(onClick).not.toHaveBeenCalled(); + }); + + test('Matches snapshot', async () => { + const items = [ + { content: 'Action', value: 1, ouiaId: '1' }, + { value: 'separator', isDivider: true, ouiaId: '2' }, + { content: 'Link', value: 'separator', to: '#', ouiaId: '3' } + ]; + const user = userEvent.setup(); + const { asFragment } = render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/react-templates/src/components/Dropdown/__tests__/__snapshots__/DropdownSimple.test.tsx.snap b/packages/react-templates/src/components/Dropdown/__tests__/__snapshots__/DropdownSimple.test.tsx.snap new file mode 100644 index 00000000000..c1f0b5cab98 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/__tests__/__snapshots__/DropdownSimple.test.tsx.snap @@ -0,0 +1,147 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Dropdown items Matches snapshot 1`] = ` + + +
+
+ +
+
+
+`; + +exports[`Dropdown toggle Matches snapshot 1`] = ` + + + +`; diff --git a/packages/react-templates/src/components/Dropdown/examples/DropdownSimpleExample.tsx b/packages/react-templates/src/components/Dropdown/examples/DropdownSimpleExample.tsx new file mode 100644 index 00000000000..6129f7ff33f --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/examples/DropdownSimpleExample.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Checkbox, Flex, FlexItem } from '@patternfly/react-core'; +import { DropdownSimple, DropdownSimpleItem } from '@patternfly/react-templates'; +import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +export const DropdownSimpleExample: React.FunctionComponent = () => { + const [isDisabled, setIsDisabled] = React.useState(false); + + const items: DropdownSimpleItem[] = [ + // eslint-disable-next-line no-console + { content: 'Action', value: 1, onClick: () => console.log('Action clicked') }, + // Prevent default click behavior on link for example purposes + { content: 'Link', value: 2, to: '#', onClick: (event: any) => event.preventDefault() }, + { content: 'Disabled Action', value: 3, isDisabled: true }, + { value: 'separator', isDivider: true }, + // eslint-disable-next-line no-console + { content: 'Second action', value: 4, onClick: () => console.log('Second action clicked') } + ]; + + return ( + + , checked: boolean) => setIsDisabled(checked)} + style={{ marginBottom: 20 }} + /> + + + + + + + + + + + + ); +}; diff --git a/packages/react-templates/src/components/Dropdown/examples/DropdownTemplates.md b/packages/react-templates/src/components/Dropdown/examples/DropdownTemplates.md new file mode 100644 index 00000000000..723e8acc93c --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/examples/DropdownTemplates.md @@ -0,0 +1,36 @@ +--- +id: Dropdown +section: components +subsection: menus +template: true +beta: true +propComponents: ['DropdownSimple', 'DropdownSimpleItem'] +--- + +Note: Templates live in their own package at [@patternfly/react-templates](https://www.npmjs.com/package/@patternfly/react-templates)! + +For custom use cases, please see the dropdown component suite from [@patternfly/react-core](https://www.npmjs.com/package/@patternfly/react-core). + +import { +Checkbox, +Divider, +Dropdown, +DropdownItem, +DropdownList, +DropdownItemProps, +Flex, +FlexItem, +MenuToggle, +MenuToggleElement, +OUIAProps +} from '@patternfly/react-core'; +import { DropdownSimple, DropdownSimpleItem } from '@patternfly/react-templates'; +import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +## Examples + +### Simple + +```ts file="./DropdownSimpleExample.tsx" + +``` diff --git a/packages/react-templates/src/components/Dropdown/index.ts b/packages/react-templates/src/components/Dropdown/index.ts new file mode 100644 index 00000000000..d56944e88c9 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/index.ts @@ -0,0 +1 @@ +export * from './DropdownSimple'; diff --git a/packages/react-templates/src/components/index.ts b/packages/react-templates/src/components/index.ts index 7868ecbae29..938364392c1 100644 --- a/packages/react-templates/src/components/index.ts +++ b/packages/react-templates/src/components/index.ts @@ -1 +1,2 @@ +export * from './Dropdown'; export * from './Select';