Something went wrong. Try again.
ATProto-based AI Assistant Chat interface.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217import { type ComponentProps, createContext, type KeyboardEvent, type ReactNode, type RefObject, use, useId, useRef,} from 'react'import { cn } from '../cn.ts'import { setRef } from '../ref.ts'import { usePlacement } from '../usePlacement.ts'import { focusOnArrival, moveFocus, type Placement, useDismissToButton } from '../usePopover.ts'import { IconButton, type IconButtonProps } from './Button.tsx'import { floating, menuRow } from './menu.ts'import { Popover } from './Popover.tsx'import { Separator as Line } from './Separator.tsx'
/* * A menu of actions behind an icon button, imported as `import * as Menu`: a `Root` holding * whether it is open, its `Trigger`, and a `Popup` of `Item`s, which a `Separator` can group. It is * a menu to screen readers, as the WAI-ARIA menu button pattern has it: the arrow keys, Home, and * End move between the items, Tab closes it and moves on, and Escape closes it and goes back to * the button. A panel holding links or other controls is a `Popover` instead, which Tab moves * through, and the arrow keys too when it asks for them. */
interface MenuState { open: boolean onOpenChange: (open: boolean) => void id: string triggerId: string trigger: RefObject<HTMLButtonElement | null> popup: RefObject<HTMLDivElement | null> /** Whether the menu opens on its last item, as from the Up arrow on the button. */ focusLast: RefObject<boolean>}
const MenuContext = createContext<MenuState | null>(null)
function useMenu() { const menu = use(MenuContext) if (!menu) throw new Error('A menu part must be inside Menu.Root') return menu}
/** The items of a menu that can take the focus, in order. */function itemsOf(popup: HTMLElement) { return [...popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)')]}
interface RootProps { open: boolean /** Asks to open or close it: from the button, an item, Escape, Tab, or a press outside. */ onOpenChange: (open: boolean) => void children: ReactNode}
/** Holds the menu's state for its parts. It renders nothing of its own. */export function Root(props: RootProps) { const id = useId() const triggerId = useId() const trigger = useRef<HTMLButtonElement>(null) const popup = useRef<HTMLDivElement>(null) const focusLast = useRef(false) useDismissToButton(props.open, trigger, popup, () => props.onOpenChange(false)) return ( <MenuContext value={{ open: props.open, onOpenChange: props.onOpenChange, id, triggerId, trigger, popup, focusLast, }} > {props.children} </MenuContext> )}
/** * The icon button that opens and closes the menu, taking `IconButton`'s props. The Down and Up * arrows also open it, on its first item or its last. */export function Trigger(props: IconButtonProps) { const menu = useMenu() const { ref, onClick, onKeyDown, ...rest } = props return ( <IconButton id={menu.triggerId} aria-haspopup="menu" aria-expanded={menu.open} aria-controls={menu.open ? menu.id : undefined} {...rest} ref={(element: HTMLButtonElement | null) => { menu.trigger.current = element setRef(ref, element) }} onClick={(event) => { onClick?.(event) if (event.defaultPrevented) return menu.focusLast.current = false menu.onOpenChange(!menu.open) }} onKeyDown={(event) => { onKeyDown?.(event) if (event.defaultPrevented || menu.open) return if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return event.preventDefault() menu.focusLast.current = event.key === 'ArrowUp' menu.onOpenChange(true) }} /> )}
interface PopupProps extends Placement { className?: string children: ReactNode}
/** * The menu's items, floating by the button while it is open, the focus on the first. Keyed by * what it shows, a view that takes another's place starts on its own first item. */export function Popup(props: PopupProps) { const menu = useMenu() const anchored = props.anchored ?? true const style = usePlacement(menu.open && anchored, menu.trigger, menu.popup, props.align)
const ref = (element: HTMLDivElement | null) => { menu.popup.current = element focusOnArrival(element, (inside) => { const items = itemsOf(inside) const item = menu.focusLast.current ? items.at(-1) : items[0] menu.focusLast.current = false return item }) }
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (event.key === 'Tab') { // From the button, so the browser's own move goes on from there rather than from the end of // the page, where the menu is. menu.trigger.current?.focus() menu.onOpenChange(false) return } moveFocus(event, itemsOf(event.currentTarget)) }
return ( <Popover open={menu.open} portal={props.portal} ref={ref} id={menu.id} role="menu" aria-labelledby={menu.triggerId} style={style} onKeyDown={onKeyDown} className={cn(floating, 'flex flex-col gap-0.5', anchored && 'fixed z-50', props.className)} > {props.children} </Popover> )}
interface ItemProps extends ComponentProps<'button'> { /** Red, for something that can't be undone, such as delete. */ danger?: boolean}
/** * A row of the menu that acts, its icon brightening with its text when hovered. Choosing it closes * the menu, unless its `onClick` prevents the default, as for one that shows another view. */export function Item(props: ItemProps) { const menu = useMenu() const { danger, className, onClick, ...rest } = props return ( <button {...rest} type="button" role="menuitem" tabIndex={-1} onClick={(event) => { onClick?.(event) if (event.defaultPrevented) return // Back to the button, unless what the item did has put the focus somewhere already. What it // opens, such as a form, can still take the focus as it arrives. The focus is on the page // itself after a press on the menu's padding, or in Safari, which doesn't focus a pressed // button. const focus = document.activeElement if (!focus || focus === document.body || menu.popup.current?.contains(focus)) menu.trigger.current?.focus() menu.onOpenChange(false) }} className={cn( menuRow, 'w-full cursor-pointer hover:bg-row-hover active:bg-row-hover disabled:pointer-events-none disabled:opacity-50 [&_svg]:text-muted hover:[&_svg]:text-ink', danger && 'text-danger [&_svg]:text-danger hover:[&_svg]:text-danger', className, )} /> )}
/** A hairline between groups of items. */export function Separator() { return <Line />}