diff --git a/docs/content/ActionList.mdx b/docs/content/ActionList.mdx index ef14a338e00..31997d70b66 100644 --- a/docs/content/ActionList.mdx +++ b/docs/content/ActionList.mdx @@ -30,10 +30,11 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi {groupId: '4'} ]} items={[ - {leadingVisual: TypographyIcon, text: 'Rename', groupId: '0'}, - {leadingVisual: VersionsIcon, text: 'Duplicate', groupId: '0'}, - {leadingVisual: SearchIcon, text: 'repo:github/memex,github/github', groupId: '1'}, + {key: '1', leadingVisual: TypographyIcon, text: 'Rename', groupId: '0'}, + {key: '2', leadingVisual: VersionsIcon, text: 'Duplicate', groupId: '0'}, + {key: '3', leadingVisual: SearchIcon, text: 'repo:github/github', groupId: '1'}, { + key: '4', leadingVisual: NoteIcon, text: 'Table', description: 'Information-dense table optimized for operations across teams', @@ -41,6 +42,7 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi groupId: '2' }, { + key: '5', leadingVisual: ProjectIcon, text: 'Board', description: 'Kanban-style board focused on visual states', @@ -48,12 +50,13 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi groupId: '2' }, { + key: '6', leadingVisual: FilterIcon, text: 'Save sort and filters to current view', groupId: '3' }, - {leadingVisual: FilterIcon, text: 'Save sort and filters to new view', groupId: '3'}, - {leadingVisual: GearIcon, text: 'View settings', groupId: '4'} + {key: '7', leadingVisual: FilterIcon, text: 'Save sort and filters to new view', groupId: '3'}, + {key: '8', leadingVisual: GearIcon, text: 'View settings', groupId: '4'} ]} /> ``` diff --git a/docs/content/ActionMenu.mdx b/docs/content/ActionMenu.mdx index 06d8f4333af..dcc4b8ae10d 100644 --- a/docs/content/ActionMenu.mdx +++ b/docs/content/ActionMenu.mdx @@ -11,11 +11,11 @@ An `ActionMenu` is an ActionList-based component for creating a menu of actions anchorContent="Menu" onAction={({text}) => console.log(text)} items={[ - {text: 'New file'}, + {text: 'New file', key: 'new-file'}, ActionMenu.Divider, - {text: 'Copy link'}, - {text: 'Edit file'}, - {text: 'Delete file', variant: 'danger'} + {text: 'Copy link', key: 'copy-link'}, + {text: 'Edit file', key: 'edit-file'}, + {text: 'Delete file', variant: 'danger', key: 'delete-file'} ]} /> ``` @@ -34,10 +34,11 @@ An `ActionMenu` is an ActionList-based component for creating a menu of actions {groupId: '4'} ]} items={[ - {leadingVisual: TypographyIcon, text: 'Rename', groupId: '0'}, - {leadingVisual: VersionsIcon, text: 'Duplicate', groupId: '0'}, - {leadingVisual: SearchIcon, text: 'repo:github/github', groupId: '1'}, + {key: '1', leadingVisual: TypographyIcon, text: 'Rename', groupId: '0'}, + {key: '2', leadingVisual: VersionsIcon, text: 'Duplicate', groupId: '0'}, + {key: '3', leadingVisual: SearchIcon, text: 'repo:github/github', groupId: '1'}, { + key: '4', leadingVisual: NoteIcon, text: 'Table', description: 'Information-dense table optimized for operations across teams', @@ -45,6 +46,7 @@ An `ActionMenu` is an ActionList-based component for creating a menu of actions groupId: '2' }, { + key: '5', leadingVisual: ProjectIcon, text: 'Board', description: 'Kanban-style board focused on visual states', @@ -52,12 +54,13 @@ An `ActionMenu` is an ActionList-based component for creating a menu of actions groupId: '2' }, { + key: '6', leadingVisual: FilterIcon, text: 'Save sort and filters to current view', groupId: '3' }, - {leadingVisual: FilterIcon, text: 'Save sort and filters to new view', groupId: '3'}, - {leadingVisual: GearIcon, text: 'View settings', groupId: '4'} + {key: '7', leadingVisual: FilterIcon, text: 'Save sort and filters to new view', groupId: '3'}, + {key: '8', leadingVisual: GearIcon, text: 'View settings', groupId: '4'} ]} /> ``` diff --git a/docs/content/DropdownMenu.mdx b/docs/content/DropdownMenu.mdx index 87997da4944..6446a79e82f 100644 --- a/docs/content/DropdownMenu.mdx +++ b/docs/content/DropdownMenu.mdx @@ -2,13 +2,20 @@ title: DropdownMenu --- -A `DropdownMenu` provides an anchor (button by default) that will open a floating menu of selectable items. The menu can be opened and navigated using keyboard or mouse. When an item is selected, the menu will close and the `onChange` callback will be called. If the default anchor button is used, the anchor contents will be updated with the selection. +A `DropdownMenu` provides an anchor (button by default) that will open a floating menu of selectable items. The menu can be opened and navigated using keyboard or mouse. When an item is selected, the menu will close and the `onChange` callback will be called. If the default anchor button is used, the anchor contents will be updated with the selection. ## Example ```javascript live noinline function DemoComponent() { - const items = React.useMemo(() => [{text: '🔵 Cyan', id: 5}, {text: '🔴 Magenta'}, {text: '🟡 Yellow'}], []) + const items = React.useMemo( + () => [ + {text: '🔵 Cyan', id: 5, key: 'cyan'}, + {text: '🔴 Magenta', key: 'magenta'}, + {text: '🟡 Yellow', key: 'yellow'} + ], + [] + ) const [selectedItem, setSelectedItem] = React.useState() return ( @@ -31,12 +38,12 @@ render() ## Component props -| Name | Type | Default | Description | -| :--------------- | :-------------------------------------------- | :---------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| items | `ItemProps[]` | `undefined` | Required. A list of item objects to display in the menu | -| selectedItem | `ItemInput` | `undefined` | An `ItemProps` item from the list of `items` which is currently selected. This item will receive a checkmark next to it in the menu. | -| onChange? | (item?: ItemInput) => unknown | `undefined` | A callback which receives the selected item or `undefined` when an item is activated in the menu. If the activated item is the same as the current `selectedItem`, `undefined` will be passed. | -| placeholder | `string` | `undefined` | Optional. A placeholder value to display when there is no current selection. | -| renderAnchor | `(props: DropdownButtonProps) => JSX.Element` | `DropdownButton` | Optional. If defined, provided component will be used to render the menu anchor. Will receive the selected `Item` text as `children` prop when an item is activated. | -| renderItem | `(props: ItemProps) => JSX.Element` | `ActionList.Item` | Optional. If defined, each item in `items` will be passed to this function, allowing for custom item rendering. | -| groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `DropdownMenu` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. | +| Name | Type | Default | Description | +| :------------ | :-------------------------------------------- | :---------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| items | `ItemProps[]` | `undefined` | Required. A list of item objects to display in the menu | +| selectedItem | `ItemInput` | `undefined` | An `ItemProps` item from the list of `items` which is currently selected. This item will receive a checkmark next to it in the menu. | +| onChange? | (item?: ItemInput) => unknown | `undefined` | A callback which receives the selected item or `undefined` when an item is activated in the menu. If the activated item is the same as the current `selectedItem`, `undefined` will be passed. | +| placeholder | `string` | `undefined` | Optional. A placeholder value to display when there is no current selection. | +| renderAnchor | `(props: DropdownButtonProps) => JSX.Element` | `DropdownButton` | Optional. If defined, provided component will be used to render the menu anchor. Will receive the selected `Item` text as `children` prop when an item is activated. | +| renderItem | `(props: ItemProps) => JSX.Element` | `ActionList.Item` | Optional. If defined, each item in `items` will be passed to this function, allowing for custom item rendering. | +| groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `DropdownMenu` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. | diff --git a/src/ActionList/Group.tsx b/src/ActionList/Group.tsx index 4fb87ad5bc7..a420fd975bd 100644 --- a/src/ActionList/Group.tsx +++ b/src/ActionList/Group.tsx @@ -12,6 +12,11 @@ export interface GroupProps extends React.ComponentPropsWithoutRef<'div'>, SxPro */ header?: HeaderProps + /** + * The id of the group. + */ + groupId?: string + /** * `Items` to render in the `Group`. */ diff --git a/src/ActionList/List.tsx b/src/ActionList/List.tsx index c8dfb92e99a..48a9290aa09 100644 --- a/src/ActionList/List.tsx +++ b/src/ActionList/List.tsx @@ -6,6 +6,7 @@ import {Divider} from './Divider' import styled from 'styled-components' import {get} from '../constants' import {SystemCssProperties} from '@styled-system/css' +import {uniqueId} from '../utils/uniqueId' export type ItemInput = ItemProps | (Partial & {renderItem: typeof Item}) @@ -124,19 +125,27 @@ export function List(props: ListProps): JSX.Element { */ const renderGroup = ( groupProps: GroupProps | (Partial & {renderItem?: typeof Item; renderGroup?: typeof Group}) - ) => ((('renderGroup' in groupProps && groupProps.renderGroup) ?? props.renderGroup) || Group).call(null, groupProps) + ) => { + const GroupComponent = (('renderGroup' in groupProps && groupProps.renderGroup) ?? props.renderGroup) || Group + return + } /** * Render an `Item` using the first of the following renderers that is defined: * An `Item`-level, `Group`-level, or `List`-level custom `Item` renderer, * or the default `Item` renderer. */ - const renderItem = (itemProps: ItemInput, item: ItemInput) => - (('renderItem' in itemProps && itemProps.renderItem) || props.renderItem || Item).call(null, { - ...itemProps, - sx: {...itemStyle, ...itemProps.sx}, - item - }) + const renderItem = (itemProps: ItemInput, item: ItemInput) => { + const ItemComponent = ('renderItem' in itemProps && itemProps.renderItem) || props.renderItem || Item + return ( + + ) + } /** * An array of `Group`s, each with an associated `Header` and with an array of `Item`s belonging to that `Group`. @@ -147,7 +156,7 @@ export function List(props: ListProps): JSX.Element { if (!isGroupedListProps(props)) { // When no `groupMetadata`s is provided, collect rendered `Item`s into a single anonymous `Group`. - groups = [{items: props.items?.map(item => renderItem(item, item))}] + groups = [{items: props.items?.map(item => renderItem(item, item)), groupId: uniqueId()}] } else { // When `groupMetadata` is provided, collect rendered `Item`s into their associated `Group`s. @@ -185,9 +194,8 @@ export function List(props: ListProps): JSX.Element { return ( {groups?.map(({header, ...groupProps}, index) => ( - <> + {renderGroup({ - key: index, sx: { ...(index === 0 && firstGroupStyle), ...(index === groups.length - 1 && lastGroupStyle) @@ -200,8 +208,8 @@ export function List(props: ListProps): JSX.Element { }), ...groupProps })} - {index + 1 !== groups.length && } - + {index + 1 !== groups.length && } + ))} ) diff --git a/src/__tests__/ActionMenu.tsx b/src/__tests__/ActionMenu.tsx index af5a07b4588..e50936a1afc 100644 --- a/src/__tests__/ActionMenu.tsx +++ b/src/__tests__/ActionMenu.tsx @@ -1,4 +1,4 @@ -import {cleanup, render as HTMLRender} from '@testing-library/react' +import {cleanup, render as HTMLRender, act, fireEvent} from '@testing-library/react' import 'babel-polyfill' import {axe, toHaveNoViolations} from 'jest-axe' import React from 'react' @@ -55,7 +55,9 @@ describe('ActionMenu', () => { let portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Menu') - await anchor.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const itemText = items @@ -71,11 +73,15 @@ describe('ActionMenu', () => { let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Menu') - await anchor.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const menuItem = await menu.queryByText(items[0].text) - menuItem?.click() + act(() => { + fireEvent.click(menuItem as Element) + }) expect(portalRoot?.textContent).toEqual('') // menu items are hidden }) @@ -84,11 +90,15 @@ describe('ActionMenu', () => { let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Menu') - await anchor.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const somethingElse = (await menu.baseElement.querySelector('#something-else')) as HTMLElement - await somethingElse?.click() + act(() => { + fireEvent.click(somethingElse) + }) expect(portalRoot?.textContent).toEqual('') // menu items are hidden }) @@ -97,11 +107,15 @@ describe('ActionMenu', () => { let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Menu') - await anchor.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const menuItem = (await portalRoot?.querySelector("[role='menuitem']")) as HTMLElement - await menuItem?.click() + act(() => { + fireEvent.click(menuItem) + }) // onAction has been called with correct argument expect(mockOnActivate).toHaveBeenCalledTimes(1) const arg = mockOnActivate.mock.calls[0][0] diff --git a/src/__tests__/DropdownMenu.tsx b/src/__tests__/DropdownMenu.tsx index e6ae97388be..57b75a67d45 100644 --- a/src/__tests__/DropdownMenu.tsx +++ b/src/__tests__/DropdownMenu.tsx @@ -57,7 +57,9 @@ describe('DropdownMenu', () => { let portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Select an Option') - await anchor?.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const itemText = items @@ -73,7 +75,9 @@ describe('DropdownMenu', () => { let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Select an Option') - await anchor.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const menuItem = await menu.queryByText('Baz') @@ -89,7 +93,9 @@ describe('DropdownMenu', () => { let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Select an Option') - await anchor.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const menuItem = await menu.queryByText('Baz') @@ -104,11 +110,15 @@ describe('DropdownMenu', () => { let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeNull() const anchor = await menu.findByText('Select an Option') - await anchor.click() + act(() => { + fireEvent.click(anchor) + }) portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') expect(portalRoot).toBeTruthy() const somethingElse = (await menu.baseElement.querySelector('#something-else')) as HTMLElement - await somethingElse?.click() + act(() => { + fireEvent.click(somethingElse) + }) // portal is closed after click expect(portalRoot?.textContent).toEqual('') // menu items are hidden }) diff --git a/src/__tests__/FormGroup.tsx b/src/__tests__/FormGroup.tsx index 532ff024b9d..4f504eb6044 100644 --- a/src/__tests__/FormGroup.tsx +++ b/src/__tests__/FormGroup.tsx @@ -18,7 +18,7 @@ describe('FormGroup', () => { const {container} = HTMLRender( Example text - + ) const results = await axe(container)