Skip to content

chore(ActionMenu): Add fullscreen sample story #6108

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

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/pink-trees-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

chore(ActionMenu): Add fullscreen sample story and variant prop
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,35 @@ export const CustomOverlayProps = () => {
)
}

export const FullScreen = () => {
const [open, setOpen] = React.useState(false)

return (
<Box sx={{display: 'flex', justifyContent: 'center'}}>
<ActionMenu open={open} onOpenChange={setOpen}>
<ActionMenu.Button>Menu</ActionMenu.Button>
<ActionMenu.Overlay
width="large"
align="center"
preventOverflow={false}
variant={{regular: 'anchored', narrow: 'fullscreen'}}
>
<ActionList>
<ActionList.Item>Option 1</ActionList.Item>
<ActionList.Item>Option 2</ActionList.Item>
<ActionList.Item>Option 2</ActionList.Item>
<ActionList.Item>Option 2</ActionList.Item>
<ActionList.Item>Option 2</ActionList.Item>
<ActionList.Item>Option 2</ActionList.Item>
<ActionList.Item>Option 2</ActionList.Item>
<ActionList.Item>Option 2</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
)
}

export const ControlledMenu = () => {
const [actionFired, fireAction] = React.useState('')
const onSelect = (name: string) => fireAction(name)
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.ActionMenuContainer {
&:where([data-variant='fullscreen']) {
padding-top: var(--base-size-28);
}
}
30 changes: 23 additions & 7 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import {useId} from '../hooks/useId'
import type {MandateProps} from '../utils/types'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {Tooltip} from '../TooltipV2/Tooltip'
import styles from './ActionMenu.module.css'
import {useResponsiveValue, type ResponsiveValue} from '../hooks/useResponsiveValue'

export type MenuCloseHandler = (
gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left',
gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close',
) => void

export type MenuContextProps = Pick<
Expand Down Expand Up @@ -79,18 +81,21 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({

const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false)
const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState])
const isNarrow = useResponsiveValue({narrow: true}, false)
const onClose: MenuCloseHandler = React.useCallback(
gesture => {
if (isNarrow && open && gesture === 'tab') {
return
}
setCombinedOpenState(false)

// Close the parent stack when an item is selected or the user tabs out of the menu entirely
switch (gesture) {
case 'tab':
case 'item-select':
parentMenuContext.onClose?.(gesture)
}
},
[setCombinedOpenState, parentMenuContext],
[setCombinedOpenState, parentMenuContext, open, isNarrow],
)

const menuButtonChild = React.Children.toArray(children).find(
Expand Down Expand Up @@ -228,8 +233,13 @@ const MenuButton = React.forwardRef(({...props}, anchorRef) => {
)
}) as PolymorphicForwardRefComponent<'button', ActionMenuButtonProps>

Copy link
Preview

Copilot AI May 22, 2025

Choose a reason for hiding this comment

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

The newly added variant prop should be documented (e.g., in JSDoc or PropTypes) to clarify the allowed shapes and describe its behavior for maintainers and consumers.

Suggested change
/**
* Props for the `Overlay` component used in the `ActionMenu`.
*
* @property variant - Determines the style of the overlay. It is an object with two optional keys:
* - `regular`: Specifies the variant for regular screens. Default is `'anchored'`.
* - `narrow`: Specifies the variant for narrow screens. Default is `'anchored'`.
* Example: `{regular: 'anchored', narrow: 'floating'}`.
*/

Copilot uses AI. Check for mistakes.

const defaultVariant: ResponsiveValue<'anchored', 'anchored' | 'fullscreen'> = {
regular: 'anchored',
narrow: 'anchored',
}

type MenuOverlayProps = Partial<OverlayProps> &
Pick<AnchoredOverlayProps, 'align' | 'side'> & {
Pick<AnchoredOverlayProps, 'align' | 'side' | 'variant'> & {
/**
* Recommended: `ActionList`
*/
Expand All @@ -242,6 +252,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
side,
onPositionChange,
'aria-labelledby': ariaLabelledby,
variant = defaultVariant,
...overlayProps
}) => {
// we typecast anchorRef as required instead of optional
Expand All @@ -258,6 +269,10 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({

const containerRef = React.useRef<HTMLDivElement>(null)
useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef, isSubmenu)
const isNarrow = useResponsiveValue({narrow: true}, false)
const responsiveVariant = useResponsiveValue(variant, {regular: 'anchored', narrow: 'anchored'})

const isNarrowFullscreen = !!isNarrow && variant.narrow === 'fullscreen'

// If the menu anchor is an icon button, we need to label the menu by tooltip that also labelled the anchor.
const [anchorAriaLabelledby, setAnchorAriaLabelledby] = useState<null | string>(null)
Expand All @@ -283,10 +298,11 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
align={align}
side={side ?? (isSubmenu ? 'outside-right' : 'outside-bottom')}
overlayProps={overlayProps}
focusZoneSettings={{focusOutBehavior: 'wrap'}}
focusZoneSettings={isNarrowFullscreen ? {disabled: true} : {focusOutBehavior: 'wrap'}}
onPositionChange={onPositionChange}
variant={variant}
>
<div ref={containerRef}>
<div ref={containerRef} className={styles.ActionMenuContainer} data-variant={responsiveVariant}>
<ActionListContainerContext.Provider
value={{
container: 'ActionMenu',
Expand All @@ -295,7 +311,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
selectionAttribute: 'aria-checked', // Should this be here?
afterSelect: () => onClose?.('item-select'),
enableFocusZone: false, // AnchoredOverlay takes care of focus zone
enableFocusZone: isNarrowFullscreen, // AnchoredOverlay takes care of focus zone. We only want to enable this if menu is narrow fullscreen.
}}
>
{children}
Expand Down
14 changes: 14 additions & 0 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.ResponsiveCloseButtonContainer {
position: relative;
}

.ResponsiveCloseButton {
position: absolute;
top: var(--base-size-8);
right: var(--base-size-8);
display: none;

@media screen and (--viewportRange-narrow) {
display: inline-grid;
}
}
22 changes: 21 additions & 1 deletion packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '
import {useId} from '../hooks/useId'
import type {AnchorPosition, PositionSettings} from '@primer/behaviors'
import {useResponsiveValue, type ResponsiveValue} from '../hooks/useResponsiveValue'
import {IconButton} from '../Button'
import {XIcon} from '@primer/octicons-react'
import classes from './AnchoredOverlay.module.css'

interface AnchoredOverlayPropsWithAnchor {
/**
Expand Down Expand Up @@ -65,7 +68,7 @@ interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'
/**
* A callback which is called whenever the overlay is currently open and a "close gesture" is detected.
*/
onClose?: (gesture: 'anchor-click' | 'click-outside' | 'escape') => unknown
onClose?: (gesture: 'anchor-click' | 'click-outside' | 'escape' | 'close') => unknown

/**
* Props to be spread on the internal `Overlay` component.
Expand Down Expand Up @@ -209,6 +212,8 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr

const currentResponsiveVariant = useResponsiveValue(variant, 'anchored')

const showXIcon = onClose && variant.narrow === 'fullscreen'

return (
<>
{renderAnchor &&
Expand Down Expand Up @@ -241,6 +246,21 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
preventOverflow={preventOverflow}
{...overlayProps}
>
{showXIcon ? (
<div className={classes.ResponsiveCloseButtonContainer}>
<IconButton
type="button"
variant="invisible"
icon={XIcon}
aria-label="Cancel and close"
className={classes.ResponsiveCloseButton}
onClick={() => {
onClose('close')
}}
/>
</div>
) : null}

{children}
</Overlay>
) : null}
Expand Down
10 changes: 1 addition & 9 deletions packages/react/src/SelectPanel/SelectPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,7 @@
}

.ResponsiveCloseButton {
display: none;

@media screen and (--viewportRange-narrow) {
display: inline-grid;
}

&:where([data-variant='modal'] &) {
display: inline-grid;
}
display: inline-grid;
}

.ResponsiveFooter {
Expand Down
28 changes: 16 additions & 12 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,21 +429,29 @@ function Panel({
(gesture: Parameters<Exclude<AnchoredOverlayProps['onOpen'], undefined>>[0]) => onOpenChange(true, gesture),
[onOpenChange],
)

const onCancelRequested = useCallback(() => {
onOpenChange(false, 'cancel')
}, [onOpenChange])

const onClose = useCallback(
(gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | 'selection' | 'escape') => {
(
gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | 'selection' | 'escape' | 'close',
) => {
// Clicking outside should cancel the selection only on modals
if (variant === 'modal' && gesture === 'click-outside') {
onCancel?.()
}
onOpenChange(false, gesture)
if (gesture === 'close') {
onCancel?.()
onCancelRequested()
} else {
onOpenChange(false, gesture)
}
},
[onOpenChange, variant, onCancel],
[onOpenChange, variant, onCancel, onCancelRequested],
)

const onCancelRequested = useCallback(() => {
onOpenChange(false, 'cancel')
}, [onOpenChange])

const renderMenuAnchor = useMemo(() => {
if (renderAnchor === null) {
return null
Expand Down Expand Up @@ -621,10 +629,6 @@ function Panel({
}
}

// because of instant selection, canceling on single select is the same as closing the panel, no onCancel needed
const showXCloseIcon =
variant === 'modal' || ((onCancel !== undefined || !isMultiSelectVariant(selected)) && usingFullScreenOnNarrow)

// We add permanent save and cancel buttons on:
// - modals
const showPermanentCancelSaveButtons = variant === 'modal'
Expand Down Expand Up @@ -723,7 +727,7 @@ function Panel({
</div>
) : null}
</div>
{showXCloseIcon ? (
{variant === 'modal' ? (
<IconButton
type="button"
variant="invisible"
Expand Down
Loading