| import { |
| createContext, |
| useCallback, |
| useContext, |
| useEffect, |
| useId, |
| useLayoutEffect, |
| useMemo, |
| useRef, |
| useState, |
| } from "react"; |
| import type { |
| AriaRole, |
| CSSProperties, |
| KeyboardEvent as ReactKeyboardEvent, |
| ReactNode, |
| RefObject, |
| } from "react"; |
| import { createPortal } from "react-dom"; |
| import { computeOverlayPosition } from "./overlayPlacement"; |
| import type { Placement as OverlayPlacement } from "./overlayPlacement"; |
|
|
| export interface AnchorRect { |
| left: number; |
| top: number; |
| right: number; |
| bottom: number; |
| width: number; |
| height: number; |
| } |
|
|
| type Anchor = HTMLElement | AnchorRect; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type Placement = OverlayPlacement; |
|
|
| interface OverlayStack { |
| register: (id: string) => () => void; |
| isTop: (id: string) => boolean; |
| } |
|
|
| const OverlayStackContext = createContext<OverlayStack | null>(null); |
|
|
| const FOCUSABLE = [ |
| "[data-overlay-autofocus]", |
| "button:not([disabled])", |
| "input:not([disabled])", |
| "select:not([disabled])", |
| "textarea:not([disabled])", |
| "[href]", |
| '[tabindex]:not([tabindex="-1"])', |
| ].join(","); |
|
|
| export function OverlayProvider({ children }: { children: ReactNode }) { |
| const stack = useRef<string[]>([]); |
| const api = useMemo<OverlayStack>( |
| () => ({ |
| register: (id) => { |
| stack.current = [...stack.current.filter((item) => item !== id), id]; |
| return () => { |
| stack.current = stack.current.filter((item) => item !== id); |
| }; |
| }, |
| isTop: (id) => stack.current.at(-1) === id, |
| }), |
| [] |
| ); |
| return ( |
| <OverlayStackContext.Provider value={api}> |
| {children} |
| </OverlayStackContext.Provider> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const NO_RECT: AnchorRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; |
|
|
| function anchorRect(anchor: Anchor | null | undefined): AnchorRect { |
| if (!anchor) return NO_RECT; |
| if ("getBoundingClientRect" in anchor) { |
| const rect = anchor.getBoundingClientRect(); |
| return { |
| left: rect.left, |
| top: rect.top, |
| right: rect.right, |
| bottom: rect.bottom, |
| width: rect.width, |
| height: rect.height, |
| }; |
| } |
| return anchor; |
| } |
|
|
| function samePosition(a: CSSProperties, b: CSSProperties): boolean { |
| return ( |
| a.left === b.left && |
| a.top === b.top && |
| a.maxWidth === b.maxWidth && |
| a.maxHeight === b.maxHeight && |
| a.visibility === b.visibility |
| ); |
| } |
|
|
| interface OverlayLayerOptions { |
| panelRef: RefObject<HTMLElement | null>; |
| onDismiss: () => void; |
| dismissOnOutside?: boolean; |
| initialFocus?: "first" | "none" | string; |
| restoreFocus?: boolean; |
| trapFocus?: boolean; |
| outsideElements?: Array<HTMLElement | null>; |
| } |
|
|
| |
| |
| export function useOverlayLayer({ |
| panelRef, |
| onDismiss, |
| dismissOnOutside = true, |
| initialFocus = "first", |
| restoreFocus = true, |
| trapFocus = false, |
| outsideElements = [], |
| }: OverlayLayerOptions): void { |
| const id = useId(); |
| const stack = useContext(OverlayStackContext); |
| const dismissRef = useRef(onDismiss); |
| const outsideRef = useRef(outsideElements); |
| dismissRef.current = onDismiss; |
| outsideRef.current = outsideElements; |
|
|
| useEffect(() => stack?.register(id), [id, stack]); |
|
|
| useEffect(() => { |
| const onPointerDown = (event: PointerEvent) => { |
| if (!dismissOnOutside || (stack && !stack.isTop(id))) return; |
| const target = event.target as Node | null; |
| if (!target || panelRef.current?.contains(target)) return; |
| if (outsideRef.current.some((element) => element?.contains(target))) return; |
| if ( |
| document.activeElement instanceof HTMLElement && |
| panelRef.current?.contains(document.activeElement) |
| ) |
| document.activeElement.blur(); |
| dismissRef.current(); |
| }; |
| const onKeyDown = (event: KeyboardEvent) => { |
| if (stack && !stack.isTop(id)) return; |
| if (event.key === "Escape") { |
| event.preventDefault(); |
| event.stopPropagation(); |
| if ( |
| document.activeElement instanceof HTMLElement && |
| panelRef.current?.contains(document.activeElement) |
| ) |
| document.activeElement.blur(); |
| dismissRef.current(); |
| return; |
| } |
| if (event.key !== "Tab" || !trapFocus) return; |
| const focusable = Array.from( |
| panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [] |
| ).filter((element) => element.getClientRects().length > 0); |
| if (!focusable.length) { |
| event.preventDefault(); |
| panelRef.current?.focus(); |
| return; |
| } |
| const first = focusable[0]; |
| const last = focusable.at(-1)!; |
| const active = document.activeElement; |
| if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) { |
| event.preventDefault(); |
| last.focus(); |
| } else if (!event.shiftKey && active === last) { |
| event.preventDefault(); |
| first.focus(); |
| } |
| }; |
| document.addEventListener("pointerdown", onPointerDown, true); |
| document.addEventListener("keydown", onKeyDown, true); |
| return () => { |
| document.removeEventListener("pointerdown", onPointerDown, true); |
| document.removeEventListener("keydown", onKeyDown, true); |
| }; |
| }, [dismissOnOutside, id, panelRef, stack, trapFocus]); |
|
|
| useLayoutEffect(() => { |
| const previous = |
| document.activeElement instanceof HTMLElement ? document.activeElement : null; |
| const frame = window.requestAnimationFrame(() => { |
| if (initialFocus === "none") return; |
| const selector = initialFocus === "first" ? FOCUSABLE : initialFocus; |
| const target = panelRef.current?.querySelector<HTMLElement>(selector); |
| target?.focus({ preventScroll: true }); |
| }); |
| return () => { |
| window.cancelAnimationFrame(frame); |
| if (restoreFocus && previous?.isConnected) previous.focus({ preventScroll: true }); |
| }; |
| }, [initialFocus, panelRef, restoreFocus]); |
| } |
|
|
| export function BodyPortal({ children }: { children: ReactNode }) { |
| return createPortal(children, document.body); |
| } |
|
|
| interface AnchoredOverlayProps { |
| |
| |
| |
| anchor: Anchor | null | undefined; |
| className: string; |
| children: ReactNode; |
| onDismiss: () => void; |
| placement?: Placement; |
| role?: AriaRole; |
| ariaLabel?: string; |
| id?: string; |
| initialFocus?: "first" | "none" | string; |
| restoreFocus?: boolean; |
| dismissOnOutside?: boolean; |
| onKeyDown?: (event: ReactKeyboardEvent<HTMLElement>) => void; |
| dataKind?: string; |
| } |
|
|
| |
| export function AnchoredOverlay({ |
| anchor, |
| className, |
| children, |
| onDismiss, |
| placement = "bottom-start", |
| role, |
| ariaLabel, |
| id, |
| initialFocus = "first", |
| restoreFocus = true, |
| dismissOnOutside = true, |
| onKeyDown, |
| dataKind, |
| }: AnchoredOverlayProps) { |
| const panelRef = useRef<HTMLDivElement>(null); |
| const [style, setStyle] = useState<CSSProperties>({ |
| position: "fixed", |
| left: 0, |
| top: 0, |
| visibility: "hidden", |
| }); |
| const anchorElement = anchor && "getBoundingClientRect" in anchor ? anchor : null; |
|
|
| useOverlayLayer({ |
| panelRef, |
| onDismiss, |
| dismissOnOutside, |
| initialFocus, |
| restoreFocus, |
| outsideElements: [anchorElement], |
| }); |
|
|
| const updatePosition = useCallback(() => { |
| const panel = panelRef.current; |
| if (!panel) return; |
| const target = anchorRect(anchor); |
| const viewport = window.visualViewport; |
| const viewportLeft = viewport?.offsetLeft ?? 0; |
| const viewportTop = viewport?.offsetTop ?? 0; |
| const viewportWidth = viewport?.width ?? window.innerWidth; |
| const viewportHeight = viewport?.height ?? window.innerHeight; |
| const measured = panel.getBoundingClientRect(); |
| |
| |
| const placed = computeOverlayPosition({ |
| placement, |
| target, |
| panel: { |
| width: Math.max(measured.width, panel.scrollWidth), |
| height: Math.max(measured.height, panel.scrollHeight), |
| }, |
| viewport: { |
| left: viewportLeft, |
| top: viewportTop, |
| width: viewportWidth, |
| height: viewportHeight, |
| }, |
| }); |
| const next: CSSProperties = { |
| position: "fixed", |
| left: placed.left, |
| top: placed.top, |
| maxWidth: placed.maxWidth, |
| maxHeight: placed.maxHeight, |
| visibility: "visible", |
| }; |
| setStyle((current) => (samePosition(current, next) ? current : next)); |
| }, [anchor, placement]); |
|
|
| useLayoutEffect(() => { |
| updatePosition(); |
| const frame = window.requestAnimationFrame(updatePosition); |
| const observer = new ResizeObserver(updatePosition); |
| if (panelRef.current) observer.observe(panelRef.current); |
| if (anchorElement) observer.observe(anchorElement); |
| window.addEventListener("resize", updatePosition); |
| window.addEventListener("scroll", updatePosition, true); |
| window.visualViewport?.addEventListener("resize", updatePosition); |
| window.visualViewport?.addEventListener("scroll", updatePosition); |
| return () => { |
| window.cancelAnimationFrame(frame); |
| observer.disconnect(); |
| window.removeEventListener("resize", updatePosition); |
| window.removeEventListener("scroll", updatePosition, true); |
| window.visualViewport?.removeEventListener("resize", updatePosition); |
| window.visualViewport?.removeEventListener("scroll", updatePosition); |
| }; |
| }, [anchorElement, updatePosition]); |
|
|
| return ( |
| <BodyPortal> |
| <div |
| ref={panelRef} |
| id={id} |
| className={className} |
| style={style} |
| role={role} |
| aria-label={ariaLabel} |
| data-overlay-kind={dataKind} |
| onKeyDown={onKeyDown} |
| > |
| {children} |
| </div> |
| </BodyPortal> |
| ); |
| } |
|
|