diff --git a/docs/content/ActionList.mdx b/docs/content/ActionList.mdx index 51e77f16699..ef14a338e00 100644 --- a/docs/content/ActionList.mdx +++ b/docs/content/ActionList.mdx @@ -63,5 +63,5 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi | Name | Type | Default | Description | | :------------ | :---------------------------------- | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------ | | items | `ItemProps[]` | `undefined` | Required. A list of item objects conforming to the `ActionList.Item` props interface. | -| renderItems | `(props: ItemProps) => JSX.Element` | `ActionList.Item` | Optional. If defined, each item in `items` will be passed to this function, allowing for `ActionList`-wide custom item rendering. | +| renderItem | `(props: ItemProps) => JSX.Element` | `ActionList.Item` | Optional. If defined, each item in `items` will be passed to this function, allowing for `ActionList`-wide custom item rendering. | | groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `ActionList` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. | diff --git a/docs/content/DropdownMenu.mdx b/docs/content/DropdownMenu.mdx new file mode 100644 index 00000000000..87997da4944 --- /dev/null +++ b/docs/content/DropdownMenu.mdx @@ -0,0 +1,42 @@ +--- +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. + +## Example + +```javascript live noinline +function DemoComponent() { + const items = React.useMemo(() => [{text: '🔵 Cyan', id: 5}, {text: '🔴 Magenta'}, {text: '🟡 Yellow'}], []) + const [selectedItem, setSelectedItem] = React.useState() + + return ( + ( + + {children} + + )} + placeholder="🎨" + items={items} + selectedItem={selectedItem} + onChange={setSelectedItem} + /> + ) +} + +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. | diff --git a/docs/content/Overlay.mdx b/docs/content/Overlay.mdx index 83173adc6b7..fc97fff6c14 100644 --- a/docs/content/Overlay.mdx +++ b/docs/content/Overlay.mdx @@ -7,18 +7,17 @@ An `Overlay` is a flexible floating surface, used to display transient content s Behaviors include: - Rendering the overlay in a React Portal so that it always renders on top of other content on the page -- Positioning the overlay according to passed in settings, using our context-aware positioning algorithms - Trapping focus - Calling a user provided function when the user presses `Escape` - Calling a user provided function when the user clicks outside of the container -- Focusing either user provided element, or the first focusable element in the container when it is opened +- Focusing either user provided element, or the first focusable element in the container when it is opened - Returning focus to an element when container is closed ## Accessibility considerations - The `Overlay` must either have: - - A value set for the `aria-labelledby` attribute that refers to a visible title. - - An `aria-label` attribute + - A value set for the `aria-labelledby` attribute that refers to a visible title. + - An `aria-label` attribute - If the `Overlay` should also have a longer description, use `aria-describedby` - The `Overlay` component has a `role="dialog"` set on it, if you are using `Overlay` for alerts, you can pass in `role="alertdialog"` instead. Please read the [W3C guidelines](https://www.w3.org/TR/wai-aria-1.1/#alertdialog) to determine which role is best for your use case - The `Overlay` component has `aria-modal` set to `true` by default and should not be overridden as all `Overlay`s behave as modals. @@ -39,9 +38,8 @@ const Demo = () => { open overlay {/* be sure to conditionally render the Overlay. This helps with performance and is required. */} - {isOpen && + {isOpen && ( { > Are you sure you would like to delete this item? - + - } - + )} ) } -render() +render() ``` ## System props @@ -70,15 +67,13 @@ render() ## Component props -| Name | Type | Default | Description | -| :--- | :----- | :-----: | :---------------------------------- | -| positionSettings | See the [`PositionSettings interface`]() section of the `anchoredPosition` docs | `{side: 'outside-bottom', align: 'start', anchorOffset: 4, alignmentOffset: 4, allowOutOfBounds: false }` | Optional. Settings used to position the `Overlay`. If none are provided, `Overlay` is positioned on the bottom left of the `anchorRef`. | -| positionDeps | `React.DependencyList` | `undefined` | Optional. If defined, the position of the `Overlay` will only be recalulated when one of the dependencies in this array changes. | -| ignoreClickRefs | `React.RefObject []` | `undefined` | Optional. An array of ref objects to ignore clicks on in the `onOutsideClick` behavior. This is often used to ignore clicking on the element that toggles the open/closed state for the `Overlay` to prevent the `Overlay` from being toggled twice. | -| initialFocusRef | `React.RefObject` | `undefined` | Optional. Ref for the element to focus when the `Overlay` is opened. If nothing is provided, the first focusable element in the `Overlay` body is focused. | -| anchorRef | `React.RefObject` | `undefined` | Required. Element the `Overlay` should be anchored to. | -| returnFocusRef | `React.RefObject` | `undefined` | Required. Ref for the element to focus when the `Overlay` is closed. | -| onClickOutside | `function` | `undefined` | Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`. | -| onEscape | `function` | `undefined` | Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`. | -| width | `'sm', 'md', 'lg', 'xl', 'auto'` | `auto` | Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `sm` corresponds to `256px`, `md` corresponds to `320px`, `lg` corresponds to `480px`, and `xl` corresponds to `640px`. | -| height | `'sm', 'md', 'auto'` | `auto` | Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`. `sm` corresponds to `480px` and `md` corresponds to `640px`. | +| Name | Type | Default | Description | +| :-------------- | :-------------------------------- | :---------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ignoreClickRefs | `React.RefObject []` | `undefined` | Optional. An array of ref objects to ignore clicks on in the `onOutsideClick` behavior. This is often used to ignore clicking on the element that toggles the open/closed state for the `Overlay` to prevent the `Overlay` from being toggled twice. | +| initialFocusRef | `React.RefObject` | `undefined` | Optional. Ref for the element to focus when the `Overlay` is opened. If nothing is provided, the first focusable element in the `Overlay` body is focused. | +| anchorRef | `React.RefObject` | `undefined` | Required. Element the `Overlay` should be anchored to. | +| returnFocusRef | `React.RefObject` | `undefined` | Required. Ref for the element to focus when the `Overlay` is closed. | +| onClickOutside | `function` | `undefined` | Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`. | +| onEscape | `function` | `undefined` | Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`. | +| width | `'sm', 'md', 'lg', 'xl', 'auto'` | `auto` | Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `sm` corresponds to `256px`, `md` corresponds to `320px`, `lg` corresponds to `480px`, and `xl` corresponds to `640px`. | +| height | `'sm', 'md', 'auto'` | `auto` | Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`. `sm` corresponds to `480px` and `md` corresponds to `640px`. | diff --git a/src/ActionList/Item.tsx b/src/ActionList/Item.tsx index d22996a5bcb..5bfc07d0e40 100644 --- a/src/ActionList/Item.tsx +++ b/src/ActionList/Item.tsx @@ -1,8 +1,9 @@ -import type {IconProps} from '@primer/octicons-react' +import {CheckIcon, IconProps} from '@primer/octicons-react' import React from 'react' import styled from 'styled-components' import {get} from '../constants' import sx, {SxProp} from '../sx' +import {ItemInput} from './List' /** * Contract for props passed to the `Item` component. @@ -38,6 +39,11 @@ export interface ItemProps extends React.ComponentPropsWithoutRef<'div'>, SxProp * - `"danger"` - A destructive action `Item`. */ variant?: 'default' | 'danger' + + /** + * For `Item`s which can be selected, whether the `Item` is currently selected. + */ + selected?: boolean } const StyledItem = styled.div<{variant: ItemProps['variant']} & SxProp>` @@ -98,12 +104,14 @@ export function Item({ text, description, descriptionVariant = 'inline', + selected, leadingVisual: LeadingVisual, variant = 'default', ...props -}: Partial): JSX.Element { +}: Partial & {item: ItemInput}): JSX.Element { return ( - + + {!!selected === selected && {selected && }} {LeadingVisual && ( diff --git a/src/ActionList/List.tsx b/src/ActionList/List.tsx index 8038cbc9950..24e410ef71a 100644 --- a/src/ActionList/List.tsx +++ b/src/ActionList/List.tsx @@ -1,3 +1,4 @@ +import type {AriaRole} from '../utils/types' import {Group, GroupProps} from './Group' import {Item, ItemProps} from './Item' import React from 'react' @@ -6,28 +7,35 @@ import styled from 'styled-components' import {get} from '../constants' import {SystemCssProperties} from '@styled-system/css' +export type ItemInput = ItemProps | (Partial & {renderItem: typeof Item}) + /** * Contract for props passed to the `List` component. */ -interface ListPropsBase { +export interface ListPropsBase { /** * A collection of `Item` props and `Item`-level custom `Item` renderers. */ - items: (ItemProps | (Partial & {renderItem: typeof Item}))[] + items: ItemInput[] + + /** + * The ARIA role describing the function of `List` component. `listbox` is a common value. + */ + role?: AriaRole /** * A `List`-level custom `Item` renderer. Every `Item` within this `List` * without a `Group`-level or `Item`-level custom `Item` renderer will be * rendered using this function component. */ - renderItem?: (props: ItemProps) => JSX.Element + renderItem?: typeof Item /** * A `List`-level custom `Group` renderer. Every `Group` within this `List` * without a `Group`-level custom `Item` renderer will be rendered using * this function component. */ - renderGroup?: (props: GroupProps) => JSX.Element + renderGroup?: typeof Group /** * Style variations. Usage is discretionary. @@ -41,7 +49,7 @@ interface ListPropsBase { /** * Contract for props passed to the `List` component, when its `Item`s are collected in `Group`s. */ -interface GroupedListProps extends ListPropsBase { +export interface GroupedListProps extends ListPropsBase { /** * A collection of `Group` props (except `items`), plus a unique group identifier * and `Group`-level custom `Item` or `Group` renderers. @@ -123,10 +131,11 @@ export function List(props: ListProps): JSX.Element { * An `Item`-level, `Group`-level, or `List`-level custom `Item` renderer, * or the default `Item` renderer. */ - const renderItem = (itemProps: ItemProps | (Partial & {renderItem: typeof Item})) => - ((('renderItem' in itemProps && itemProps.renderItem) ?? props.renderItem) || Item).call(null, { + const renderItem = (itemProps: ItemInput, item: ItemInput) => + (('renderItem' in itemProps && itemProps.renderItem) || props.renderItem || Item).call(null, { ...itemProps, - sx: {...itemStyle, ...itemProps.sx} + sx: {...itemStyle, ...itemProps.sx}, + item }) /** @@ -138,7 +147,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(renderItem)}] + groups = [{items: props.items?.map(item => renderItem(item, item))}] } else { // When `groupMetadata` is provided, collect rendered `Item`s into their associated `Group`s. @@ -159,7 +168,13 @@ export function List(props: ListProps): JSX.Element { ...group, items: [ ...(group?.items ?? []), - renderItem({...(group && 'renderItem' in group && {renderItem: group.renderItem}), ...itemProps}) + renderItem( + { + ...(group && 'renderItem' in group && {renderItem: group.renderItem}), + ...itemProps + }, + itemProps + ) ] }) } diff --git a/src/DropdownMenu/DropdownButton.tsx b/src/DropdownMenu/DropdownButton.tsx new file mode 100644 index 00000000000..df2a7f36d87 --- /dev/null +++ b/src/DropdownMenu/DropdownButton.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import {TriangleDownIcon} from '@primer/octicons-react' +import Button, {ButtonProps} from '../Button/Button' +import StyledOcticon from '../StyledOcticon' + +export type DropdownButtonProps = ButtonProps + +export const DropdownButton = React.forwardRef>( + ({children, ...props}: React.PropsWithChildren, ref): JSX.Element => ( + + ) +) diff --git a/src/DropdownMenu/DropdownMenu.tsx b/src/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 00000000000..d493786ef70 --- /dev/null +++ b/src/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,159 @@ +import React, {useCallback, useRef, useState} from 'react' +import {List, GroupedListProps, ListPropsBase, ItemInput} from '../ActionList/List' +import Overlay from '../Overlay' +import {DropdownButton, DropdownButtonProps} from './DropdownButton' +import {Item} from '../ActionList/Item' +import {useFocusTrap} from '../hooks/useFocusTrap' +import {useFocusZone} from '../hooks/useFocusZone' +import {useAnchoredPosition} from '../hooks/useAnchoredPosition' +import {useRenderForcingRef} from '../hooks/useRenderForcingRef' +import {uniqueId} from '../utils/uniqueId' + +export interface DropdownMenuProps extends Partial>, ListPropsBase { + /** + * A custom function component used to render the anchor element. + * Will receive the selected text as `children` prop when an item is activated. + * Uses a `DropdownButton` by default. + */ + renderAnchor?: >(props: T) => JSX.Element + + /** + * A placeholder value to display on the trigger button when no selection has been made. + */ + placeholder?: string + + /** + * An `ItemProps` item from the list of `items` which is currently selected. This item will receive a checkmark next to it in the menu. + */ + selectedItem?: ItemInput + + /** + * 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. + */ + onChange?: (item?: ItemInput) => unknown +} + +/** + * 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. + */ +export function DropdownMenu({ + renderAnchor = (props: T) => , + renderItem = Item, + placeholder, + selectedItem, + onChange, + ...listProps +}: DropdownMenuProps): JSX.Element { + const anchorRef = useRef(null) + const [overlayRef, updateOverlayRef] = useRenderForcingRef() + + const anchorId = `dropdownMenuAnchor-${uniqueId()}` + + const [open, setOpen] = useState(false) + const [focusType, setFocusType] = useState(null) + const onDismiss = useCallback(() => { + setOpen(false) + setFocusType(null) + }, []) + + const onAnchorKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!event.defaultPrevented) { + if (!open) { + if (['ArrowDown', 'ArrowUp'].includes(event.key)) { + setFocusType('list') + setOpen(true) + event.preventDefault() + } else if ([' ', 'Enter'].includes(event.key)) { + setFocusType('anchor') + setOpen(true) + event.preventDefault() + } + } else if (focusType === 'anchor') { + if (['ArrowDown', 'ArrowUp', 'Tab', 'Enter'].includes(event.key)) { + setFocusType('list') + event.preventDefault() + } else if (event.key === 'Escape') { + onDismiss() + event.preventDefault() + } + } + } + }, + [open, focusType, onDismiss] + ) + const onAnchorClick = useCallback( + (event: React.MouseEvent) => { + if (!event.defaultPrevented && event.button === 0 && !open) { + setOpen(true) + setFocusType('anchor') + } + }, + [open] + ) + + const {position} = useAnchoredPosition({anchorElementRef: anchorRef, floatingElementRef: overlayRef}) + + useFocusZone({containerRef: overlayRef, disabled: !open || focusType !== 'list' || !position}) + useFocusTrap({containerRef: overlayRef, disabled: !open || focusType !== 'list' || !position}) + + const renderItemWithCallbacks = useCallback( + ({onClick, onKeyDown, item, ...itemProps}) => { + const handleSelection = () => { + onChange?.(item === selectedItem ? undefined : item) + onDismiss() + } + + return renderItem({ + ...itemProps, + item, + role: 'option', + selected: item === selectedItem, + onClick: event => { + handleSelection() + onClick?.(event) + }, + onKeyDown: event => { + if (!event.defaultPrevented && [' ', 'Enter'].includes(event.key)) { + handleSelection() + // prevent "Enter" event from becoming a click on the anchor as overlay closes + event.preventDefault() + } + onKeyDown?.(event) + } + }) + }, + [onChange, onDismiss, renderItem, selectedItem] + ) + + return ( + <> + {renderAnchor({ + ref: anchorRef, + id: anchorId, + 'aria-labelledby': anchorId, + 'aria-haspopup': 'listbox', + children: selectedItem?.text ?? placeholder, + onClick: onAnchorClick, + onKeyDown: onAnchorKeyDown + })} + {open ? ( + + + + ) : null} + + ) +} + +DropdownMenu.displayName = 'DropdownMenu' diff --git a/src/DropdownMenu/index.ts b/src/DropdownMenu/index.ts new file mode 100644 index 00000000000..ba93a7e16a8 --- /dev/null +++ b/src/DropdownMenu/index.ts @@ -0,0 +1,4 @@ +export {DropdownMenu} from './DropdownMenu' +export type {DropdownMenuProps} from './DropdownMenu' +export {DropdownButton} from './DropdownButton' +export type {DropdownButtonProps} from './DropdownButton' diff --git a/src/Overlay.tsx b/src/Overlay.tsx index 059bdf3a5c5..3eb50a7154e 100644 --- a/src/Overlay.tsx +++ b/src/Overlay.tsx @@ -1,15 +1,15 @@ import styled from 'styled-components' -import React, {ReactElement} from 'react' +import React, {ReactElement, useRef} from 'react' import {get, COMMON, POSITION, SystemPositionProps, SystemCommonProps} from './constants' import {ComponentProps} from './utils/types' -import {useOverlay, AnchoredPositionHookSettings, TouchOrMouseEvent} from './hooks' +import {useOverlay, TouchOrMouseEvent} from './hooks' import Portal from './Portal' import sx, {SxProp} from './sx' +import {useCombinedRefs} from './hooks/useCombinedRefs' type StyledOverlayProps = { width?: keyof typeof widthMap height?: keyof typeof heightMap - visibility: 'visible' | 'hidden' } const heightMap = { @@ -48,7 +48,6 @@ const StyledOverlay = styled.div props.visibility}; ${COMMON}; ${POSITION}; ${sx}; @@ -57,63 +56,45 @@ export type OverlayProps = { ignoreClickRefs?: React.RefObject[] initialFocusRef?: React.RefObject returnFocusRef: React.RefObject - anchorRef: React.RefObject onClickOutside: (e: TouchOrMouseEvent) => void onEscape: (e: KeyboardEvent) => void - positionSettings?: AnchoredPositionHookSettings - positionDeps?: React.DependencyList -} & Omit, 'visibility' | keyof SystemPositionProps> +} & Omit, keyof SystemPositionProps> /** * An `Overlay` is a flexible floating surface, used to display transient content such as menus, * selection options, dialogs, and more. Overlays use shadows to express elevation. The `Overlay` * component handles all behaviors needed by overlay UIs as well as the common styles that all overlays * should have. - * @param positionSettings Settings for calculating the anchored position. - * @param positionDeps Dependencies to determine when to re-calculate the position of the overlay. * @param ignoreClickRefs Optional. An array of ref objects to ignore clicks on in the `onOutsideClick` behavior. This is often used to ignore clicking on the element that toggles the open/closed state for the `Overlay` to prevent the `Overlay` from being toggled twice. * @param initialFocusRef Optional. Ref for the element to focus when the `Overlay` is opened. If nothing is provided, the first focusable element in the `Overlay` body is focused. - * @param anchorRef Required. Element the `Overlay` should be anchored to. * @param returnFocusRef Required. Ref for the element to focus when the `Overlay` is closed. * @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`. * @param onEscape Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`. * @param width Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `sm` corresponds to `256px`, `md` corresponds to `320px`, `lg` corresponds to `480px`, and `xl` corresponds to `640px`. * @param height Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`. `sm` corresponds to `480px` and `md` corresponds to `640px`. */ -const Overlay = ({ - onClickOutside, - role = 'dialog', - positionSettings, - positionDeps, - anchorRef, - initialFocusRef, - returnFocusRef, - ignoreClickRefs, - onEscape, - ...rest -}: OverlayProps): ReactElement => { - const {position, ...overlayRest} = useOverlay({ - anchorRef, - positionSettings, - positionDeps, - returnFocusRef, - onEscape, - ignoreClickRefs, - onClickOutside, - initialFocusRef - }) - return ( - - - - ) -} +const Overlay = React.forwardRef( + ( + {onClickOutside, role = 'dialog', initialFocusRef, returnFocusRef, ignoreClickRefs, onEscape, ...rest}, + forwardedRef + ): ReactElement => { + const overlayRef = useRef(null) + const combinedRef = useCombinedRefs(overlayRef, forwardedRef) + + const overlayProps = useOverlay({ + overlayRef, + returnFocusRef, + onEscape, + ignoreClickRefs, + onClickOutside, + initialFocusRef + }) + return ( + + + + ) + } +) Overlay.defaultProps = { height: 'auto', diff --git a/src/__tests__/ActionList.tsx b/src/__tests__/ActionList.tsx index 5add974d2db..55fef51faa2 100644 --- a/src/__tests__/ActionList.tsx +++ b/src/__tests__/ActionList.tsx @@ -6,8 +6,7 @@ import theme from '../theme' import {ActionList} from '../ActionList' import {COMMON} from '../constants' import {behavesAsComponent, checkExports} from '../utils/testing' -import {ThemeProvider} from 'styled-components' -import {BaseStyles} from '..' +import {BaseStyles, ThemeProvider} from '..' expect.extend(toHaveNoViolations) function SimpleActionList(): JSX.Element { diff --git a/src/__tests__/DropdownMenu.tsx b/src/__tests__/DropdownMenu.tsx new file mode 100644 index 00000000000..e6ae97388be --- /dev/null +++ b/src/__tests__/DropdownMenu.tsx @@ -0,0 +1,115 @@ +import {cleanup, render as HTMLRender, act, fireEvent} from '@testing-library/react' +import 'babel-polyfill' +import {axe, toHaveNoViolations} from 'jest-axe' +import React from 'react' +import theme from '../theme' +import {DropdownMenu, DropdownButton} from '../DropdownMenu' +import {COMMON} from '../constants' +import {behavesAsComponent, checkExports} from '../utils/testing' +import {BaseStyles, ThemeProvider} from '..' +import {ItemInput} from '../ActionList/List' + +expect.extend(toHaveNoViolations) + +const items = [{text: 'Foo'}, {text: 'Bar'}, {text: 'Baz'}, {text: 'Bon'}] as ItemInput[] + +function SimpleDropdownMenu(): JSX.Element { + const [selectedItem, setSelectedItem] = React.useState() + + return ( + + +
X
+ +
+
+
+ ) +} + +describe('DropdownMenu', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + behavesAsComponent({Component: DropdownMenu, systemPropArray: [COMMON], options: {skipAs: true, skipSx: true}}) + + checkExports('DropdownMenu', { + default: undefined, + DropdownMenu, + DropdownButton + }) + + it('should have no axe violations', async () => { + const {container} = HTMLRender() + const results = await axe(container) + expect(results).toHaveNoViolations() + cleanup() + }) + + it('should trigger the overlay on trigger click', async () => { + const menu = HTMLRender() + let portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeNull() + const anchor = await menu.findByText('Select an Option') + await anchor?.click() + portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeTruthy() + const itemText = items + .map((i: ItemInput) => { + if (i.hasOwnProperty('text')) return i?.text + }) + .join('') + expect(portalRoot?.textContent?.trim()).toEqual(itemText) + }) + + it('should dismiss the overlay on dropdown item click', async () => { + const menu = HTMLRender() + let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeNull() + const anchor = await menu.findByText('Select an Option') + await anchor.click() + portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeTruthy() + const menuItem = await menu.queryByText('Baz') + act(() => { + fireEvent.click(menuItem as Element) + }) + // portal is closed after click + expect(portalRoot?.textContent).toEqual('') // menu items are hidden + }) + + it('option should be selected when chosen from the dropdown menu', async () => { + const menu = HTMLRender() + let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeNull() + const anchor = await menu.findByText('Select an Option') + await anchor.click() + portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeTruthy() + const menuItem = await menu.queryByText('Baz') + act(() => { + fireEvent.click(menuItem as Element) + }) + expect(anchor?.textContent).toEqual('Baz') + }) + + it('should dismiss the overlay on clicking outside overlay', async () => { + const menu = HTMLRender() + let portalRoot = await menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeNull() + const anchor = await menu.findByText('Select an Option') + await anchor.click() + portalRoot = menu.baseElement.querySelector('#__primerPortalRoot__') + expect(portalRoot).toBeTruthy() + const somethingElse = (await menu.baseElement.querySelector('#something-else')) as HTMLElement + await somethingElse?.click() + // portal is closed after click + expect(portalRoot?.textContent).toEqual('') // menu items are hidden + }) +}) diff --git a/src/__tests__/Overlay.tsx b/src/__tests__/Overlay.tsx index a5aa2fb6b89..f1071bc33d8 100644 --- a/src/__tests__/Overlay.tsx +++ b/src/__tests__/Overlay.tsx @@ -27,10 +27,8 @@ const TestComponent = ({initialFocus, callback}: TestComponentSettings) => { open overlay - {isOpen && ( + {isOpen ? ( { - )} + ) : null} ) } diff --git a/src/__tests__/__snapshots__/DropdownMenu.tsx.snap b/src/__tests__/__snapshots__/DropdownMenu.tsx.snap new file mode 100644 index 00000000000..0885577466b --- /dev/null +++ b/src/__tests__/__snapshots__/DropdownMenu.tsx.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DropdownMenu renders consistently 1`] = ` +.c0 { + position: relative; + display: inline-block; + padding: 6px 16px; + font-family: inherit; + font-weight: 600; + line-height: 20px; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border-radius: 6px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + -webkit-text-decoration: none; + text-decoration: none; + text-align: center; + font-size: 14px; + color: #24292e; + background-color: #fafbfc; + border: 1px solid rgba(27,31,35,0.15); + box-shadow: 0 1px 0 rgba(27,31,35,0.04),inset 0 1px 0 rgba(255,255,255,0.25); +} + +.c0:hover { + -webkit-text-decoration: none; + text-decoration: none; +} + +.c0:focus { + outline: none; +} + +.c0:disabled { + cursor: default; +} + +.c0:disabled svg { + opacity: 0.6; +} + +.c0:hover { + background-color: #f3f4f6; + border-color: rgba(27,31,35,0.15); +} + +.c0:focus { + border-color: rgba(27,31,35,0.15); + box-shadow: 0 0 0 3px rgba(3,102,214,0.3); +} + +.c0:active { + background-color: #edeff2; + box-shadow: inset 0 0.15em 0.3em rgba(27,31,35,0.15); +} + +.c0:disabled { + color: #959da5; + background-color: #fafbfc; + border-color: rgba(27,31,35,0.15); +} + +.c1 { + margin-left: 4px; +} + + +`; diff --git a/src/hooks/useAnchoredPosition.ts b/src/hooks/useAnchoredPosition.ts index f7b4e373faf..1e5f6e6be43 100644 --- a/src/hooks/useAnchoredPosition.ts +++ b/src/hooks/useAnchoredPosition.ts @@ -18,7 +18,7 @@ export interface AnchoredPositionHookSettings extends Partial */ export function useAnchoredPosition( settings?: AnchoredPositionHookSettings, - dependencies?: React.DependencyList + dependencies: React.DependencyList = [] ): { floatingElementRef: React.RefObject anchorElementRef: React.RefObject @@ -30,9 +30,11 @@ export function useAnchoredPosition( React.useEffect(() => { if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { setPosition(getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings)) + } else { + setPosition(undefined) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencies ?? []) + }, [floatingElementRef.current, anchorElementRef.current, ...dependencies]) return { floatingElementRef, anchorElementRef, diff --git a/src/hooks/useCombinedRefs.ts b/src/hooks/useCombinedRefs.ts new file mode 100644 index 00000000000..c5915b152c2 --- /dev/null +++ b/src/hooks/useCombinedRefs.ts @@ -0,0 +1,29 @@ +import React, {ForwardedRef, useRef} from 'react' + +/** + * Creates a ref by combining multiple constituent refs. The ref returned by this hook + * should be passed as the ref for the element that needs to be shared. This is + * particularly useful when you are using `React.forwardRef` in your component but you + * also want to be able to access the local element. This is a small anti-pattern, + * though, as it breaks encapsulation. + * @param refs + */ +export function useCombinedRefs(...refs: (ForwardedRef | null | undefined)[]) { + const combinedRef = useRef(null) + + React.useEffect(() => { + for (const ref of refs) { + if (!ref) { + return + } + if (typeof ref === 'function') { + ref(combinedRef.current ?? null) + } else { + ref.current = combinedRef.current ?? null + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...refs, combinedRef.current]) + + return combinedRef +} diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts index 2a6695e0b91..ccdce1a845d 100644 --- a/src/hooks/useFocusTrap.ts +++ b/src/hooks/useFocusTrap.ts @@ -28,25 +28,30 @@ interface FocusTrapHookSettings { * @param settings {FocusTrapHookSettings} */ export function useFocusTrap( - settings?: FocusTrapHookSettings + settings?: FocusTrapHookSettings, + dependencies: React.DependencyList = [] ): {containerRef: React.RefObject; initialFocusRef: React.RefObject} { const containerRef = useProvidedRefOrCreate(settings?.containerRef) const initialFocusRef = useProvidedRefOrCreate(settings?.initialFocusRef) const disabled = settings?.disabled const abortController = React.useRef() - React.useEffect(() => { - if (containerRef.current instanceof HTMLElement) { - if (!disabled) { - abortController.current = focusTrap(containerRef.current, initialFocusRef.current ?? undefined) - return () => { + React.useEffect( + () => { + if (containerRef.current instanceof HTMLElement) { + if (!disabled) { + abortController.current = focusTrap(containerRef.current, initialFocusRef.current ?? undefined) + return () => { + abortController.current?.abort() + } + } else { abortController.current?.abort() } - } else { - abortController.current?.abort() } - } - }, [containerRef, initialFocusRef, disabled]) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [containerRef, initialFocusRef, disabled, ...dependencies] + ) return {containerRef, initialFocusRef} } diff --git a/src/hooks/useFocusZone.ts b/src/hooks/useFocusZone.ts index 56a4501bf3c..100ee1b024f 100644 --- a/src/hooks/useFocusZone.ts +++ b/src/hooks/useFocusZone.ts @@ -24,7 +24,7 @@ export interface FocusZoneHookSettings extends Omit; activeDescendantControlRef: React.RefObject} { const containerRef = useProvidedRefOrCreate(settings.containerRef) const useActiveDescendant = !!settings.activeDescendantFocus @@ -36,26 +36,29 @@ export function useFocusZone( const disabled = settings?.disabled const abortController = React.useRef() - useEffect(() => { - if ( - containerRef.current instanceof HTMLElement && - (!useActiveDescendant || activeDescendantControlRef.current instanceof HTMLElement) - ) { - if (!disabled) { - const vanillaSettings: FocusZoneSettings = { - ...settings, - activeDescendantControl: activeDescendantControlRef.current ?? undefined - } - abortController.current = focusZone(containerRef.current, vanillaSettings) - return () => { + useEffect( + () => { + if ( + containerRef.current instanceof HTMLElement && + (!useActiveDescendant || activeDescendantControlRef.current instanceof HTMLElement) + ) { + if (!disabled) { + const vanillaSettings: FocusZoneSettings = { + ...settings, + activeDescendantControl: activeDescendantControlRef.current ?? undefined + } + abortController.current = focusZone(containerRef.current, vanillaSettings) + return () => { + abortController.current?.abort() + } + } else { abortController.current?.abort() } - } else { - abortController.current?.abort() } - } + }, // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencies ?? []) + [disabled, ...dependencies] + ) return {containerRef, activeDescendantControlRef} } diff --git a/src/hooks/useOverlay.tsx b/src/hooks/useOverlay.tsx index 7898c971b5c..b3cb5b6a9ba 100644 --- a/src/hooks/useOverlay.tsx +++ b/src/hooks/useOverlay.tsx @@ -1,8 +1,7 @@ import {useOnOutsideClick, TouchOrMouseEvent} from './useOnOutsideClick' import {useOpenAndCloseFocus} from './useOpenAndCloseFocus' import {useOnEscapePress} from './useOnEscapePress' -import {AnchoredPositionHookSettings, useAnchoredPosition} from './useAnchoredPosition' -import {useRef} from 'react' +import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' export type UseOverlaySettings = { ignoreClickRefs?: React.RefObject[] @@ -10,32 +9,24 @@ export type UseOverlaySettings = { returnFocusRef: React.RefObject onEscape: (e: KeyboardEvent) => void onClickOutside: (e: TouchOrMouseEvent) => void - anchorRef: React.RefObject - positionDeps?: React.DependencyList - positionSettings?: AnchoredPositionHookSettings + overlayRef?: React.RefObject } export type OverlayReturnProps = { ref: React.RefObject - position: {top: number; left: number} | undefined } export const useOverlay = ({ - anchorRef, - positionSettings = {}, - positionDeps, + overlayRef: _overlayRef, returnFocusRef, initialFocusRef, onEscape, ignoreClickRefs, onClickOutside }: UseOverlaySettings): OverlayReturnProps => { - const overlayRef = useRef(null) - positionSettings.anchorElementRef = anchorRef - positionSettings.floatingElementRef = overlayRef + const overlayRef = useProvidedRefOrCreate(_overlayRef) useOpenAndCloseFocus({containerRef: overlayRef, returnFocusRef, initialFocusRef}) useOnOutsideClick({containerRef: overlayRef, ignoreClickRefs, onClickOutside}) useOnEscapePress(onEscape) - const {position} = useAnchoredPosition(positionSettings, positionDeps) - return {ref: overlayRef, position} + return {ref: overlayRef} } diff --git a/src/hooks/useRenderForcingRef.ts b/src/hooks/useRenderForcingRef.ts new file mode 100644 index 00000000000..60ddeb56fd1 --- /dev/null +++ b/src/hooks/useRenderForcingRef.ts @@ -0,0 +1,22 @@ +import {MutableRefObject, RefObject, useCallback, useRef, useState} from 'react' + +/** + * There are certain situations where a ref might be set after the current render cycle for a + * component has finished. e.g. a forward ref from a conditionally rendered child component. + * In these situations, we need to force a re-render, which is done here by the useState hook. + * @type TRef The type of the RefObject which should be created. + */ +export function useRenderForcingRef() { + const [refCurrent, setRefCurrent] = useState(null) + const ref = useRef(null) as MutableRefObject + ref.current = refCurrent + + const setRef = useCallback( + (newRef: TRef | null) => { + ref.current = newRef + setRefCurrent(newRef) + }, + [ref] + ) + return [ref as RefObject, setRef] as const +} diff --git a/src/index.ts b/src/index.ts index 7f549326a2c..2357ce448ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,9 @@ export type { DropdownItemProps, DropdownMenuProps } from './Dropdown' +export {DropdownButton, DropdownMenu} from './DropdownMenu' +// not exporting new DropdownMenu types yet due to conflict with Dropdown types above +// export type {DropdownButtonProps, DropdownMenuProps} from './DropdownMenu' export {default as FilteredSearch} from './FilteredSearch' export type {FilteredSearchProps} from './FilteredSearch' export {default as FilterList} from './FilterList' diff --git a/src/polyfills/eventListenerSignal.ts b/src/polyfills/eventListenerSignal.ts index 9d8d54148ec..450053f5f66 100644 --- a/src/polyfills/eventListenerSignal.ts +++ b/src/polyfills/eventListenerSignal.ts @@ -33,6 +33,10 @@ function featureSupported(): boolean { } function monkeyPatch() { + if (typeof window === 'undefined') { + return + } + const originalAddEventListener = EventTarget.prototype.addEventListener EventTarget.prototype.addEventListener = function (name, originalCallback, optionsOrCapture) { if ( diff --git a/src/stories/DropdownMenu.stories.tsx b/src/stories/DropdownMenu.stories.tsx new file mode 100644 index 00000000000..ca0112d34a3 --- /dev/null +++ b/src/stories/DropdownMenu.stories.tsx @@ -0,0 +1,52 @@ +import {Meta} from '@storybook/react' +import React from 'react' +import {theme, ThemeProvider} from '..' +import {ItemInput} from '../ActionList/List' +import BaseStyles from '../BaseStyles' +import {DropdownMenu, DropdownButton} from '../DropdownMenu' + +const meta: Meta = { + title: 'Composite components/DropdownMenu', + component: DropdownMenu, + decorators: [ + (Story: React.ComponentType): JSX.Element => { + return ( + + + + + + ) + } + ], + parameters: { + controls: { + disable: true + } + } +} +export default meta + +export function FavoriteColorStory(): JSX.Element { + const items = React.useMemo(() => [{text: '🔵 Cyan'}, {text: '🔴 Magenta'}, {text: '🟡 Yellow'}], []) + const [selectedItem, setSelectedItem] = React.useState() + + return ( + <> +

Favorite Color

+
Please select your favorite color:
+ ( + + {children} + + )} + placeholder="🎨" + items={items} + selectedItem={selectedItem} + onChange={setSelectedItem} + /> + + ) +} +FavoriteColorStory.storyName = 'Favorite Color' diff --git a/src/stories/Overlay.stories.tsx b/src/stories/Overlay.stories.tsx index e2dbcd31640..450939f6781 100644 --- a/src/stories/Overlay.stories.tsx +++ b/src/stories/Overlay.stories.tsx @@ -48,9 +48,8 @@ export const DropdownOverlay = () => { - {isOpen && ( + {isOpen ? ( { Delete - )} + ) : null} ) } @@ -82,10 +81,8 @@ export const DialogOverlay = () => { - {isOpen && ( + {isOpen ? ( { - )} + ) : null} ) } diff --git a/src/stories/Portal.stories.tsx b/src/stories/Portal.stories.tsx index 306cec6d8ae..97d5b92c186 100644 --- a/src/stories/Portal.stories.tsx +++ b/src/stories/Portal.stories.tsx @@ -62,7 +62,7 @@ export const CustomPortalRootByRegistration: React.FC> = ( <> Root position - {mounted && ( + {mounted ? ( <> Outer container @@ -70,7 +70,7 @@ export const CustomPortalRootByRegistration: React.FC> = ( Portaled content rendered at the outer container. - )} + ) : null} ) @@ -93,7 +93,7 @@ export const MultiplePortalRoots: React.FC> = () => { Outer container - {mounted && ( + {mounted ? ( <> Portaled content rendered at the outer container. Portaled content rendered at the end of the inner container. @@ -101,7 +101,7 @@ export const MultiplePortalRoots: React.FC> = () => { Portaled content rendered at <BaseStyles> root. - )} + ) : null} Inner container diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx index 0c510883f48..3bbdc6b9a3e 100644 --- a/src/stories/useAnchoredPosition.stories.tsx +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -295,7 +295,7 @@ export const WithPortal = () => { }> Show the overlay! - {showMenu && ( + {showMenu ? ( } @@ -308,7 +308,7 @@ export const WithPortal = () => { An un-constrained overlay! - )} + ) : null} diff --git a/src/utils/types.ts b/src/utils/types.ts index b68feb8c60e..c43ed7bc471 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -16,3 +16,75 @@ export type ComponentProps = T extends React.ComponentType * Contruct a type describing the items in `T`, if `T` is an array. */ export type Flatten = T extends (infer U)[] ? U : never + +// ref: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques +export type AriaRole = + | 'alert' + | 'alertdialog' + | 'application' + | 'article' + | 'banner' + | 'button' + | 'cell' + | 'checkbox' + | 'columnheader' + | 'combobox' + | 'complementary' + | 'contentinfo' + | 'definition' + | 'dialog' + | 'directory' + | 'document' + | 'feed' + | 'figure' + | 'form' + | 'grid' + | 'gridcell' + | 'group' + | 'heading' + | 'img' + | 'link' + | 'list' + | 'listbox' + | 'listitem' + | 'log' + | 'main' + | 'marquee' + | 'math' + | 'menu' + | 'menubar' + | 'menuitem' + | 'menuitemcheckbox ' + | 'menuitemradio' + | 'navigation' + | 'none' + | 'note' + | 'option' + | 'presentation' + | 'progressbar' + | 'radio' + | 'radiogroup' + | 'region' + | 'row' + | 'rowgroup' + | 'rowheader' + | 'scrollbar' + | 'search' + | 'searchbox' + | 'separator' + | 'slider' + | 'spinbutton' + | 'status' + | 'switch' + | 'tab' + | 'table' + | 'tablist' + | 'tabpanel' + | 'term' + | 'textbox' + | 'timer' + | 'toolbar' + | 'tooltip' + | 'tree' + | 'treegrid' + | 'treeitem'