import React, { forwardRef, useRef } from "react"; import { ChevronRightIcon } from "@navikt/aksel-icons"; import { useModalContext } from "../modal/Modal.context"; import { type OverridableComponent, useId } from "../utils-external"; import { Menu, MenuPortalProps } from "../utils/components/floating-menu/Menu"; import { Slot } from "../utils/components/slot/Slot"; import { cl, composeEventHandlers, createStrictContext, requireReactElement, } from "../utils/helpers"; import { useControllableState, useMergeRefs } from "../utils/hooks"; /* -------------------------------------------------------------------------- */ /* ActionMenu */ /* -------------------------------------------------------------------------- */ type ActionMenuContextValue = { triggerId: string; triggerRef: React.RefObject; contentId: string; open: boolean; onOpenChange: (open: boolean) => void; onOpenToggle: () => void; rootElement: MenuPortalProps["rootElement"]; }; const { Provider: ActionMenuProvider, useContext: useActionMenuContext } = createStrictContext({ name: "ActionMenuContext", errorMessage: "ActionMenu sub-components cannot be rendered outside the ActionMenu component.", }); type ActionMenuProps = { children?: React.ReactNode; /** * Whether the menu is open or not. * Only needed if you want manually control state. */ open?: boolean; /** * Callback for when the menu is opened or closed. */ onOpenChange?: (open: boolean) => void; } & Pick; interface ActionMenuComponent extends React.FC { /** * Acts as a trigger and anchor for the menu. * Must be wrapped around a button. If you use your own component, make sure to forward ref and props. * @example * ```jsx * * * * ``` */ Trigger: typeof ActionMenuTrigger; /** * The menu content, containing all the items. * @example * ```jsx * * * Item 1 * * * Item 2 * * * ``` */ Content: typeof ActionMenuContent; /** * Semantically and visually groups items together with a label. * This is the prefered way to group items, as it provides better accessibility * rather than using a standalone `ActionMenu.Label`. * * It is required to use either `label` or `aria-label` to provide an accessible name for the group. * @example * ```jsx * * * * Item 1 * * * Item 2 * * * * * Item 3 * * * Item 4 * * * * ``` */ Group: typeof ActionMenuGroup; /** * Separate labeling option for the menu. * This is not for grouping items, but rather for adding a label to the menu at the top. For grouping items, use `ActionMenu.Group`. * @example * ```jsx * * * Label * * * * // Grouped * * * Item 1 * * * Item 2 * * * * // Standalone * * Item 3 * * * ``` * @example As link * ```jsx * * Item * * ``` */ Item: typeof ActionMenuItem; /** * A checkbox item in the menu. Can be used standalone or grouped with other items. * @example * ```jsx * * Checkbox 1 * * ``` */ CheckboxItem: typeof ActionMenuCheckboxItem; /** * A radio group in the menu. * * It is required to use either `label` or `aria-label` to provide an accessible name for the group. * @example * ```jsx * * Radio 1 * Radio 2 * * ``` */ RadioGroup: typeof ActionMenuRadioGroup; /** * A radio item in the menu. Should always be grouped with an `ActionMenu.RadioGroup`. * @example * ```jsx * * Radio 1 * Radio 2 * * ``` */ RadioItem: typeof ActionMenuRadioItem; /** * A simple divider to separate items in the menu. */ Divider: typeof ActionMenuDivider; /** * A sub-menu that can be nested inside the menu. * The sub-menu can be nested inside other sub-menus allowing for multiple levels of nesting. * @example * ```jsx * * Submenu 1 * * * Subitem 1 * * * Subitem 2 * * * * ``` */ Sub: typeof ActionMenuSub; /** * Acts as a trigger for a sub-menu. * In contrast to `ActionMenu.Trigger`, this trigger is a standalone component and should not be wrapped around a React.ReactNode. * @example * ```jsx * * Submenu 1 * * ``` */ SubTrigger: typeof ActionMenuSubTrigger; /** * The content of a sub-menu. * @example * ```jsx * * * * Subitem 1 * * * Subitem 2 * * * * ``` */ SubContent: typeof ActionMenuSubContent; } const ActionMenuRoot = ({ children, open: openProp, onOpenChange, rootElement: rootElementProp, }: ActionMenuProps) => { const triggerRef = useRef(null); const modalContext = useModalContext(false); const rootElement = modalContext ? modalContext.modalRef.current : rootElementProp; const [open = false, setOpen] = useControllableState({ value: openProp, defaultValue: false, onChange: onOpenChange, }); return ( setOpen((prevOpen) => !prevOpen)} rootElement={rootElement} > {children} ); }; /** * ActionMenu is a dropdown menu for actions and navigation. * * @example * ```jsx * * * * * * alert("Item 1 selected")}> * Item 1 * * alert("Item 2 selected")}> * Item 2 * * * * ``` */ export const ActionMenu = ActionMenuRoot as ActionMenuComponent; /* -------------------------------------------------------------------------- */ /* ActionMenuTrigger */ /* -------------------------------------------------------------------------- */ interface ActionMenuTriggerProps extends React.ButtonHTMLAttributes { children: React.ReactElement; } export const ActionMenuTrigger = forwardRef< HTMLButtonElement, ActionMenuTriggerProps >( ( { children, onKeyDown, style, onClick, ...rest }: ActionMenuTriggerProps, ref, ) => { const context = useActionMenuContext(); const mergedRefs = useMergeRefs(ref, context.triggerRef); return ( { if (event.key === "ArrowDown") { context.onOpenChange(true); /* Stop keydown from scrolling window */ event.preventDefault(); } })} > {requireReactElement(children)} ); }, ); /* -------------------------------------------------------------------------- */ /* ActionMenuContent */ /* -------------------------------------------------------------------------- */ interface ActionMenuContentProps extends Omit< React.HTMLAttributes, "id" > { children?: React.ReactNode; align?: "start" | "end"; } export const ActionMenuContent = forwardRef< HTMLDivElement, ActionMenuContentProps >( ( { children, className, style, align = "start", ...rest }: ActionMenuContentProps, ref, ) => { const context = useActionMenuContext(); return (
{children}
); }, ); /* -------------------------------------------------------------------------- */ /* ActionMenuLabel */ /* -------------------------------------------------------------------------- */ interface ActionMenuLabelProps extends React.HTMLAttributes { children: React.ReactNode; } export const ActionMenuLabel = forwardRef( ({ children, className, ...rest }: ActionMenuLabelProps, ref) => { return (
{children}
); }, ); /* -------------------------------------------------------------------------- */ /* ActionMenuGroup */ /* -------------------------------------------------------------------------- */ type ActionMenuGroupElement = React.ElementRef; type MenuGroupProps = React.ComponentPropsWithoutRef; type ActionMenuGroupLabelingProps = | { /** * Adds a visual and accessible label to the group. */ label: string; /** * Adds an aria-label to the group. */ "aria-label"?: never; } | { /** * Adds an aria-label to the group. */ "aria-label": string; /** * Adds a visual and accessible label to the group. */ label?: never; }; type ActionMenuGroupProps = Omit & ActionMenuGroupLabelingProps; export const ActionMenuGroup = forwardRef< ActionMenuGroupElement, ActionMenuGroupProps >(({ children, className, label, ...rest }: ActionMenuGroupProps, ref) => { const labelId = useId(); return ( {label && ( {label} )} {children} ); }); /* -------------------------------------------------------------------------- */ /* Utility components */ /* -------------------------------------------------------------------------- */ type MarkerProps = { children: React.ReactNode; className?: string; placement: "left" | "right"; }; const Marker = ({ children, className, placement }: MarkerProps) => { return (
{children}
); }; type ShortcutProps = { children: string; }; const Shortcut = ({ children }: ShortcutProps) => { /** * Assumes the user will input either a single keyboard key * or keys separated by "+" */ const parsed = children.split("+").filter((str) => str !== ""); return ( {parsed.map((char, index) => ( {char} ))} ); }; /* -------------------------------------------------------------------------- */ /* ActionMenuItem */ /* -------------------------------------------------------------------------- */ type ActionMenuItemElement = React.ElementRef; type MenuItemProps = React.ComponentPropsWithoutRef; interface ActionMenuItemProps extends Omit { /** * Shows connected shortcut-keys for the item. * This is only a visual representation, you will have to implement the actual shortcut yourself. */ shortcut?: string; /** * Styles the item as a destructive action. */ variant?: "danger"; /** * Adds an icon on the left side. For right side position use iconPosition. The icon will always have aria-hidden. */ icon?: React.ReactNode; /** * Position of icon. * @default "left" */ iconPosition?: "left" | "right"; } export const ActionMenuItem: OverridableComponent< ActionMenuItemProps, ActionMenuItemElement > = forwardRef( ( { children, as: Component = "div", className, icon, shortcut, variant, iconPosition = "left", ...rest }, ref, ) => { return ( {children} {icon && ( {icon} )} {shortcut && {shortcut}} ); }, ); /* -------------------------------------------------------------------------- */ /* ActionMenuCheckboxItem */ /* -------------------------------------------------------------------------- */ type ActionMenuCheckboxItemElement = React.ElementRef; type MenuCheckboxItemProps = React.ComponentPropsWithoutRef< typeof Menu.CheckboxItem >; interface ActionMenuCheckboxItemProps extends Omit< MenuCheckboxItemProps, "asChild" > { children: React.ReactNode; /** * Shows connected shortcut-keys for the item. * This is only a visual representation, you will have to implement the actual shortcut yourself. */ shortcut?: string; } export const ActionMenuCheckboxItem = forwardRef< ActionMenuCheckboxItemElement, ActionMenuCheckboxItemProps >( ( { children, className, shortcut, onSelect, ...rest }: ActionMenuCheckboxItemProps, ref, ) => { return ( { /** * Prevent default to avoid the menu from closing when clicking the checkbox */ event.preventDefault(); })} asChild={false} className={cl("aksel-action-menu__item", className)} data-marker="left" aria-keyshortcuts={shortcut} > {children} {shortcut && {shortcut}} ); }, ); /* -------------------------------------------------------------------------- */ /* ActionMenuRadioGroup */ /* -------------------------------------------------------------------------- */ type ActionMenuRadioGroupElement = React.ElementRef; type MenuRadioGroupProps = React.ComponentPropsWithoutRef< typeof Menu.RadioGroup >; type ActionMenuRadioGroupProps = ActionMenuGroupLabelingProps & Omit & { children: React.ReactNode; }; export const ActionMenuRadioGroup = forwardRef< ActionMenuRadioGroupElement, ActionMenuRadioGroupProps >(({ children, label, ...rest }: ActionMenuRadioGroupProps, ref) => { const labelId = useId(); return ( {label && ( {label} )} {children} ); }); /* -------------------------------------------------------------------------- */ /* ActionMenuRadioItem */ /* -------------------------------------------------------------------------- */ type ActionMenuRadioItemElement = React.ElementRef; type MenuRadioItemProps = React.ComponentPropsWithoutRef; interface ActionMenuRadioItemProps extends Omit { children: React.ReactNode; } export const ActionMenuRadioItem = forwardRef< ActionMenuRadioItemElement, ActionMenuRadioItemProps >( ( { children, className, onSelect, ...rest }: ActionMenuRadioItemProps, ref, ) => { return ( { /** * Prevent default to avoid the menu from closing when clicking the radio */ event.preventDefault(); })} asChild={false} className={cl("aksel-action-menu__item", className)} data-marker="left" > {children} ); }, ); /* -------------------------------------------------------------------------- */ /* ActionMenuDivider */ /* -------------------------------------------------------------------------- */ type ActionMenuDividerElement = React.ElementRef; type MenuDividerProps = React.ComponentPropsWithoutRef; type ActionMenuDividerProps = Omit; export const ActionMenuDivider = forwardRef< ActionMenuDividerElement, ActionMenuDividerProps >(({ className, ...rest }: ActionMenuDividerProps, ref) => { return ( ); }); /* -------------------------------------------------------------------------- */ /* ActionMenuSub */ /* -------------------------------------------------------------------------- */ interface ActionMenuSubProps { children?: React.ReactNode; /** * Whether the sub-menu is open or not. Only needed if you want to manually control state. */ open?: boolean; /** * Callback for when the sub-menu is opened or closed. */ onOpenChange?: (open: boolean) => void; } export const ActionMenuSub = (props: ActionMenuSubProps) => { const { children, open: openProp, onOpenChange } = props; const [open = false, setOpen] = useControllableState({ value: openProp, defaultValue: false, onChange: onOpenChange, }); return ( {children} ); }; /* -------------------------------------------------------------------------- */ /* ActionMenuSubTrigger */ /* -------------------------------------------------------------------------- */ type ActionMenuSubTriggerElement = React.ElementRef; type MenuSubTriggerProps = React.ComponentPropsWithoutRef< typeof Menu.SubTrigger >; interface ActionMenuSubTriggerProps extends Omit< MenuSubTriggerProps, "asChild" > { icon?: React.ReactNode; /** * Position of icon. * @default "left" */ iconPosition?: "left" | "right"; } export const ActionMenuSubTrigger = forwardRef< ActionMenuSubTriggerElement, ActionMenuSubTriggerProps >( ( { children, className, icon, iconPosition = "left", ...rest }: ActionMenuSubTriggerProps, ref, ) => { return ( {children} {icon && ( {icon} )}
); }, ); /* -------------------------------------------------------------------------- */ /* ActionMenuSubContent */ /* -------------------------------------------------------------------------- */ type ActionMenuSubContentElement = React.ElementRef; interface ActionMenuSubContentProps extends React.HTMLAttributes { children: React.ReactNode; } export const ActionMenuSubContent = forwardRef< ActionMenuSubContentElement, ActionMenuSubContentProps >(({ children, className, style, ...rest }: ActionMenuSubContentProps, ref) => { const context = useActionMenuContext(); return (
{children}
); }); /* -------------------------------------------------------------------------- */ ActionMenu.Trigger = ActionMenuTrigger; ActionMenu.Content = ActionMenuContent; ActionMenu.Group = ActionMenuGroup; ActionMenu.Label = ActionMenuLabel; ActionMenu.Item = ActionMenuItem; ActionMenu.CheckboxItem = ActionMenuCheckboxItem; ActionMenu.RadioGroup = ActionMenuRadioGroup; ActionMenu.RadioItem = ActionMenuRadioItem; ActionMenu.Divider = ActionMenuDivider; ActionMenu.Sub = ActionMenuSub; ActionMenu.SubTrigger = ActionMenuSubTrigger; ActionMenu.SubContent = ActionMenuSubContent; export type { ActionMenuCheckboxItemProps, ActionMenuContentProps, ActionMenuDividerProps, ActionMenuGroupProps, ActionMenuItemProps, ActionMenuLabelProps, ActionMenuProps, ActionMenuRadioGroupProps, ActionMenuRadioItemProps, ActionMenuSubContentProps, ActionMenuSubProps, ActionMenuSubTriggerProps, ActionMenuTriggerProps, };