-
Notifications
You must be signed in to change notification settings - Fork 656
Add ActionMenu.IconButton and ActionMenu.Anchor child validation #7640
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
base: main
Are you sure you want to change the base?
Changes from 3 commits
c732051
af96315
7d28e38
7593a5f
414aa66
d6d9c33
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,14 @@ | ||
| import React, {useCallback, useContext, useMemo, useEffect, useState} from 'react' | ||
| import React, {useCallback, useContext, useMemo, useEffect, useRef, useState} from 'react' | ||
| import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react' | ||
| import type {AnchoredOverlayProps} from '../AnchoredOverlay' | ||
| import {AnchoredOverlay} from '../AnchoredOverlay' | ||
| import type {OverlayProps} from '../Overlay' | ||
| import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from '../hooks' | ||
| import {Divider} from '../ActionList/Divider' | ||
| import {ActionListContainerContext} from '../ActionList/ActionListContainerContext' | ||
| import type {ButtonProps} from '../Button' | ||
| import type {ButtonProps, IconButtonProps} from '../Button' | ||
| import type {AnchorPosition} from '@primer/behaviors' | ||
| import {Button} from '../Button' | ||
| import {Button, IconButton} from '../Button' | ||
| import {useId} from '../hooks/useId' | ||
| import type {MandateProps} from '../utils/types' | ||
| import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' | ||
|
|
@@ -19,6 +19,7 @@ import {isSlot} from '../utils/is-slot' | |
| import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types/Slots' | ||
| import {useFeatureFlag} from '../FeatureFlags' | ||
| import {DialogContext} from '../Dialog/Dialog' | ||
| import {warning} from '../utils/warning' | ||
|
|
||
| export type MenuCloseHandler = ( | ||
| gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close', | ||
|
|
@@ -106,7 +107,12 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({ | |
| const menuButtonChild = React.Children.toArray(children).find( | ||
| child => | ||
| React.isValidElement<ActionMenuButtonProps>(child) && | ||
| (child.type === MenuButton || child.type === Anchor || isSlot(child, Anchor) || isSlot(child, MenuButton)), | ||
| (child.type === MenuButton || | ||
| child.type === MenuIconButton || | ||
| child.type === Anchor || | ||
| isSlot(child, Anchor) || | ||
| isSlot(child, MenuButton) || | ||
| isSlot(child, MenuIconButton)), | ||
| ) | ||
| const menuButtonChildId = React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined | ||
|
|
||
|
|
@@ -122,7 +128,12 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({ | |
| if (child.type === Tooltip || isSlot(child, Tooltip)) { | ||
| // tooltip trigger | ||
| const anchorChildren = child.props.children | ||
| if (anchorChildren.type === MenuButton || isSlot(anchorChildren, MenuButton)) { | ||
| if ( | ||
| anchorChildren.type === MenuButton || | ||
| isSlot(anchorChildren, MenuButton) || | ||
| anchorChildren.type === MenuIconButton || | ||
| isSlot(anchorChildren, MenuIconButton) | ||
| ) { | ||
| // eslint-disable-next-line react-hooks/immutability | ||
| renderAnchor = anchorProps => { | ||
| // We need to attach the anchor props to the tooltip trigger (ActionMenu.Button's grandchild) not the tooltip itself. | ||
|
|
@@ -156,7 +167,12 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({ | |
| renderAnchor = anchorProps => React.cloneElement(child, anchorProps) | ||
| } | ||
| return null | ||
| } else if (child.type === MenuButton || isSlot(child, MenuButton)) { | ||
| } else if ( | ||
| child.type === MenuButton || | ||
| isSlot(child, MenuButton) || | ||
| child.type === MenuIconButton || | ||
| isSlot(child, MenuIconButton) | ||
| ) { | ||
| renderAnchor = anchorProps => React.cloneElement(child, mergeAnchorHandlers(anchorProps, child.props)) | ||
| return null | ||
| } else { | ||
|
|
@@ -182,6 +198,23 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({ | |
| ) | ||
| } | ||
|
|
||
| // The list is from GitHub's custom-axe-rules https://github.com/github/github/blob/master/app/assets/modules/github/axe-custom-rules.ts#L3 | ||
| const interactiveElements = [ | ||
| 'a[href]', | ||
| 'button:not([disabled])', | ||
| 'summary', | ||
| 'select', | ||
| 'input:not([type=hidden])', | ||
| 'textarea', | ||
| ] | ||
|
|
||
| const isInteractive = (element: HTMLElement) => { | ||
| return ( | ||
| interactiveElements.some(selector => element.matches(selector)) || | ||
| (element.hasAttribute('role') && element.getAttribute('role') === 'button') | ||
| ) | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| export type ActionMenuAnchorProps = {children: React.ReactElement<any>; id?: string} & React.HTMLAttributes<HTMLElement> | ||
| const Anchor: WithSlotMarker< | ||
|
|
@@ -195,6 +228,26 @@ const Anchor: WithSlotMarker< | |
| > | ||
| > = React.forwardRef<HTMLElement, ActionMenuAnchorProps>(({children: child, ...anchorProps}, anchorRef) => { | ||
| const {onOpen, isSubmenu} = React.useContext(MenuContext) | ||
| const internalRef = useRef<HTMLElement | null>(null) as React.MutableRefObject<HTMLElement | null> | ||
|
|
||
| useEffect(() => { | ||
siddharthkp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Skip validation for submenu anchors, where ActionList.Item is a valid anchor | ||
| if (isSubmenu) return | ||
| if (!internalRef.current) return | ||
| const anchorElement = internalRef.current | ||
| const isAnchorInteractive = isInteractive(anchorElement) | ||
| const anchorChildren = anchorElement.childNodes | ||
| const hasInteractiveDescendant = Array.from(anchorChildren).some(child => { | ||
| return ( | ||
| (child instanceof HTMLElement && isInteractive(child)) || | ||
| Array.from(child.childNodes).some(grandChild => grandChild instanceof HTMLElement && isInteractive(grandChild)) | ||
| ) | ||
| }) | ||
| warning( | ||
| !isAnchorInteractive && !hasInteractiveDescendant, | ||
siddharthkp marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 'The `ActionMenu.Anchor` expects a single interactive element that can act as a menu trigger. Consider using a `<button>` or an `IconButton` as the direct child of `ActionMenu.Anchor`, or use `ActionMenu.Button` or `ActionMenu.IconButton` instead.', | ||
| ) | ||
| }, [isSubmenu]) | ||
siddharthkp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const openSubmenuOnRightArrow: React.KeyboardEventHandler<HTMLElement> = useCallback( | ||
| event => { | ||
|
|
@@ -231,9 +284,17 @@ const Anchor: WithSlotMarker< | |
|
|
||
| return ( | ||
| <ActionListContainerContext.Provider value={thisActionListContext}> | ||
| {/* eslint-disable-next-line react-hooks/refs */} | ||
| {React.cloneElement(child, { | ||
| ...anchorProps, | ||
| ref: anchorRef, | ||
| ref: (node: HTMLElement | null) => { | ||
| internalRef.current = node | ||
| if (typeof anchorRef === 'function') { | ||
| anchorRef(node) | ||
| } else if (anchorRef) { | ||
| ;(anchorRef as React.MutableRefObject<HTMLElement | null>).current = node | ||
| } | ||
|
||
| }, | ||
| onClick: onButtonClick, | ||
| onKeyDown: onButtonKeyDown, | ||
| })} | ||
|
|
@@ -252,6 +313,17 @@ const MenuButton = React.forwardRef(({...props}, anchorRef) => { | |
| ) | ||
| }) as PolymorphicForwardRefComponent<'button', ActionMenuButtonProps> | ||
|
|
||
| /** this component is syntactical sugar 🍭 */ | ||
| export type ActionMenuIconButtonProps = IconButtonProps | ||
|
|
||
siddharthkp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const MenuIconButton = React.forwardRef(({...props}, anchorRef) => { | ||
| return ( | ||
| <Anchor ref={anchorRef}> | ||
| <IconButton type="button" {...props} /> | ||
| </Anchor> | ||
| ) | ||
| }) as PolymorphicForwardRefComponent<'button', ActionMenuIconButtonProps> | ||
|
|
||
| const defaultVariant: ResponsiveValue<'anchored', 'anchored' | 'fullscreen'> = { | ||
| regular: 'anchored', | ||
| narrow: 'anchored', | ||
|
|
@@ -373,7 +445,14 @@ Menu.displayName = 'ActionMenu' | |
|
|
||
| Menu.__SLOT__ = Symbol('ActionMenu') | ||
| MenuButton.__SLOT__ = Symbol('ActionMenu.Button') | ||
| MenuIconButton.__SLOT__ = Symbol('ActionMenu.IconButton') | ||
| Anchor.__SLOT__ = Symbol('ActionMenu.Anchor') | ||
| Overlay.__SLOT__ = Symbol('ActionMenu.Overlay') | ||
|
|
||
| export const ActionMenu = Object.assign(Menu, {Button: MenuButton, Anchor, Overlay, Divider}) | ||
| export const ActionMenu = Object.assign(Menu, { | ||
| Button: MenuButton, | ||
| IconButton: MenuIconButton, | ||
| Anchor, | ||
| Overlay, | ||
| Divider, | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.