| | 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<string, any>; |
| | onValuesChange?: (values: Record<string, any>) => void; |
| | searchValue?: string; |
| | onSearch?: (value: string) => void; |
| | combobox?: Ariakit.ComboboxProps['render']; |
| | trigger?: Ariakit.MenuButtonProps['render']; |
| | defaultOpen?: boolean; |
| | } |
| |
|
| | export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(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 = ( |
| | <Ariakit.MenuProvider store={menuStore} values={values} setValues={onValuesChange}> |
| | <Ariakit.MenuButton |
| | ref={ref} |
| | {...props} |
| | className={cn( |
| | !parent && |
| | 'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary', |
| | menuStore.useState('open') |
| | ? 'bg-surface-tertiary hover:bg-surface-tertiary' |
| | : 'bg-surface-secondary hover:bg-surface-tertiary', |
| | props.className, |
| | )} |
| | render={parent ? <CustomMenuItem render={trigger} /> : trigger} |
| | > |
| | <span className="flex-1">{label}</span> |
| | <Ariakit.MenuButtonArrow className="stroke-1 text-base opacity-75" /> |
| | </Ariakit.MenuButton> |
| | <Ariakit.Menu |
| | open={menuStore.useState('open')} |
| | portal |
| | overlap |
| | unmountOnHide |
| | gutter={parent ? -4 : 4} |
| | className={cn( |
| | `${parent ? 'animate-popover-left ml-3' : 'animate-popover'} outline-none! z-50 flex max-h-[min(450px,var(--popover-available-height))] w-full`, |
| | 'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light', |
| | 'bg-surface-secondary px-3 py-2 text-sm text-text-primary shadow-lg', |
| | 'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]', |
| | searchable && 'p-0', |
| | )} |
| | > |
| | <SearchableContext.Provider value={searchable}> |
| | {searchable ? ( |
| | <> |
| | <div className="sticky top-0 z-10 bg-inherit p-1"> |
| | <Ariakit.Combobox |
| | autoSelect |
| | render={combobox} |
| | className={cn( |
| | 'h-10 w-full rounded-lg border-none bg-transparent px-2 text-base', |
| | 'sm:h-8 sm:text-sm', |
| | 'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-white', |
| | )} |
| | /> |
| | </div> |
| | <Ariakit.ComboboxList className="p-0.5 pt-0">{children}</Ariakit.ComboboxList> |
| | </> |
| | ) : ( |
| | children |
| | )} |
| | </SearchableContext.Provider> |
| | </Ariakit.Menu> |
| | </Ariakit.MenuProvider> |
| | ); |
| |
|
| | if (searchable) { |
| | return ( |
| | <Ariakit.ComboboxProvider |
| | resetValueOnHide |
| | includesBaseElement={false} |
| | value={searchValue} |
| | setValue={onSearch} |
| | > |
| | {element} |
| | </Ariakit.ComboboxProvider> |
| | ); |
| | } |
| |
|
| | return element; |
| | }); |
| |
|
| | export const CustomMenuSeparator = React.forwardRef<HTMLHRElement, Ariakit.MenuSeparatorProps>( |
| | function CustomMenuSeparator(props, ref) { |
| | return ( |
| | <Ariakit.MenuSeparator |
| | ref={ref} |
| | {...props} |
| | className={cn( |
| | 'my-0.5 h-0 w-full border-t border-slate-200 dark:border-slate-700', |
| | props.className, |
| | )} |
| | /> |
| | ); |
| | }, |
| | ); |
| |
|
| | export interface CustomMenuGroupProps extends Ariakit.MenuGroupProps { |
| | label?: React.ReactNode; |
| | } |
| |
|
| | export const CustomMenuGroup = React.forwardRef<HTMLDivElement, CustomMenuGroupProps>( |
| | function CustomMenuGroup({ label, ...props }, ref) { |
| | return ( |
| | <Ariakit.MenuGroup ref={ref} {...props} className={cn('', props.className)}> |
| | {label && ( |
| | <Ariakit.MenuGroupLabel className="cursor-default p-2 text-sm font-medium opacity-60 sm:py-1 sm:text-xs"> |
| | {label} |
| | </Ariakit.MenuGroupLabel> |
| | )} |
| | {props.children} |
| | </Ariakit.MenuGroup> |
| | ); |
| | }, |
| | ); |
| |
|
| | const SearchableContext = React.createContext(false); |
| |
|
| | export interface CustomMenuItemProps extends Omit<Ariakit.ComboboxItemProps, 'store'> { |
| | name?: string; |
| | } |
| |
|
| | export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemProps>( |
| | 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 (checkable) { |
| | defaultProps.children = ( |
| | <React.Fragment> |
| | <span className="flex-1">{defaultProps.children}</span> |
| | <Ariakit.MenuItemCheck checked={checked} /> |
| | {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. |
| | <Ariakit.VisuallyHidden>{checked ? 'checked' : 'not checked'}</Ariakit.VisuallyHidden> |
| | )} |
| | </React.Fragment> |
| | ); |
| | } |
| |
|
| | |
| | |
| | if (!searchable) { |
| | if (name != null && value != null) { |
| | const radioProps = { ...defaultProps, name, value, hideOnClick: true }; |
| | return <Ariakit.MenuItemRadio {...radioProps} />; |
| | } |
| | return <Ariakit.MenuItem {...defaultProps} />; |
| | } |
| |
|
| | return ( |
| | <Ariakit.ComboboxItem |
| | {...defaultProps} |
| | setValueOnClick={false} |
| | value={checkable ? value : undefined} |
| | selectValueOnClick={() => { |
| | 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; |
| | }} |
| | /> |
| | ); |
| | }, |
| | ); |
| |
|