diff --git a/.changeset/actionmenu-overlay.md b/.changeset/actionmenu-overlay.md new file mode 100644 index 00000000000..adaa01d36cc --- /dev/null +++ b/.changeset/actionmenu-overlay.md @@ -0,0 +1,5 @@ +--- +'@primer/components': patch +--- + +ActionMenu v2: Added `ActionMenu.Overlay` which accepts props to customize the Menu overlay. diff --git a/docs/content/drafts/ActionMenu2.mdx b/docs/content/drafts/ActionMenu2.mdx index 24f3727dfec..7767f230c1d 100644 --- a/docs/content/drafts/ActionMenu2.mdx +++ b/docs/content/drafts/ActionMenu2.mdx @@ -1,5 +1,5 @@ --- -title: ActionMenu +title: ActionMenu v2 status: Alpha source: https://github.com/primer/react/tree/main/src/ActionMenu storybook: '/react/storybook?path=/story/composite-components-actionmenu2' @@ -13,29 +13,30 @@ import {Props} from '../../src/props'
- - Menu - - onSelect('Copy link')}> - Copy link - ⌘C - - onSelect('Quote reply')}> - Quote reply - ⌘Q - - onSelect('Edit comment')}> - Edit comment - ⌘E - - - onSelect('Delete file')}> - Delete file - ⌘D - - - - + + Menu + + + + Copy link + ⌘C + + + Quote reply + ⌘Q + + + Edit comment + ⌘E + + + + Delete file + ⌘D + + + +
@@ -50,7 +51,7 @@ import {ActionMenu} from '@primer/components/drafts' ### Minimal example -`ActionMenu` ships with `ActionMenu.Button` which is an accessible trigger for the overlay. It's recommended to compose `ActionList` with this component. +`ActionMenu` ships with `ActionMenu.Button` which is an accessible trigger for the overlay. It's recommended to compose `ActionList` with `ActionMenu.Overlay`   @@ -62,13 +63,15 @@ render( Menu - - console.log('New file')}>New file - Copy link - Edit file - - Delete file - + + + console.log('New file')}>New file + Copy link + Edit file + + Delete file + + ) ``` @@ -91,26 +94,28 @@ render( - - - - - - Rename - - - - - - Archive all cards - - - - - - Delete - - + + + + + + + Rename + + + + + + Archive all cards + + + + + + Delete + + + ) ``` @@ -125,59 +130,61 @@ render( Open column menu - - - - - - - repo:github/memex,github/github - - - - - - - - - Table - - Information-dense table optimized for operations across teams - - - - - - - Board - Kanban-style board focused on visual states - - - - - - - - - Save sort and filters to current view - - - - - - Save sort and filters to new view - - - - - - - - - View settings - - - + + + + + + + + repo:github/memex,github/github + + + + + + + + + Table + + Information-dense table optimized for operations across teams + + + + + + + Board + Kanban-style board focused on visual states + + + + + + + + + Save sort and filters to current view + + + + + + Save sort and filters to new view + + + + + + + + + View settings + + + + ) ``` @@ -201,13 +208,15 @@ const Example = () => { - - Copy link - Quote reply - Edit comment - - Delete file - + + + Copy link + Quote reply + Edit comment + + Delete file + + ) @@ -216,17 +225,52 @@ const Example = () => { render() ``` +### With Overlay Props + +To create an anchor outside of the menu, you need to switch to controlled mode for the menu and pass it as `anchorRef` to `ActionMenu`: + +```javascript live noinline +// import {ActionMenu, ActionList} from '@primer/components/drafts' +const {ActionMenu, ActionList} = drafts // ignore docs silliness; import like that ↑ + +const handleEscape = () => alert('you hit escape!') + +render( + + Open Actions Menu + + + + Open current Codespace + + Your existing Codespace will be opened to its previous state, and you'll be asked to manually switch to + new-branch. + + ⌘O + + + Create new Codespace + + Create a brand new Codespace with a fresh image and checkout this branch. + + ⌘C + + + + +) +``` + ## Props / API reference ### ActionMenu -| Name | Type | Default | Description | -| :----------- | :-------------------------------------------------- | :-----: | :----------------------------------------------------------------------------------------------------------------------- | -| children\* | `React.ReactElement[]` | - | Required. Recommended: `ActionMenu.Button` or `ActionMenu.Anchor` with [`ActionList`](/drafts/ActionList2) | -| open | `boolean` | - | Optional. If defined, will control the open/closed state of the overlay. Must be used in conjuction with `onOpenChange`. | -| onOpenChange | `(open: boolean) => void` | - | Optional. If defined, will control the open/closed state of the overlay. Must be used in conjuction with `open`. | -| anchorRef | `React.RefObject` | - | Optional. Useful for defining an external anchor | -| overlayProps | [`Partial`](/Overlay#component-props) | - | Optional. Props to be spread on the internal [`AnchoredOverlay`](/AnchoredOverlay) component. | +| Name | Type | Default | Description | +| :----------- | :----------------------------- | :-----: | :----------------------------------------------------------------------------------------------------------------------- | +| children\* | `React.ReactElement[]` | - | Required. Recommended: `ActionMenu.Button` or `ActionMenu.Anchor` with `ActionMenu.Overlay` | +| open | `boolean` | - | Optional. If defined, will control the open/closed state of the overlay. Must be used in conjuction with `onOpenChange`. | +| onOpenChange | `(open: boolean) => void` | - | Optional. If defined, will control the open/closed state of the overlay. Must be used in conjuction with `open`. | +| anchorRef | `React.RefObject` | - | Optional. Useful for defining an external anchor | ### ActionMenu.Button @@ -240,6 +284,13 @@ render() | :--------- | :------------------- | :-----: | :-------------------------------- | | children\* | `React.ReactElement` | - | Required. Accepts a single child. | +### ActionMenu.Overlay + +| Name | Type | Default | Description | +| :--------------------------------------- | :-------------------- | :-----------------: | :-------------------------------------------------------------------------------------------- | +| children\* | `React.ReactElement[] | React.ReactElement` | Required. Recommended: [`ActionList`](/drafts/ActionList2) | +| [OverlayProps](/Overlay#component-props) | - | - | Optional. Props to be spread on the internal [`AnchoredOverlay`](/AnchoredOverlay) component. | + ## Further reading [Interface guidelines: Action List + Menu](https://primer.style/design/components/action-list) diff --git a/src/ActionList2/ActionListContainerContext.tsx b/src/ActionList2/ActionListContainerContext.tsx new file mode 100644 index 00000000000..98b4c6cb374 --- /dev/null +++ b/src/ActionList2/ActionListContainerContext.tsx @@ -0,0 +1,14 @@ +/** This context can be used by components that compose ActionList inside a Menu */ + +import React from 'react' + +type ContextProps = { + container?: string + listRole?: string + itemRole?: string + // This can be any function, we don't know anything about the arguments + // to be more specific here, this is as good as (...args: any[]) => unknown + // eslint-disable-next-line @typescript-eslint/ban-types + afterSelect?: Function +} +export const ActionListContainerContext = React.createContext({}) diff --git a/src/ActionList2/Item.tsx b/src/ActionList2/Item.tsx index ec650b17823..a56f75f13fa 100644 --- a/src/ActionList2/Item.tsx +++ b/src/ActionList2/Item.tsx @@ -8,7 +8,7 @@ import sx, {SxProp, merge} from '../sx' import createSlots from '../utils/create-slots' import {AriaRole} from '../utils/types' import {ListContext} from './List' -import {MenuContext} from './MenuContext' +import {ActionListContainerContext} from './ActionListContainerContext' import {Selection} from './Selection' export const getVariantStyles = (variant: ItemProps['variant'], disabled: ItemProps['disabled']) => { @@ -102,7 +102,7 @@ export const Item = React.forwardRef( forwardedRef ): JSX.Element => { const {variant: listVariant, showDividers} = React.useContext(ListContext) - const {itemRole, afterSelect} = React.useContext(MenuContext) + const {itemRole, afterSelect} = React.useContext(ActionListContainerContext) const {theme} = useTheme() @@ -171,10 +171,9 @@ export const Item = React.forwardRef( const clickHandler = React.useCallback( event => { - if (typeof onSelect !== 'function') return if (disabled) return if (!event.defaultPrevented) { - onSelect(event) + if (typeof onSelect === 'function') onSelect(event) // if this Item is inside a Menu, close the Menu if (typeof afterSelect === 'function') afterSelect() } @@ -184,10 +183,9 @@ export const Item = React.forwardRef( const keyPressHandler = React.useCallback( event => { - if (typeof onSelect !== 'function') return if (disabled) return if (!event.defaultPrevented && [' ', 'Enter'].includes(event.key)) { - onSelect(event) + if (typeof onSelect === 'function') onSelect(event) // if this Item is inside a Menu, close the Menu if (typeof afterSelect === 'function') afterSelect() } diff --git a/src/ActionList2/List.tsx b/src/ActionList2/List.tsx index d0860dbda3a..d9ea4707760 100644 --- a/src/ActionList2/List.tsx +++ b/src/ActionList2/List.tsx @@ -3,7 +3,7 @@ import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/r import styled from 'styled-components' import sx, {SxProp, merge} from '../sx' import {AriaRole} from '../utils/types' -import {MenuContext} from './MenuContext' +import {ActionListContainerContext} from './ActionListContainerContext' export type ListProps = { /** @@ -41,7 +41,7 @@ export const List = React.forwardRef( } /** if list is inside a Menu, it will get a role from the Menu */ - const {listRole} = React.useContext(MenuContext) + const {listRole} = React.useContext(ActionListContainerContext) return ( diff --git a/src/ActionList2/MenuContext.tsx b/src/ActionList2/MenuContext.tsx deleted file mode 100644 index d727e5e88c3..00000000000 --- a/src/ActionList2/MenuContext.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/** This context can be used by components that compose ActionList inside a Menu */ - -import React from 'react' - -type ContextProps = {parent?: string; listRole?: string; itemRole?: string; afterSelect?: () => void} -export const MenuContext = React.createContext({}) diff --git a/src/ActionList2/Selection.tsx b/src/ActionList2/Selection.tsx index 1881a273889..aec06b0f09b 100644 --- a/src/ActionList2/Selection.tsx +++ b/src/ActionList2/Selection.tsx @@ -2,7 +2,7 @@ import React from 'react' import {CheckIcon} from '@primer/octicons-react' import {ListContext} from './List' import {GroupContext} from './Group' -import {MenuContext} from './MenuContext' +import {ActionListContainerContext} from './ActionListContainerContext' import {ItemProps} from './Item' import {LeadingVisualContainer} from './Visuals' @@ -10,7 +10,7 @@ type SelectionProps = Pick export const Selection: React.FC = ({selected}) => { const {selectionVariant: listSelectionVariant} = React.useContext(ListContext) const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext) - const {parent} = React.useContext(MenuContext) + const {container} = React.useContext(ActionListContainerContext) /** selectionVariant in Group can override the selectionVariant in List root */ const selectionVariant = typeof groupSelectionVariant !== 'undefined' ? groupSelectionVariant : listSelectionVariant @@ -25,7 +25,7 @@ export const Selection: React.FC = ({selected}) => { return null } - if (parent === 'ActionMenu') { + if (container === 'ActionMenu') { throw new Error( 'ActionList cannot have a selectionVariant inside ActionMenu, please use DropdownMenu or SelectPanel instead. More information: https://primer.style/design/components/action-list#application' ) diff --git a/src/ActionMenu2.tsx b/src/ActionMenu2.tsx index e2a7aafe951..018f0bf789a 100644 --- a/src/ActionMenu2.tsx +++ b/src/ActionMenu2.tsx @@ -1,16 +1,18 @@ import Button, {ButtonProps} from './Button' import React from 'react' -import {AnchoredOverlay} from './AnchoredOverlay' -import {useProvidedStateOrCreate} from './hooks/useProvidedStateOrCreate' +import {AnchoredOverlay, AnchoredOverlayProps} from './AnchoredOverlay' import {OverlayProps} from './Overlay' -import {useProvidedRefOrCreate} from './hooks' -import {AnchoredOverlayWrapperAnchorProps} from './AnchoredOverlay/AnchoredOverlay' +import {useProvidedRefOrCreate, useProvidedStateOrCreate} from './hooks' import {Divider} from './ActionList2/Divider' -import {MenuContext as ActionListMenuContext} from './ActionList2/MenuContext' +import {ActionListContainerContext} from './ActionList2/ActionListContainerContext' +import {MandateProps} from './utils/types' -type ActionMenuBaseProps = { +type MenuContextProps = Pick +export const MenuContext = React.createContext({renderAnchor: null, open: false}) + +export type ActionMenuProps = { /** - * Recommended: `ActionMenu.Button` or `ActionMenu.Anchor` with ActionList` + * Recommended: `ActionMenu.Button` or `ActionMenu.Anchor` with `ActionMenu.Overlay` */ children: React.ReactElement[] | React.ReactElement @@ -23,66 +25,49 @@ type ActionMenuBaseProps = { * If defined, will control the open/closed state of the overlay. Must be used in conjuction with `open`. */ onOpenChange?: (s: boolean) => void +} & Pick - /** - * Props to be spread on the internal `Overlay` component. - */ - overlayProps?: Partial -} - -export type ActionMenuProps = ActionMenuBaseProps & AnchoredOverlayWrapperAnchorProps - -const ActionMenuBase: React.FC = ({ +const Menu: React.FC = ({ anchorRef: externalAnchorRef, open, onOpenChange, - overlayProps, children }: ActionMenuProps) => { const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false) - const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) const onClose = React.useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) - let renderAnchor: AnchoredOverlayWrapperAnchorProps['renderAnchor'] = null - const contents: React.ReactElement[] = [] + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) + let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null - React.Children.map(children, child => { + // 🚨 Hack for good API! + // we strip out Anchor from children and pass it to AnchoredOverlay to render + // with additional props for accessibility + const contents = React.Children.map(children, child => { if (child.type === MenuButton || child.type === Anchor) { renderAnchor = anchorProps => React.cloneElement(child, anchorProps) - } else { - contents.push(child) + return null } + return child }) return ( - - - {contents} - - + + {contents} + ) } -type AnchorRef = AnchoredOverlayWrapperAnchorProps['anchorRef'] - export type MenuAnchorProps = {children: React.ReactElement} -const Anchor = React.forwardRef(({children, ...anchorProps}, anchorRef) => { - return React.cloneElement(children, {...anchorProps, ref: anchorRef}) -}) +const Anchor = React.forwardRef( + ({children, ...anchorProps}, anchorRef) => { + return React.cloneElement(children, {...anchorProps, ref: anchorRef}) + } +) /** this component is syntactical sugar 🍭 */ export type MenuButtonProps = ButtonProps -const MenuButton = React.forwardRef((props, anchorRef) => { +const MenuButton = React.forwardRef((props, anchorRef) => { return (
- - - onSelect('Copy link')}> - Copy link - ⌘C - - onSelect('Quote reply')}> - Quote reply - ⌘Q - - onSelect('Edit comment')}> - Edit comment - ⌘E - - - onSelect('Delete file')}> - Delete file - ⌘D - - + + + + onSelect('Copy link')}> + Copy link + ⌘C + + onSelect('Quote reply')}> + Quote reply + ⌘Q + + onSelect('Edit comment')}> + Edit comment + ⌘E + + + onSelect('Delete file')}> + Delete file + ⌘D + + + ) @@ -185,39 +190,39 @@ export function ControlledMenu(): JSX.Element {
- - {/** - * Even though the state is controlled externally, - * we can pass an Anchor for the menu to "anchor to" - */} + {/** + * Even though the state is controlled externally, + * we can pass an Anchor for the menu to "anchor to" + */} + Anchor - - onSelect('Copy link')}> - Copy link - ⌘C - - onSelect('Quote reply')}> - Quote reply - ⌘Q - - onSelect('Edit comment')}> - Edit comment - ⌘E - - - onSelect('Delete file')}> - Delete file - ⌘D - - + + + onSelect('Copy link')}> + Copy link + ⌘C + + onSelect('Quote reply')}> + Quote reply + ⌘Q + + onSelect('Edit comment')}> + Edit comment + ⌘E + + + onSelect('Delete file')}> + Delete file + ⌘D + + + ) @@ -238,27 +243,28 @@ export function CustomAnchor(): JSX.Element { - - - onSelect('Rename')}> - - - - Rename - - onSelect('Archive')}> - - - - Archive all cards - - onSelect('Delete file')}> - - - - Delete - - + + + onSelect('Rename')}> + + + + Rename + + onSelect('Archive')}> + + + + Archive all cards + + onSelect('Delete file')}> + + + + Delete + + + ) @@ -302,7 +308,7 @@ export function MemexTableMenu(): JSX.Element { }} > {name} - + - - - - - Sort ascending (123...) - Sort descending (123...) - - Filter by values - Group by values - - Hide field - Delete file - + + + + + + Sort ascending (123...) + Sort descending (123...) + + Filter by values + Group by values + + Hide field + Delete file + + @@ -441,7 +449,7 @@ export function MemexViewOptionsMenu(): JSX.Element { React - + - - -
  • - - - Table - - - Board - - -
  • -
    - - - - - - - - Title, Assignees, Status, Labels, Repositories - + + + +
  • + + + Table + + + Board + + +
  • +
    + + + + + + + + Title, Assignees, Status, Labels, Repositories + + + + + + group: none + + + + + + sort: manual + + + + + + Search or filter this view + + + - + - group: none + Rename view - + - sort: manual + Save changes to new view - + - + - Search or filter this view + Delete view -
    - - - - - - Rename view - - - - - - Save changes to new view - - - - - - Delete view - - + -
  • - -
  • -
    +
  • + +
  • + +
    @@ -529,6 +539,49 @@ export function MemexViewOptionsMenu(): JSX.Element { } MemexViewOptionsMenu.storyName = 'Memex View Options Menu' +export function OverlayProps(): JSX.Element { + const [open, setOpen] = React.useState(false) + const inputRef = React.createRef() + + return ( + <> +

    OverlayProps

    +

    + Disable `onClickOutside` and `onEscape`. Only way to close is to select an action which takes focus on a + TextInput +

    + + Menu + { + /* do nothing, keep it open*/ + }} + onEscape={() => { + /* do nothing, keep it open*/ + }} + returnFocusRef={inputRef} + > + + Option 1 + Option 2 + Option 2 + Option 2 + Option 2 + Option 2 + Option 2 + Option 2 + + + +
    +
    + + + ) +} +OverlayProps.storyName = 'Overlay Props' + export function UnexpectedSelectionVariant(): JSX.Element { return ( <> @@ -536,14 +589,15 @@ export function UnexpectedSelectionVariant(): JSX.Element { Menu - - - Copy link - Quote reply - Edit comment - - Delete file - + + + Copy link + Quote reply + Edit comment + + Delete file + + )