import * as React from 'react'; import * as Ariakit from '@ariakit/react'; import { cn } from '~/utils'; export interface CustomMenuProps extends Ariakit.MenuButtonProps<'div'> { label?: React.ReactNode; values?: Record; onValuesChange?: (values: Record) => void; searchValue?: string; onSearch?: (value: string) => void; combobox?: Ariakit.ComboboxProps['render']; trigger?: Ariakit.MenuButtonProps['render']; defaultOpen?: boolean; } export const CustomMenu = React.forwardRef(function CustomMenu( { label, children, values, onValuesChange, searchValue, onSearch, combobox, trigger, defaultOpen, ...props }, ref, ) { const parent = Ariakit.useMenuContext(); const searchable = searchValue != null || !!onSearch || !!combobox; const menuStore = Ariakit.useMenuStore({ showTimeout: 100, placement: parent ? 'right' : 'left', defaultOpen: defaultOpen, }); const element = ( : trigger} > {label} {searchable ? ( <>
{children} ) : ( children )}
); if (searchable) { return ( {element} ); } return element; }); export const CustomMenuSeparator = React.forwardRef( function CustomMenuSeparator(props, ref) { return ( ); }, ); export interface CustomMenuGroupProps extends Ariakit.MenuGroupProps { label?: React.ReactNode; } export const CustomMenuGroup = React.forwardRef( function CustomMenuGroup({ label, ...props }, ref) { return ( {label && ( {label} )} {props.children} ); }, ); const SearchableContext = React.createContext(false); export interface CustomMenuItemProps extends Omit { name?: string; } export const CustomMenuItem = React.forwardRef( function CustomMenuItem({ name, value, ...props }, ref) { const menu = Ariakit.useMenuContext(); const searchable = React.useContext(SearchableContext); const defaultProps: CustomMenuItemProps = { ref, focusOnHover: true, blurOnHoverEnd: false, ...props, className: cn( 'relative flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white', props.className, ), }; const checkable = Ariakit.useStoreState(menu, (state) => { if (!name) { return false; } if (value == null) { return false; } return state?.values[name] != null; }); const checked = Ariakit.useStoreState(menu, (state) => { if (!name) { return false; } return state?.values[name] === value; }); // If the item is checkable, we render a checkmark icon next to the label. if (checkable) { defaultProps.children = ( {defaultProps.children} {searchable && ( // When an item is displayed in a search menu as a role=option // element instead of a role=menuitemradio, we can't depend on the // aria-checked attribute. Although NVDA and JAWS announce it // accurately, VoiceOver doesn't. TalkBack does announce the checked // state, but misleadingly implies that a double tap will change the // state, which isn't the case. Therefore, we use a visually hidden // element to indicate whether the item is checked or not, ensuring // cross-browser/AT compatibility. {checked ? 'checked' : 'not checked'} )} ); } // If the item is not rendered in a search menu (listbox), we can render it // as a MenuItem/MenuItemRadio. if (!searchable) { if (name != null && value != null) { const radioProps = { ...defaultProps, name, value, hideOnClick: true }; return ; } return ; } return ( { if (name == null || value == null) { return false; } // By default, clicking on a ComboboxItem will update the // selectedValue state of the combobox. However, since we're sharing // state between combobox and menu, we also need to update the menu's // values state. menu?.setValue(name, value); return true; }} hideOnClick={(event) => { // Make sure that clicking on a combobox item that opens a nested // menu/dialog does not close the menu. const expandable = event.currentTarget.hasAttribute('aria-expanded'); if (expandable) { return false; } // By default, clicking on a ComboboxItem only closes its own popover. // However, since we're in a menu context, we also close all parent // menus. menu?.hideAll(); return true; }} /> ); }, );