Something went wrong. Try again.
ATProto-based AI Assistant Chat interface.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111import { type KeyboardEvent, type RefObject, useId, useRef } from 'react'import { useDismiss } from './useDismiss.ts'import { usePlacement } from './usePlacement.ts'
/** Where a floating element sits, for `usePopover` and `Menu.Popup` alike. */export interface Placement { /** Which edge of the button it lines up with, the end for a button at the end of a row. */ align?: 'start' | 'end' /** Whether the button places it, the default. Without, the caller places it with classes. */ anchored?: boolean /** Whether it renders at the end of the page, the default. Without, it renders in place. */ portal?: boolean}
interface PopoverOptions extends Placement { /** * Whether the arrow keys, Home, and End move between its buttons and links as well as Tab, for a * panel of rows such as the account menu, as the WAI-ARIA disclosure pattern allows. Not for a * panel with a field, whose own keys these would take. */ arrows?: boolean}
/** The buttons and links in a floating element the keyboard can reach. */const controls = 'button:not(:disabled), [href]'
/** * Closes a floating element on Escape or a press outside it and the button opening it, with the * focus back on the button after Escape. */export function useDismissToButton( open: boolean, button: RefObject<HTMLButtonElement | null>, floating: RefObject<HTMLElement | null>, onClose: () => void,) { useDismiss(open, [button, floating], (reason) => { onClose() if (reason === 'escape') button.current?.focus() })}
/** * Moves the focus into a floating element as it arrives, to the control `pick` finds. Only on * arriving, so a rerender leaves the focus wherever the keyboard has moved it since. */export function focusOnArrival( element: HTMLElement | null, pick: (element: HTMLElement) => HTMLElement | null | undefined,) { if (element && !element.contains(document.activeElement)) pick(element)?.focus()}
/** * Moves the focus between `items` on the arrow keys, Home, and End, round from one end to the * other, and leaves any other key alone. */export function moveFocus(event: KeyboardEvent, items: HTMLElement[]) { const at = items.indexOf(document.activeElement as HTMLElement) const last = items.length - 1 const next = { ArrowDown: at === last ? 0 : at + 1, ArrowUp: at <= 0 ? last : at - 1, Home: 0, End: last, }[event.key] if (next === undefined) return event.preventDefault() items[next]?.focus()}
/** * A menu or card that floats by the button opening it: placed by the button, closed on Escape or a * press outside, with the focus back on the button after Escape. The focus starts at the control * marked `data-autofocus`, else the first control, of each element given `popover.ref`, so the * keyboard lands inside the menu as it opens, and inside a view as it takes another's place. Tab * moves through it, and with `arrows` the arrow keys too. Spread `trigger` on the button and * `popover` on a `Popover`. */export function usePopover(open: boolean, onClose: () => void, options: PopoverOptions = {}) { const id = useId() const anchor = useRef<HTMLButtonElement>(null) const floating = useRef<HTMLDivElement>(null) const anchored = options.anchored ?? true const position = usePlacement(open && anchored, anchor, floating, options.align) useDismissToButton(open, anchor, floating, onClose) const ref = (element: HTMLDivElement | null) => { floating.current = element focusOnArrival( element, (inside) => inside.querySelector<HTMLElement>('[data-autofocus]') ?? inside.querySelector<HTMLElement>(controls), ) } const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => moveFocus(event, [...event.currentTarget.querySelectorAll<HTMLElement>(controls)]) return { anchor, trigger: { 'aria-expanded': open, 'aria-controls': open ? id : undefined }, popover: { open, ref, id, style: position, portal: options.portal, onKeyDown: options.arrows ? onKeyDown : undefined, }, }}