Skip to content

Action Menu #1152

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 125 commits into from
Apr 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
125 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
55161b5
feat: Style 'ActionListSectionHeader'
smockle Feb 24, 2021
4ff6835
basic structure for ActionMenu
VanAnderson Apr 2, 2021
f921cd5
progress
VanAnderson Apr 2, 2021
52241d6
clean up ActionMenu and add displayNames
VanAnderson Apr 5, 2021
3c9dec9
update ActionMenu Stories to reflect changing API
VanAnderson Apr 5, 2021
8604e25
start to ActionMenu docs
VanAnderson Apr 5, 2021
9f12d0a
tentative updates to ActionList
VanAnderson Apr 5, 2021
9b682f1
ActionMenu tests
VanAnderson Apr 5, 2021
44721f8
update ActionMenu docs
VanAnderson Apr 5, 2021
73caa51
remove commented code
VanAnderson Apr 5, 2021
da8b54b
refigure ActionMenuProps interface
VanAnderson Apr 6, 2021
786220a
implement styling fix, storybook fix
VanAnderson Apr 6, 2021
d3300c4
ActionMenu supports keyboard keys
VanAnderson Apr 7, 2021
b715b4e
Fix some TypeScript errors
VanAnderson Apr 7, 2021
d509b91
linter fixes
VanAnderson Apr 7, 2021
d298159
passing tests for ActionMenu
VanAnderson Apr 7, 2021
eca0669
type get-random-values module
VanAnderson Apr 7, 2021
1c3f4e1
add tests for ActionMenu
VanAnderson Apr 8, 2021
4bf14c2
ActionMenu buttonContent -> triggerContent
VanAnderson Apr 8, 2021
cb023bd
linter fixes
VanAnderson Apr 8, 2021
ba7fc30
remove cruft from ActionList rebase
VanAnderson Apr 8, 2021
cd72015
small spacing fixes
VanAnderson Apr 8, 2021
e73eb87
fix typescript error
VanAnderson Apr 8, 2021
cd008de
fix some typescript errors
VanAnderson Apr 8, 2021
9276de4
use proper AriaRole prop
VanAnderson Apr 8, 2021
fdeada6
revise props on ActionList and remove package dependency
VanAnderson Apr 9, 2021
8e414ad
update docs for ActionMenu
VanAnderson Apr 9, 2021
e6dcb7d
Add focus trap and focus zone support to dropdowns.
T-Hugs Apr 8, 2021
4c5df63
Fix lint issues
dgreif Apr 8, 2021
52404d4
Complete implementation of keyboard nav in DropdownMenu.
T-Hugs Apr 9, 2021
54b55c9
remove focus first item behavior from useOpenAndCloseFocus
VanAnderson Apr 12, 2021
dd11702
restore implicity initial focus functionality for useOpenAndCloseFocu…
VanAnderson Apr 13, 2021
2746286
add focusTrap and focusZone dependencies, correct anchorClick handler
VanAnderson Apr 13, 2021
2a3c288
add onAnchorKeydown callback to anchor
VanAnderson Apr 13, 2021
d196ecb
remove some extra cruft from merges
VanAnderson Apr 13, 2021
a96a3e0
Remove d.ts checkbox from pr template (#1155)
dgreif Apr 8, 2021
dba11d2
Add as prop to ComponentProps
colebemis Apr 8, 2021
5114a71
Move as prop definition
colebemis Apr 8, 2021
f92dfce
Update as prop type
colebemis Apr 8, 2021
4ea9a1a
Update as prop type
colebemis Apr 8, 2021
7532fc2
Update as prop type
colebemis Apr 8, 2021
f11d445
Check default portal still exists before using it (#1153)
dgreif Apr 8, 2021
6db244a
Background styles for focused action list item
dgreif Apr 9, 2021
45d1ee7
swap 'trailing' for 'auxilary' in ListItem
VanAnderson Apr 13, 2021
a3f11d3
trainlingText and trailingIcon are grouped together
VanAnderson Apr 13, 2021
0006819
add groupId to ActionList/Item
VanAnderson Apr 13, 2021
d59d5d8
adjust styling on trailingText and trailingIcon
VanAnderson Apr 14, 2021
5f18587
fix: rebase mishaps
VanAnderson Apr 15, 2021
c77ad1b
Revise some details with ActionMenu tests
VanAnderson Apr 15, 2021
63b1ebb
item input is optional on Item
VanAnderson Apr 15, 2021
ea1e4f3
fix typo in ActionMenu docs
VanAnderson Apr 15, 2021
b45d0b0
fix typo in docs
VanAnderson Apr 15, 2021
538c933
revisions to ActionMenu and associated tests
VanAnderson 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
b68acdf
onActivate -> onAction for ActionMenu
VanAnderson Apr 15, 2021
716cd2c
triggerContent -> anchorContent for ActionMenu
VanAnderson Apr 15, 2021
8493e6a
add renderAnchor to ActionMenu docs
VanAnderson Apr 15, 2021
1e89b2a
Merge branch 'main' into dropdownmenu
dgreif Apr 15, 2021
a2d4df1
Change wording in docs/content/ActionMenu.mdx
VanAnderson 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
807d6ae
Merge branch 'dropdownmenu' into VanAnderson/action-menu
VanAnderson Apr 16, 2021
9978e13
implement uniqueId for ActionMenu
VanAnderson Apr 16, 2021
9c8b666
don't extend ActionList from div
VanAnderson Apr 16, 2021
d6095b9
registerPortalRoot root is optional
VanAnderson Apr 16, 2021
9a14317
Merge branch 'main' into VanAnderson/action-menu
VanAnderson Apr 16, 2021
7cf5cc8
linter fix on Item
VanAnderson Apr 16, 2021
b90b79a
clean up Update src/ActionMenu.tsx click and keypress handlers
VanAnderson Apr 16, 2021
62f6fc4
knock out right margin for trailingContainer
VanAnderson Apr 16, 2021
4a3afe2
add ActionMenu groupMetadata example
VanAnderson Apr 16, 2021
b2bd62f
tweaks to Grouped ActionMenu examples.
VanAnderson Apr 16, 2021
88ac566
useRenderForcingRef in ActionMenu
VanAnderson Apr 16, 2021
7f6a811
Revert "registerPortalRoot root is optional"
VanAnderson Apr 16, 2021
93cdab5
import useRenderForcingRef in ActionMenu
VanAnderson Apr 16, 2021
38bbce1
remove registerPortalRoot(undefined)
VanAnderson Apr 16, 2021
91da62d
remove registerPortalRoot from imports
VanAnderson 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
74 changes: 74 additions & 0 deletions docs/content/ActionMenu.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: ActionMenu
---

An `ActionMenu` is an ActionList-based component for creating a menu of actions that expands through a trigger button.

## Default example

```jsx live
<ActionMenu
anchorContent="Menu"
onAction={({text}) => console.log(text)}
items={[
{text: 'New file'},
ActionMenu.Divider,
{text: 'Copy link'},
{text: 'Edit file'},
{text: 'Delete file', variant: 'danger'}
]}
/>
```

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add an example that uses groupMetadata?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, just pulled from our ActionList docs on this one and kept them parallel.

## Example with grouped items

```jsx live
<ActionMenu
anchorContent="Menu"
onAction={({text}) => console.log(text)}
groupMetadata={[
{groupId: '0'},
{groupId: '1', header: {title: 'Live query', variant: 'subtle'}},
{groupId: '2', header: {title: 'Layout', variant: 'subtle'}},
{groupId: '3'},
{groupId: '4'}
]}
items={[
{leadingVisual: TypographyIcon, text: 'Rename', groupId: '0'},
{leadingVisual: VersionsIcon, text: 'Duplicate', groupId: '0'},
{leadingVisual: SearchIcon, text: 'repo:github/github', groupId: '1'},
{
leadingVisual: NoteIcon,
text: 'Table',
description: 'Information-dense table optimized for operations across teams',
descriptionVariant: 'block',
groupId: '2'
},
{
leadingVisual: ProjectIcon,
text: 'Board',
description: 'Kanban-style board focused on visual states',
descriptionVariant: 'block',
groupId: '2'
},
{
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'}
]}
/>
```

## Component props

| Name | Type | Default | Description |
| :------------ | :------------------------------------ | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| items | `ItemProps[]` | `undefined` | Required. A list of item objects conforming to the `ActionList.Item` props interface. |
| 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. |
| renderAnchor | `(props: ButtonProps) => JSX.Element` | `Button` | 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. |
| anchorContent | React.ReactNode | `undefined` | Optional. If defined, it will be passed to the trigger as the elements child. |
| onAction | (props: ItemProps) => void | `undefined` | Optional. If defined, this function will be called when a menu item is activated either by a click or a keyboard press. |
16 changes: 14 additions & 2 deletions docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
MailIcon,
GitCommitIcon,
FlameIcon,
MarkGithubIcon
MarkGithubIcon,
NoteIcon,
ProjectIcon,
FilterIcon,
GearIcon,
TypographyIcon,
VersionsIcon
} from '@primer/octicons-react'
import State from '../../../components/State'

Expand All @@ -29,5 +35,11 @@ export default {
MailIcon,
GitCommitIcon,
FlameIcon,
MarkGithubIcon
MarkGithubIcon,
NoteIcon,
ProjectIcon,
FilterIcon,
GearIcon,
TypographyIcon,
VersionsIcon
}
57 changes: 47 additions & 10 deletions src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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'
import styled from 'styled-components'

/**
* Contract for props passed to the `Item` component.
Expand Down Expand Up @@ -32,6 +32,16 @@ export interface ItemProps extends React.ComponentPropsWithoutRef<'div'>, SxProp
*/
leadingVisual?: React.FunctionComponent<IconProps>

/**
* Icon (or similar) positioned after `Item` text.
*/
trailingIcon?: React.FunctionComponent<IconProps>

/**
* Text positioned after `Item` text and optional trailing icon.
*/
trailingText?: string

/**
* Style variations associated with various `Item` types.
*
Expand All @@ -44,6 +54,11 @@ export interface ItemProps extends React.ComponentPropsWithoutRef<'div'>, SxProp
* For `Item`s which can be selected, whether the `Item` is currently selected.
*/
selected?: boolean

/**
* Designates the group that an item belongs to.
*/
groupId?: string
}

const StyledItem = styled.div<{variant: ItemProps['variant']} & SxProp>`
Expand Down Expand Up @@ -72,14 +87,10 @@ const StyledTextContainer = styled.div<{descriptionVariant: ItemProps['descripti
flex-direction: ${({descriptionVariant}) => (descriptionVariant === 'inline' ? 'row' : 'column')};
`

const LeadingVisualContainer = styled.div`
{
/* Match visual height to adjacent text line height.
*
* TODO: When rem-based spacing on a 4px scale lands, replace
* hardcoded '20px' with '${get('space.s20')}'.
*/
}
const BaseVisualContainer = styled.div`
// Match visual height to adjacent text line height.
// TODO: When rem-based spacing on a 4px scale lands, replace
// hardcoded '20px' with '${get('space.s20')}'.
height: 20px;
width: ${get('space.3')};
display: flex;
Expand All @@ -93,6 +104,20 @@ const LeadingVisualContainer = styled.div`
}
`

const LeadingVisualContainer = styled(BaseVisualContainer)``

const TrailingVisualContainer = styled(BaseVisualContainer)`
color: ${get('colors.icon.tertiary')};
margin-left: auto;
margin-right: 0;
div:nth-child(2) {
margin-left: ${get('space.2')};
}
display: flex;
flex-direction: row;
justify-content: flex-end;
`

const DescriptionContainer = styled.span`
color: ${get('colors.text.secondary')};
`
Expand All @@ -106,9 +131,11 @@ export function Item({
descriptionVariant = 'inline',
selected,
leadingVisual: LeadingVisual,
trailingIcon: TrailingIcon,
trailingText,
variant = 'default',
...props
}: Partial<ItemProps> & {item: ItemInput}): JSX.Element {
}: Partial<ItemProps> & {item?: ItemInput}): JSX.Element {
return (
<StyledItem tabIndex={-1} variant={variant} aria-selected={selected} {...props}>
{!!selected === selected && <LeadingVisualContainer>{selected && <CheckIcon />}</LeadingVisualContainer>}
Expand All @@ -121,6 +148,16 @@ export function Item({
<div>{text}</div>
{description && <DescriptionContainer>{description}</DescriptionContainer>}
</StyledTextContainer>
{(TrailingIcon || trailingText) && (
<TrailingVisualContainer>
{trailingText && <div>{trailingText}</div>}
{TrailingIcon && (
<div>
<TrailingIcon />
</div>
)}
</TrailingVisualContainer>
)}
</StyledItem>
)
}
2 changes: 1 addition & 1 deletion src/ActionList/List.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react'
import type {AriaRole} from '../utils/types'
import {Group, GroupProps} from './Group'
import {Item, ItemProps} from './Item'
import React from 'react'
import {Divider} from './Divider'
import styled from 'styled-components'
import {get} from '../constants'
Expand Down
132 changes: 132 additions & 0 deletions src/ActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {List, ListPropsBase, GroupedListProps} from './ActionList/List'
import {Item, ItemProps} from './ActionList/Item'
import {Divider} from './ActionList/Divider'
import Button, {ButtonProps} from './Button'
import React, {useCallback, useRef, useState} from 'react'
import Overlay from './Overlay'
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 ActionMenuProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderAnchor?: (props: any) => JSX.Element
anchorContent?: React.ReactNode
onAction?: (props: ItemProps) => void
}

const ActionMenuItem = (props: ItemProps) => <Item role="menuitem" {...props} />

ActionMenuItem.displayName = 'ActionMenu.Item'

const ActionMenuBase = ({
anchorContent,
renderAnchor = <T extends ButtonProps>(props: T) => <Button {...props}>{anchorContent}</Button>,
renderItem = Item,
onAction,
...listProps
}: ActionMenuProps): JSX.Element => {
const anchorRef = useRef<HTMLElement>(null)
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
const anchorId = `dropdownMenuAnchor-${uniqueId()}`
const [open, setOpen] = useState<boolean>(false)
const [state, setState] = useState<'closed' | 'buttonFocus' | 'listFocus'>('closed')
const onDismiss = useCallback(() => {
setOpen(false)
setState('closed')
}, [])

const onAnchorKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (!event.defaultPrevented) {
if (state === 'closed') {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
setState('listFocus')
setOpen(true)
event.preventDefault()
} else if (event.key === ' ' || event.key === 'Enter') {
setState('buttonFocus')
setOpen(true)
event.preventDefault()
}
} else if (state === 'buttonFocus') {
if (['ArrowDown', 'ArrowUp', 'Tab', 'Enter'].indexOf(event.key) !== -1) {
setState('listFocus')
event.preventDefault()
} else if (event.key === 'Escape') {
setState('closed')
onDismiss()
event.preventDefault()
}
}
}
},
[state, onDismiss]
)
const onAnchorClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!event.defaultPrevented && event.button === 0 && !open) {
setOpen(true)
setState('buttonFocus')
}
},
[open]
)

const {position} = useAnchoredPosition({anchorElementRef: anchorRef, floatingElementRef: overlayRef})

useFocusZone({containerRef: overlayRef, disabled: !open || state !== 'listFocus'}, [position])
useFocusTrap({containerRef: overlayRef, disabled: !open || state !== 'listFocus'}, [position])

return (
<>
{renderAnchor({
ref: anchorRef,
id: anchorId,
'aria-labelledby': anchorId,
'aria-haspopup': 'listbox',
'aria-label': 'menu',
onClick: onAnchorClick,
onKeyDown: onAnchorKeyDown,
children: anchorContent,
tabIndex: 0
})}
{open ? (
<Overlay
initialFocusRef={anchorRef}
returnFocusRef={anchorRef}
onClickOutside={onDismiss}
onEscape={onDismiss}
ref={updateOverlayRef}
{...position}
>
<List
{...listProps}
role="menu"
renderItem={({onClick, ...itemProps}) =>
renderItem({
...itemProps,
role: 'menuitem',
onKeyPress: _event => {
onAction?.(itemProps as ItemProps)
onDismiss()
},
onClick: event => {
onAction?.(itemProps as ItemProps)
onClick?.(event)
onDismiss()
}
})
}
/>
</Overlay>
) : null}
</>
)
}

ActionMenuBase.displayName = 'ActionMenu'

export const ActionMenu = Object.assign(ActionMenuBase, {Divider: Divider, Item: ActionMenuItem})
Loading