Skip to content

DropdownMenu #1135

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 56 commits into from
Apr 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
2898c3c
chore: Merge branch 'overlay' into dropdownmenu
smockle Mar 25, 2021
ae7841e
chore: Merge branch 'main' into dropdownmenu
smockle Mar 25, 2021
f45dfdb
feat: Add DropdownMenu
smockle Mar 25, 2021
98f948e
fix: Don’t hardcode a palette emoji in DropdownMenu; it’s typically n…
smockle Mar 26, 2021
bb8e06d
feat: Enable deselection; draw checks by selected items and empty spa…
smockle Mar 26, 2021
d9576c8
feat: Add DropdownButton; begin adding required roles and other attri…
smockle Mar 26, 2021
7bce122
chore: Merge branch 'main' into dropdownmenu
smockle Apr 5, 2021
e3869a7
fix: Imported types, plus Storybook warnings, plus debug List-level c…
smockle Apr 7, 2021
b812e73
fix: Restore 'List'-level custom 'Item' rendering.
smockle Apr 7, 2021
0cdaab2
chore: Merge branch 'main' into dropdownmenu
smockle Apr 7, 2021
007ce3b
chore: Merge 'dropdown-with-zones' into 'dropdownmenu'
smockle Apr 12, 2021
16751fb
fix: keep focus on anchor when dropdown menu opens
dgreif Apr 12, 2021
0029c37
fix: Rename focus-related state and values to distinguish/dedupe from…
smockle Apr 13, 2021
5edc447
chore: Run Prettier
smockle Apr 13, 2021
f7bd529
fix: Add 'onDismiss' to the dependency list for 'onAnchorKeyDown' to …
smockle Apr 13, 2021
fd369a3
fix: force render after overlay ref is set
dgreif Apr 13, 2021
3ec6103
fix(dropdown): treat enter and space on item as an activation
dgreif Apr 14, 2021
ccecdd1
fix: only prevent default if item is activated
dgreif Apr 14, 2021
4a545f9
docs: basic docs in dropdown menu
dgreif Apr 14, 2021
6edab5a
docs: add `DropdownMenu` docs
dgreif Apr 14, 2021
9b8e9fd
chore: add basic tests to DropdownMenu
VanAnderson Apr 14, 2021
e89cf1e
fix: add placeholder value to DropdownMenu
VanAnderson Apr 14, 2021
ff6e266
fix: fix linter by removing unused import
VanAnderson Apr 14, 2021
275ef22
refactor: allow DropdownMenu state to be controlled by parent
dgreif Apr 14, 2021
a789c3f
docs: remove positioning form overlay docs
dgreif Apr 14, 2021
9fd1e23
fix: typescript fixes
dgreif Apr 14, 2021
8eb67b4
chore: delete isRefObject
VanAnderson Apr 15, 2021
1904563
add selectedItem to docs
VanAnderson Apr 15, 2021
d3cc00d
remove random dash in docs
VanAnderson Apr 15, 2021
718b99d
make DropdownMenu a controlled component in the docs
VanAnderson Apr 15, 2021
6c0ea65
restore custom anchor for DropdownMenu in storybook story
VanAnderson Apr 15, 2021
ae943e4
fix: avoid signal monkey patch during gatsby server side render
dgreif Apr 15, 2021
4fd91cf
docs: memoize dropdown menu items in example
dgreif Apr 15, 2021
398a9d8
docs: useRenderForcingRef
dgreif Apr 15, 2021
d3d9b87
docs: fix renderItems typo
dgreif Apr 15, 2021
1fa39d4
fix: Readable yet equivalent 'aria-selected'. Fix typo.
smockle Apr 15, 2021
800a738
fix: Replace 'ChevronIcon' with 'TriangleDownIcon' per latest style g…
smockle Apr 15, 2021
710123a
refactor: use ternary for conditional child component rendering
dgreif Apr 15, 2021
c64b970
test: update snapshot for DropdownMenu
dgreif Apr 15, 2021
33863d8
fix: use custom theme provider
dgreif Apr 15, 2021
edf01d5
fix: export tweaks
dgreif Apr 15, 2021
1e89b2a
Merge branch 'main' into dropdownmenu
dgreif Apr 15, 2021
42e8658
fix: cleanup after merging master
dgreif Apr 15, 2021
7e74ab1
refactor: `setSelectedItem` -> `onChange`
dgreif Apr 15, 2021
f8b0609
chore: add comment explaining lack of DropdownMenu type exports
dgreif Apr 16, 2021
7440928
refactor: `includes` instead of `indexOf`
dgreif Apr 16, 2021
77a2e38
docs: replace `setSelectedItem` with `onChange`
dgreif Apr 16, 2021
848d2fd
refactor: default value for dependency arrays
dgreif Apr 16, 2021
9735116
refactor: simplify event key checks
dgreif Apr 16, 2021
04946b4
chore: remove display name for `DropdownButton`
dgreif Apr 16, 2021
75f9ed5
refactor: optional chaining for item event handlers
dgreif Apr 16, 2021
33c4374
refactor: `itemActivated` -> `handleSelection`
dgreif Apr 16, 2021
8eabeee
refactor: remove `randomId` in favor of `uniqueId`
dgreif Apr 16, 2021
9054617
perf: useCallback for renderItem
dgreif Apr 16, 2021
029ffad
Merge branch 'main' into dropdownmenu
dgreif Apr 16, 2021
20ebbe1
Merge branch 'main' into dropdownmenu
smockle Apr 16, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/ActionList.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
42 changes: 42 additions & 0 deletions docs/content/DropdownMenu.mdx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => (
<DropdownButton aria-labelledby={`favorite-color-label ${ariaLabelledBy}`} {...anchorProps}>
{children}
</DropdownButton>
)}
placeholder="🎨"
items={items}
selectedItem={selectedItem}
onChange={setSelectedItem}
/>
)
}

render(<DemoComponent />)
```

## 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. |
39 changes: 17 additions & 22 deletions docs/content/Overlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -39,9 +38,8 @@ const Demo = () => {
open overlay
</Button>
{/* be sure to conditionally render the Overlay. This helps with performance and is required. */}
{isOpen &&
{isOpen && (
<Overlay
anchorRef={anchorRef}
initialFocusRef={noButtonRef}
returnFocusRef={anchorRef}
ignoreClickRefs={[anchorRef]}
Expand All @@ -51,17 +49,16 @@ const Demo = () => {
>
<Flex flexDirection="column" p={2}>
<Text id="title">Are you sure you would like to delete this item?</Text>
<Button >yes</Button>
<Button>yes</Button>
<Button ref={noButtonRef}>no</Button>
</Flex>
</Overlay>
}

)}
</>
)
}

render(<Demo/>)
render(<Demo />)
```

## System props
Expand All @@ -70,15 +67,13 @@ render(<Demo/>)

## 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<HTMLElement> []` | `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<HTMLElement>` | `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<HTMLElement>` | `undefined` | Required. Element the `Overlay` should be anchored to. |
| returnFocusRef | `React.RefObject<HTMLElement>` | `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<HTMLElement> []` | `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<HTMLElement>` | `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<HTMLElement>` | `undefined` | Required. Element the `Overlay` should be anchored to. |
| returnFocusRef | `React.RefObject<HTMLElement>` | `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`. |
14 changes: 11 additions & 3 deletions src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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>`
Expand Down Expand Up @@ -98,12 +104,14 @@ export function Item({
text,
description,
descriptionVariant = 'inline',
selected,
leadingVisual: LeadingVisual,
variant = 'default',
...props
}: Partial<ItemProps>): JSX.Element {
}: Partial<ItemProps> & {item: ItemInput}): JSX.Element {
return (
<StyledItem variant={variant} {...props}>
<StyledItem tabIndex={-1} variant={variant} aria-selected={selected} {...props}>
{!!selected === selected && <LeadingVisualContainer>{selected && <CheckIcon />}</LeadingVisualContainer>}
{LeadingVisual && (
<LeadingVisualContainer>
<LeadingVisual />
Expand Down
35 changes: 25 additions & 10 deletions src/ActionList/List.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {AriaRole} from '../utils/types'
import {Group, GroupProps} from './Group'
import {Item, ItemProps} from './Item'
import React from 'react'
Expand All @@ -6,28 +7,35 @@ import styled from 'styled-components'
import {get} from '../constants'
import {SystemCssProperties} from '@styled-system/css'

export type ItemInput = ItemProps | (Partial<ItemProps> & {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<ItemProps> & {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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<ItemProps> & {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
})

/**
Expand All @@ -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.

Expand All @@ -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
)
]
})
}
Expand Down
15 changes: 15 additions & 0 deletions src/DropdownMenu/DropdownButton.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement, React.PropsWithChildren<DropdownButtonProps>>(
({children, ...props}: React.PropsWithChildren<DropdownButtonProps>, ref): JSX.Element => (
<Button ref={ref} {...props}>
{children}
<StyledOcticon icon={TriangleDownIcon} ml={1} />
</Button>
)
)
Loading