diff --git a/apps/app/src/components/ui/hooks/use-hover-popover.ts b/apps/app/src/components/ui/hooks/use-hover-popover.ts index dd2656de50..ff6d5ab649 100644 --- a/apps/app/src/components/ui/hooks/use-hover-popover.ts +++ b/apps/app/src/components/ui/hooks/use-hover-popover.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; +import { useResponsiveOverlayBehavior } from "@bb/shared-ui/responsive-overlay"; interface HoverPopoverHandlers { onBlur: () => void; @@ -51,7 +51,7 @@ export function useHoverPopover({ closeDelayMs = DEFAULT_CLOSE_DELAY_MS, hoverableContent = true, }: UseHoverPopoverOptions = {}): UseHoverPopoverResult { - const isPointerCoarse = usePointerCoarse(); + const { supportsHover } = useResponsiveOverlayBehavior(); const [open, setOpen] = useState(false); const [isFocusOverTrigger, setIsFocusOverTrigger] = useState(false); const [isFocusOverContent, setIsFocusOverContent] = useState(false); @@ -68,9 +68,8 @@ export function useHoverPopover({ }, []); useEffect(() => { - if (isPointerCoarse) return; - clearToggleTimeout(); + if (!supportsHover) return; if (isFocusOverTrigger || isFocusOverContent) { if (!open) setOpen(true); @@ -106,7 +105,7 @@ export function useHoverPopover({ closeDelayMs, isFocusOverContent, isFocusOverTrigger, - isPointerCoarse, + supportsHover, isPointerOverContent, isPointerOverTrigger, open, @@ -134,7 +133,7 @@ export function useHoverPopover({ ); const triggerHoverProps = { - ...(isPointerCoarse + ...(!supportsHover ? EMPTY_HOVER_PROPS : { onPointerEnter: () => { @@ -149,7 +148,7 @@ export function useHoverPopover({ }; const contentHoverProps = { - ...(isPointerCoarse + ...(!supportsHover ? EMPTY_HOVER_PROPS : hoverableContent ? { diff --git a/packages/plugin-registry/r/responsive-overlay.json b/packages/plugin-registry/r/responsive-overlay.json index 0a608a562f..e614f5e4e2 100644 --- a/packages/plugin-registry/r/responsive-overlay.json +++ b/packages/plugin-registry/r/responsive-overlay.json @@ -11,12 +11,13 @@ "@bb/overlay-trigger", "@bb/portal-scope", "@bb/use-compact-viewport", + "@bb/use-pointer-coarse", "@bb/utils" ], "files": [ { "path": "registry/components/ui/responsive-overlay.tsx", - "content": "import * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Slot } from \"@radix-ui/react-slot\";\n\nimport {\n blurActiveKeyboardInputBeforeOverlayOpen,\n blurActiveKeyboardInputBeforeOverlayClose,\n blurActiveKeyboardInputWithin,\n getOverlayTriggerClassName,\n preventOverlayTriggerSelection,\n} from \"./overlay-trigger.js\";\nimport { useIsCompactViewport } from \"./hooks/use-compact-viewport.js\";\nimport { usePortalScopeProps } from \"../../lib/portal-scope.js\";\nimport { cn } from \"../../lib/utils.js\";\n\nexport interface ResponsiveOverlayContextValue {\n isCompactViewport: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n}\n\nconst RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS = 120;\n\nexport const COMPACT_SHEET_CONTENT_STYLE: React.CSSProperties = {\n width: \"auto\",\n minWidth: \"auto\",\n maxWidth: \"none\",\n};\n\nconst DRAWER_KEYBOARD_INSET_PROPERTY = \"--bb-drawer-keyboard-inset\";\nconst DRAWER_KEYBOARD_MIN_OVERLAP_PX = 80;\n\nfunction resetDrawerKeyboardStyles(drawerElement: HTMLElement | null): void {\n if (drawerElement === null) return;\n\n drawerElement.style.height = \"\";\n drawerElement.style.bottom = \"\";\n drawerElement.style.removeProperty(DRAWER_KEYBOARD_INSET_PROPERTY);\n}\n\nexport function measureDrawerKeyboardOverlap({\n layoutViewportHeight,\n visualViewportHeight,\n visualViewportOffsetTop,\n}: {\n layoutViewportHeight: number;\n visualViewportHeight: number;\n visualViewportOffsetTop: number;\n}): number {\n const overlap = Math.round(\n layoutViewportHeight - (visualViewportHeight + visualViewportOffsetTop),\n );\n return overlap >= DRAWER_KEYBOARD_MIN_OVERLAP_PX ? overlap : 0;\n}\n\nfunction useDrawerKeyboardInset(\n panelRef: React.RefObject,\n open: boolean,\n): void {\n React.useEffect(() => {\n const panel = panelRef.current;\n const visualViewport = window.visualViewport;\n if (panel === null || !open || !visualViewport) return;\n\n let animationFrame: number | null = null;\n const applyInset = () => {\n animationFrame = null;\n const overlap = measureDrawerKeyboardOverlap({\n layoutViewportHeight: panel.ownerDocument.documentElement.clientHeight,\n visualViewportHeight: visualViewport.height,\n visualViewportOffsetTop: visualViewport.offsetTop,\n });\n if (overlap === 0) {\n panel.style.bottom = \"\";\n panel.style.removeProperty(DRAWER_KEYBOARD_INSET_PROPERTY);\n return;\n }\n panel.style.bottom = `${overlap}px`;\n panel.style.setProperty(DRAWER_KEYBOARD_INSET_PROPERTY, `${overlap}px`);\n };\n const scheduleUpdate = () => {\n if (animationFrame !== null) {\n window.cancelAnimationFrame(animationFrame);\n }\n animationFrame = window.requestAnimationFrame(applyInset);\n };\n\n applyInset();\n visualViewport.addEventListener(\"resize\", scheduleUpdate);\n visualViewport.addEventListener(\"scroll\", scheduleUpdate);\n\n return () => {\n visualViewport.removeEventListener(\"resize\", scheduleUpdate);\n visualViewport.removeEventListener(\"scroll\", scheduleUpdate);\n if (animationFrame !== null) {\n window.cancelAnimationFrame(animationFrame);\n }\n resetDrawerKeyboardStyles(panel);\n };\n }, [open, panelRef]);\n}\n\nexport function useResponsiveRoot(\n controlledOpen: boolean | undefined,\n controlledOnChange: ((open: boolean) => void) | undefined,\n defaultOpen: boolean = false,\n): ResponsiveOverlayContextValue {\n const isCompactViewport = useIsCompactViewport();\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const onOpenChange = React.useCallback(\n (next: boolean) => {\n if (open && !next && isCompactViewport) {\n blurActiveKeyboardInputBeforeOverlayClose();\n }\n if (!isControlled) {\n setInternalOpen(next);\n }\n controlledOnChange?.(next);\n },\n [isCompactViewport, isControlled, controlledOnChange, open],\n );\n\n return React.useMemo(\n () => ({ isCompactViewport, open, onOpenChange }),\n [isCompactViewport, open, onOpenChange],\n );\n}\n\ninterface MobileTriggerProps {\n asChild?: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n haspopup: \"menu\" | \"dialog\";\n children: React.ReactNode;\n onClick?: React.MouseEventHandler;\n}\n\nexport const MobileTrigger = React.forwardRef<\n HTMLButtonElement,\n MobileTriggerProps &\n Omit<\n React.ButtonHTMLAttributes,\n keyof MobileTriggerProps\n >\n>(\n (\n {\n asChild,\n open,\n onOpenChange,\n haspopup,\n onClick,\n children,\n className,\n ...domProps\n },\n ref,\n ) => {\n const triggerClassName = getOverlayTriggerClassName(className);\n const handleClick: React.MouseEventHandler = (e) => {\n onClick?.(e);\n if (!e.defaultPrevented) {\n if (!open) {\n blurActiveKeyboardInputBeforeOverlayOpen();\n }\n onOpenChange(!open);\n }\n };\n\n const ariaProps = {\n \"aria-expanded\": open,\n \"aria-haspopup\": haspopup,\n \"data-state\": open ? \"open\" : \"closed\",\n } as const;\n\n if (asChild) {\n return (\n \n {children}\n \n );\n }\n\n return (\n \n {children}\n \n );\n },\n);\nMobileTrigger.displayName = \"MobileTrigger\";\n\nconst RADIX_CONTENT_PROP_NAMES = [\n \"side\",\n \"sideOffset\",\n \"align\",\n \"alignOffset\",\n \"collisionPadding\",\n \"collisionBoundary\",\n \"arrowPadding\",\n \"sticky\",\n \"hideWhenDetached\",\n \"avoidCollisions\",\n \"onOpenAutoFocus\",\n \"onCloseAutoFocus\",\n \"onEscapeKeyDown\",\n \"onPointerDownOutside\",\n \"onFocusOutside\",\n \"onInteractOutside\",\n] as const;\n\ntype RadixContentPropName = (typeof RADIX_CONTENT_PROP_NAMES)[number];\n\nconst RADIX_CONTENT_KEYS: ReadonlySet = new Set(\n RADIX_CONTENT_PROP_NAMES,\n);\n\nexport function stripRadixContentProps>(\n props: T,\n): Omit {\n const result = {} as Record;\n for (const key of Object.keys(props)) {\n if (!RADIX_CONTENT_KEYS.has(key)) {\n result[key] = props[key];\n }\n }\n return result as Omit;\n}\n\ninterface ResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n closeOnBackdropClick?: boolean;\n onAfterCloseAutoFocus?: () => void;\n srLabel?: string;\n labelledBy?: string;\n describedBy?: string;\n contentClassName?: string;\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nexport function useResponsiveDrawerRealization({\n open,\n enabled = true,\n}: {\n open: boolean;\n enabled?: boolean;\n}): { isContentRealized: boolean; realizeContent: () => void } {\n const [isContentRealized, setIsContentRealized] = React.useState(false);\n const realizeContent = React.useCallback(\n () => setIsContentRealized(true),\n [],\n );\n\n React.useEffect(() => {\n if (!enabled || !open || isContentRealized) {\n return;\n }\n\n let firstFrame: number | null = null;\n let secondFrame: number | null = null;\n firstFrame = window.requestAnimationFrame(() => {\n firstFrame = null;\n secondFrame = window.requestAnimationFrame(() => {\n secondFrame = null;\n realizeContent();\n });\n });\n const fallback = window.setTimeout(\n realizeContent,\n RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS,\n );\n\n return () => {\n if (firstFrame !== null) {\n window.cancelAnimationFrame(firstFrame);\n }\n if (secondFrame !== null) {\n window.cancelAnimationFrame(secondFrame);\n }\n window.clearTimeout(fallback);\n };\n }, [enabled, isContentRealized, open, realizeContent]);\n\n return {\n isContentRealized: enabled && isContentRealized,\n realizeContent,\n };\n}\n\nexport function ResponsiveDrawerShell({\n open,\n onOpenChange,\n onAfterCloseAutoFocus,\n closeOnBackdropClick = true,\n srLabel,\n labelledBy,\n describedBy,\n contentClassName,\n onContentAnimationEnd,\n children,\n}: ResponsiveDrawerShellProps) {\n const { isContentRealized } = useResponsiveDrawerRealization({ open });\n\n if (!open && !isContentRealized) {\n return null;\n }\n\n return (\n \n {isContentRealized ? (\n children\n ) : (\n \n )}\n \n );\n}\n\ninterface PersistentResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n closeOnBackdropClick?: boolean;\n onAfterCloseAutoFocus?: () => void;\n srLabel?: string;\n labelledBy?: string;\n describedBy?: string;\n contentClassName?: string;\n motionDurationMs?: number;\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nconst PERSISTENT_DRAWER_EASING = \"cubic-bezier(0.32, 0.72, 0, 1)\";\nconst PERSISTENT_DRAWER_CLOSE_RATIO = 0.25;\nconst PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC = 450;\nconst PERSISTENT_DRAWER_FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\ntype PersistentDrawerStackEntry = {\n panel: () => HTMLElement | null;\n requestClose: () => void;\n};\n\ntype PersistentDrawerStack = {\n entries: PersistentDrawerStackEntry[];\n handleKeyDown: (event: KeyboardEvent) => void;\n};\n\nconst persistentDrawerStacks = new WeakMap();\n\nfunction getDrawerFocusableElements(panel: HTMLElement): HTMLElement[] {\n return Array.from(\n panel.querySelectorAll(PERSISTENT_DRAWER_FOCUSABLE_SELECTOR),\n ).filter(\n (element) => element.closest('[aria-hidden=\"true\"], [inert]') === null,\n );\n}\n\nfunction activeElementIsInAnotherOverlay(\n activeElement: Element | null,\n panel: HTMLElement,\n): boolean {\n const overlay = activeElement?.closest(\n \"[data-bb-portaled-overlay]\",\n );\n return overlay !== null && overlay !== undefined && overlay !== panel;\n}\n\nfunction handleDrawerTab(event: KeyboardEvent, panel: HTMLElement): void {\n const activeElement = panel.ownerDocument.activeElement;\n if (\n !panel.contains(activeElement) &&\n activeElementIsInAnotherOverlay(activeElement, panel)\n ) {\n return;\n }\n\n const focusable = getDrawerFocusableElements(panel);\n event.preventDefault();\n if (focusable.length === 0) {\n panel.focus({ preventScroll: true });\n return;\n }\n\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n if (event.shiftKey) {\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === first\n ) {\n last?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.max(0, index - 1)]?.focus({ preventScroll: true });\n return;\n }\n\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === last\n ) {\n first?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.min(focusable.length - 1, index + 1)]?.focus({\n preventScroll: true,\n });\n}\n\nfunction registerOpenDrawer(\n ownerDocument: Document,\n entry: PersistentDrawerStackEntry,\n): () => void {\n let stack = persistentDrawerStacks.get(ownerDocument);\n if (stack === undefined) {\n const entries: PersistentDrawerStackEntry[] = [];\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented) {\n return;\n }\n const topEntry = entries[entries.length - 1];\n const panel = topEntry?.panel() ?? null;\n if (topEntry === undefined || panel === null) {\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n topEntry.requestClose();\n } else if (event.key === \"Tab\") {\n handleDrawerTab(event, panel);\n }\n };\n stack = { entries, handleKeyDown };\n persistentDrawerStacks.set(ownerDocument, stack);\n ownerDocument.addEventListener(\"keydown\", handleKeyDown);\n }\n stack.entries.push(entry);\n\n return () => {\n const currentStack = persistentDrawerStacks.get(ownerDocument);\n if (currentStack === undefined) {\n return;\n }\n const index = currentStack.entries.indexOf(entry);\n if (index >= 0) {\n currentStack.entries.splice(index, 1);\n }\n if (currentStack.entries.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", currentStack.handleKeyDown);\n persistentDrawerStacks.delete(ownerDocument);\n }\n };\n}\n\ninterface UsePersistentOverlayFocusArgs {\n onAfterCloseAutoFocus?: () => void;\n onBeforeCloseAutoFocus?: () => void;\n open: boolean;\n panelRef: React.RefObject;\n requestClose: () => void;\n}\n\nexport function usePersistentOverlayFocus({\n onAfterCloseAutoFocus,\n onBeforeCloseAutoFocus,\n open,\n panelRef,\n requestClose,\n}: UsePersistentOverlayFocusArgs): void {\n const returnFocusRef = React.useRef(null);\n\n React.useLayoutEffect(() => {\n if (!open) return;\n const panel = panelRef.current;\n if (panel === null) return;\n const ownerDocument = panel.ownerDocument;\n const previousFocus = ownerDocument.activeElement;\n returnFocusRef.current =\n previousFocus instanceof HTMLElement &&\n previousFocus !== ownerDocument.body\n ? previousFocus\n : null;\n const unregister = registerOpenDrawer(ownerDocument, {\n panel: () => panelRef.current,\n requestClose,\n });\n panel.focus({ preventScroll: true });\n return unregister;\n }, [open, panelRef, requestClose]);\n\n const previousOpenRef = React.useRef(open);\n React.useLayoutEffect(() => {\n let cancelDeferredFocus: (() => void) | undefined;\n if (previousOpenRef.current && !open) {\n onBeforeCloseAutoFocus?.();\n const returnFocus = returnFocusRef.current;\n if (\n returnFocus?.isConnected &&\n returnFocus.closest('[aria-hidden=\"true\"], [inert]') === null\n ) {\n returnFocus.focus({ preventScroll: true });\n if (returnFocus.ownerDocument.activeElement !== returnFocus) {\n const ownerWindow = returnFocus.ownerDocument.defaultView;\n if (ownerWindow !== null) {\n const frame = ownerWindow.requestAnimationFrame(() => {\n if (\n returnFocus.isConnected &&\n returnFocus.closest('[aria-hidden=\"true\"], [inert]') === null\n ) {\n returnFocus.focus({ preventScroll: true });\n }\n onAfterCloseAutoFocus?.();\n });\n cancelDeferredFocus = () => ownerWindow.cancelAnimationFrame(frame);\n }\n }\n }\n returnFocusRef.current = null;\n if (cancelDeferredFocus === undefined) {\n onAfterCloseAutoFocus?.();\n }\n }\n previousOpenRef.current = open;\n return cancelDeferredFocus;\n }, [onAfterCloseAutoFocus, onBeforeCloseAutoFocus, open]);\n}\n\ntype PersistentDrawerDrag = {\n pointerId: number;\n startY: number;\n lastY: number;\n lastTimeMs: number;\n velocityY: number;\n height: number;\n};\n\nexport function PersistentResponsiveDrawerShell({\n open,\n onOpenChange,\n onAfterCloseAutoFocus,\n closeOnBackdropClick = true,\n srLabel,\n labelledBy,\n describedBy,\n contentClassName,\n motionDurationMs = 220,\n onContentAnimationEnd,\n children,\n}: PersistentResponsiveDrawerShellProps) {\n const panelRef = React.useRef(null);\n const backdropRef = React.useRef(null);\n const dragRef = React.useRef(null);\n const settledStateRef = React.useRef(null);\n const labelId = React.useId();\n const portalScopeProps = usePortalScopeProps();\n const transition = `transform ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const backdropTransition = `opacity ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const onOpenChangeRef = React.useRef(onOpenChange);\n React.useLayoutEffect(() => {\n onOpenChangeRef.current = onOpenChange;\n }, [onOpenChange]);\n const requestClose = React.useCallback(() => {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n onOpenChangeRef.current(false);\n }, []);\n const prepareCloseAutoFocus = React.useCallback(() => {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n }, []);\n\n usePersistentOverlayFocus({\n onAfterCloseAutoFocus,\n onBeforeCloseAutoFocus: prepareCloseAutoFocus,\n open,\n panelRef,\n requestClose,\n });\n\n useDrawerKeyboardInset(panelRef, open);\n\n const reportSettled = React.useCallback(\n (settledOpen: boolean) => {\n if (settledStateRef.current === settledOpen) {\n return;\n }\n settledStateRef.current = settledOpen;\n onContentAnimationEnd?.(settledOpen);\n },\n [onContentAnimationEnd],\n );\n\n React.useEffect(() => {\n settledStateRef.current = null;\n const timeout = window.setTimeout(\n () => reportSettled(open),\n motionDurationMs + 50,\n );\n return () => window.clearTimeout(timeout);\n }, [motionDurationMs, open, reportSettled]);\n\n const setDragPosition = React.useCallback(\n (offsetY: number, height: number, animate: boolean) => {\n const panel = panelRef.current;\n const backdrop = backdropRef.current;\n if (panel === null || backdrop === null) {\n return;\n }\n panel.style.transition = animate ? transition : \"none\";\n panel.style.transform = `translate3d(0, ${offsetY}px, 0)`;\n backdrop.style.transition = animate ? backdropTransition : \"none\";\n backdrop.style.opacity = String(\n Math.max(0, Math.min(1, 1 - offsetY / height)),\n );\n },\n [backdropTransition, transition],\n );\n\n const handleDragStart = React.useCallback(\n (event: React.PointerEvent) => {\n if (!open || event.button !== 0) {\n return;\n }\n event.currentTarget.setPointerCapture(event.pointerId);\n const nowMs = Date.now();\n const height = Math.max(panelRef.current?.clientHeight ?? 0, 1);\n dragRef.current = {\n pointerId: event.pointerId,\n startY: event.clientY,\n lastY: event.clientY,\n lastTimeMs: nowMs,\n velocityY: 0,\n height,\n };\n setDragPosition(0, height, false);\n event.preventDefault();\n },\n [open, setDragPosition],\n );\n\n const handleDragMove = React.useCallback(\n (event: React.PointerEvent) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n const nowMs = Date.now();\n const elapsedMs = nowMs - drag.lastTimeMs;\n if (elapsedMs > 0) {\n drag.velocityY = ((event.clientY - drag.lastY) / elapsedMs) * 1000;\n drag.lastY = event.clientY;\n drag.lastTimeMs = nowMs;\n }\n setDragPosition(\n Math.max(0, event.clientY - drag.startY),\n drag.height,\n false,\n );\n event.preventDefault();\n },\n [setDragPosition],\n );\n\n const finishDrag = React.useCallback(\n (event: React.PointerEvent, cancelled: boolean) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n dragRef.current = null;\n const offsetY = Math.max(0, event.clientY - drag.startY);\n const shouldClose =\n !cancelled &&\n (offsetY >= drag.height * PERSISTENT_DRAWER_CLOSE_RATIO ||\n drag.velocityY >= PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC);\n if (shouldClose) {\n setDragPosition(drag.height, drag.height, true);\n requestClose();\n } else {\n setDragPosition(0, drag.height, true);\n }\n event.preventDefault();\n },\n [requestClose, setDragPosition],\n );\n\n const portalTarget = typeof document === \"undefined\" ? null : document.body;\n if (portalTarget === null) {\n return null;\n }\n\n return createPortal(\n <>\n event.preventDefault()}\n />\n {\n if (\n event.currentTarget === event.target &&\n event.propertyName === \"transform\"\n ) {\n reportSettled(open);\n }\n }}\n >\n finishDrag(event, false)}\n onPointerCancel={(event) => finishDrag(event, true)}\n >\n
\n
\n {srLabel === undefined ? null : (\n

\n {srLabel}\n

\n )}\n {children}\n \n ,\n portalTarget,\n );\n}\n", + "content": "import * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Slot } from \"@radix-ui/react-slot\";\n\nimport {\n blurActiveKeyboardInputBeforeOverlayOpen,\n blurActiveKeyboardInputBeforeOverlayClose,\n blurActiveKeyboardInputWithin,\n getOverlayTriggerClassName,\n preventOverlayTriggerSelection,\n} from \"./overlay-trigger.js\";\nimport { useIsCompactViewport } from \"./hooks/use-compact-viewport.js\";\nimport { usePointerCoarse } from \"./hooks/use-pointer-coarse.js\";\nimport { usePortalScopeProps } from \"../../lib/portal-scope.js\";\nimport { cn } from \"../../lib/utils.js\";\n\nexport interface ResponsiveOverlayContextValue {\n isCompactViewport: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n}\n\nconst RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS = 120;\n\nexport const COMPACT_SHEET_CONTENT_STYLE: React.CSSProperties = {\n width: \"auto\",\n minWidth: \"auto\",\n maxWidth: \"none\",\n};\n\nconst DRAWER_KEYBOARD_INSET_PROPERTY = \"--bb-drawer-keyboard-inset\";\nconst DRAWER_KEYBOARD_MIN_OVERLAP_PX = 80;\n\nfunction resetDrawerKeyboardStyles(drawerElement: HTMLElement | null): void {\n if (drawerElement === null) return;\n\n drawerElement.style.height = \"\";\n drawerElement.style.bottom = \"\";\n drawerElement.style.removeProperty(DRAWER_KEYBOARD_INSET_PROPERTY);\n}\n\nexport function measureDrawerKeyboardOverlap({\n layoutViewportHeight,\n visualViewportHeight,\n visualViewportOffsetTop,\n}: {\n layoutViewportHeight: number;\n visualViewportHeight: number;\n visualViewportOffsetTop: number;\n}): number {\n const overlap = Math.round(\n layoutViewportHeight - (visualViewportHeight + visualViewportOffsetTop),\n );\n return overlap >= DRAWER_KEYBOARD_MIN_OVERLAP_PX ? overlap : 0;\n}\n\nfunction useDrawerKeyboardInset(\n panelRef: React.RefObject,\n open: boolean,\n): void {\n React.useEffect(() => {\n const panel = panelRef.current;\n const visualViewport = window.visualViewport;\n if (panel === null || !open || !visualViewport) return;\n\n let animationFrame: number | null = null;\n const applyInset = () => {\n animationFrame = null;\n const overlap = measureDrawerKeyboardOverlap({\n layoutViewportHeight: panel.ownerDocument.documentElement.clientHeight,\n visualViewportHeight: visualViewport.height,\n visualViewportOffsetTop: visualViewport.offsetTop,\n });\n if (overlap === 0) {\n panel.style.bottom = \"\";\n panel.style.removeProperty(DRAWER_KEYBOARD_INSET_PROPERTY);\n return;\n }\n panel.style.bottom = `${overlap}px`;\n panel.style.setProperty(DRAWER_KEYBOARD_INSET_PROPERTY, `${overlap}px`);\n };\n const scheduleUpdate = () => {\n if (animationFrame !== null) {\n window.cancelAnimationFrame(animationFrame);\n }\n animationFrame = window.requestAnimationFrame(applyInset);\n };\n\n applyInset();\n visualViewport.addEventListener(\"resize\", scheduleUpdate);\n visualViewport.addEventListener(\"scroll\", scheduleUpdate);\n\n return () => {\n visualViewport.removeEventListener(\"resize\", scheduleUpdate);\n visualViewport.removeEventListener(\"scroll\", scheduleUpdate);\n if (animationFrame !== null) {\n window.cancelAnimationFrame(animationFrame);\n }\n resetDrawerKeyboardStyles(panel);\n };\n }, [open, panelRef]);\n}\n\nexport function useResponsiveOverlayBehavior() {\n const presentation = useIsCompactViewport() ? \"drawer\" : \"floating\";\n const isPointerCoarse = usePointerCoarse();\n\n return {\n presentation,\n supportsHover: presentation === \"floating\" && !isPointerCoarse,\n };\n}\n\nexport function useResponsiveRoot(\n controlledOpen: boolean | undefined,\n controlledOnChange: ((open: boolean) => void) | undefined,\n defaultOpen: boolean = false,\n): ResponsiveOverlayContextValue {\n const { presentation } = useResponsiveOverlayBehavior();\n const isCompactViewport = presentation === \"drawer\";\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const onOpenChange = React.useCallback(\n (next: boolean) => {\n if (open && !next && isCompactViewport) {\n blurActiveKeyboardInputBeforeOverlayClose();\n }\n if (!isControlled) {\n setInternalOpen(next);\n }\n controlledOnChange?.(next);\n },\n [isCompactViewport, isControlled, controlledOnChange, open],\n );\n\n return React.useMemo(\n () => ({ isCompactViewport, open, onOpenChange }),\n [isCompactViewport, open, onOpenChange],\n );\n}\n\ninterface MobileTriggerProps {\n asChild?: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n haspopup: \"menu\" | \"dialog\";\n children: React.ReactNode;\n onClick?: React.MouseEventHandler;\n}\n\nexport const MobileTrigger = React.forwardRef<\n HTMLButtonElement,\n MobileTriggerProps &\n Omit<\n React.ButtonHTMLAttributes,\n keyof MobileTriggerProps\n >\n>(\n (\n {\n asChild,\n open,\n onOpenChange,\n haspopup,\n onClick,\n children,\n className,\n ...domProps\n },\n ref,\n ) => {\n const triggerClassName = getOverlayTriggerClassName(className);\n const handleClick: React.MouseEventHandler = (e) => {\n onClick?.(e);\n if (!e.defaultPrevented) {\n if (!open) {\n blurActiveKeyboardInputBeforeOverlayOpen();\n }\n onOpenChange(!open);\n }\n };\n\n const ariaProps = {\n \"aria-expanded\": open,\n \"aria-haspopup\": haspopup,\n \"data-state\": open ? \"open\" : \"closed\",\n } as const;\n\n if (asChild) {\n return (\n \n {children}\n \n );\n }\n\n return (\n \n {children}\n \n );\n },\n);\nMobileTrigger.displayName = \"MobileTrigger\";\n\nconst RADIX_CONTENT_PROP_NAMES = [\n \"side\",\n \"sideOffset\",\n \"align\",\n \"alignOffset\",\n \"collisionPadding\",\n \"collisionBoundary\",\n \"arrowPadding\",\n \"sticky\",\n \"hideWhenDetached\",\n \"avoidCollisions\",\n \"onOpenAutoFocus\",\n \"onCloseAutoFocus\",\n \"onEscapeKeyDown\",\n \"onPointerDownOutside\",\n \"onFocusOutside\",\n \"onInteractOutside\",\n] as const;\n\ntype RadixContentPropName = (typeof RADIX_CONTENT_PROP_NAMES)[number];\n\nconst RADIX_CONTENT_KEYS: ReadonlySet = new Set(\n RADIX_CONTENT_PROP_NAMES,\n);\n\nexport function stripRadixContentProps>(\n props: T,\n): Omit {\n const result = {} as Record;\n for (const key of Object.keys(props)) {\n if (!RADIX_CONTENT_KEYS.has(key)) {\n result[key] = props[key];\n }\n }\n return result as Omit;\n}\n\ninterface ResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n closeOnBackdropClick?: boolean;\n onAfterCloseAutoFocus?: () => void;\n srLabel?: string;\n labelledBy?: string;\n describedBy?: string;\n contentClassName?: string;\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nexport function useResponsiveDrawerRealization({\n open,\n enabled = true,\n}: {\n open: boolean;\n enabled?: boolean;\n}): { isContentRealized: boolean; realizeContent: () => void } {\n const [isContentRealized, setIsContentRealized] = React.useState(false);\n const realizeContent = React.useCallback(\n () => setIsContentRealized(true),\n [],\n );\n\n React.useEffect(() => {\n if (!enabled || !open || isContentRealized) {\n return;\n }\n\n let firstFrame: number | null = null;\n let secondFrame: number | null = null;\n firstFrame = window.requestAnimationFrame(() => {\n firstFrame = null;\n secondFrame = window.requestAnimationFrame(() => {\n secondFrame = null;\n realizeContent();\n });\n });\n const fallback = window.setTimeout(\n realizeContent,\n RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS,\n );\n\n return () => {\n if (firstFrame !== null) {\n window.cancelAnimationFrame(firstFrame);\n }\n if (secondFrame !== null) {\n window.cancelAnimationFrame(secondFrame);\n }\n window.clearTimeout(fallback);\n };\n }, [enabled, isContentRealized, open, realizeContent]);\n\n return {\n isContentRealized: enabled && isContentRealized,\n realizeContent,\n };\n}\n\nexport function ResponsiveDrawerShell({\n open,\n onOpenChange,\n onAfterCloseAutoFocus,\n closeOnBackdropClick = true,\n srLabel,\n labelledBy,\n describedBy,\n contentClassName,\n onContentAnimationEnd,\n children,\n}: ResponsiveDrawerShellProps) {\n const { isContentRealized } = useResponsiveDrawerRealization({ open });\n\n if (!open && !isContentRealized) {\n return null;\n }\n\n return (\n \n {isContentRealized ? (\n children\n ) : (\n \n )}\n \n );\n}\n\ninterface PersistentResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n closeOnBackdropClick?: boolean;\n onAfterCloseAutoFocus?: () => void;\n srLabel?: string;\n labelledBy?: string;\n describedBy?: string;\n contentClassName?: string;\n motionDurationMs?: number;\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nconst PERSISTENT_DRAWER_EASING = \"cubic-bezier(0.32, 0.72, 0, 1)\";\nconst PERSISTENT_DRAWER_CLOSE_RATIO = 0.25;\nconst PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC = 450;\nconst PERSISTENT_DRAWER_FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\ntype PersistentDrawerStackEntry = {\n panel: () => HTMLElement | null;\n requestClose: () => void;\n};\n\ntype PersistentDrawerStack = {\n entries: PersistentDrawerStackEntry[];\n handleKeyDown: (event: KeyboardEvent) => void;\n};\n\nconst persistentDrawerStacks = new WeakMap();\n\nfunction getDrawerFocusableElements(panel: HTMLElement): HTMLElement[] {\n return Array.from(\n panel.querySelectorAll(PERSISTENT_DRAWER_FOCUSABLE_SELECTOR),\n ).filter(\n (element) => element.closest('[aria-hidden=\"true\"], [inert]') === null,\n );\n}\n\nfunction activeElementIsInAnotherOverlay(\n activeElement: Element | null,\n panel: HTMLElement,\n): boolean {\n const overlay = activeElement?.closest(\n \"[data-bb-portaled-overlay]\",\n );\n return overlay !== null && overlay !== undefined && overlay !== panel;\n}\n\nfunction handleDrawerTab(event: KeyboardEvent, panel: HTMLElement): void {\n const activeElement = panel.ownerDocument.activeElement;\n if (\n !panel.contains(activeElement) &&\n activeElementIsInAnotherOverlay(activeElement, panel)\n ) {\n return;\n }\n\n const focusable = getDrawerFocusableElements(panel);\n event.preventDefault();\n if (focusable.length === 0) {\n panel.focus({ preventScroll: true });\n return;\n }\n\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n if (event.shiftKey) {\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === first\n ) {\n last?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.max(0, index - 1)]?.focus({ preventScroll: true });\n return;\n }\n\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === last\n ) {\n first?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.min(focusable.length - 1, index + 1)]?.focus({\n preventScroll: true,\n });\n}\n\nfunction registerOpenDrawer(\n ownerDocument: Document,\n entry: PersistentDrawerStackEntry,\n): () => void {\n let stack = persistentDrawerStacks.get(ownerDocument);\n if (stack === undefined) {\n const entries: PersistentDrawerStackEntry[] = [];\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented) {\n return;\n }\n const topEntry = entries[entries.length - 1];\n const panel = topEntry?.panel() ?? null;\n if (topEntry === undefined || panel === null) {\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n topEntry.requestClose();\n } else if (event.key === \"Tab\") {\n handleDrawerTab(event, panel);\n }\n };\n stack = { entries, handleKeyDown };\n persistentDrawerStacks.set(ownerDocument, stack);\n ownerDocument.addEventListener(\"keydown\", handleKeyDown);\n }\n stack.entries.push(entry);\n\n return () => {\n const currentStack = persistentDrawerStacks.get(ownerDocument);\n if (currentStack === undefined) {\n return;\n }\n const index = currentStack.entries.indexOf(entry);\n if (index >= 0) {\n currentStack.entries.splice(index, 1);\n }\n if (currentStack.entries.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", currentStack.handleKeyDown);\n persistentDrawerStacks.delete(ownerDocument);\n }\n };\n}\n\ninterface UsePersistentOverlayFocusArgs {\n onAfterCloseAutoFocus?: () => void;\n onBeforeCloseAutoFocus?: () => void;\n open: boolean;\n panelRef: React.RefObject;\n requestClose: () => void;\n}\n\nexport function usePersistentOverlayFocus({\n onAfterCloseAutoFocus,\n onBeforeCloseAutoFocus,\n open,\n panelRef,\n requestClose,\n}: UsePersistentOverlayFocusArgs): void {\n const returnFocusRef = React.useRef(null);\n\n React.useLayoutEffect(() => {\n if (!open) return;\n const panel = panelRef.current;\n if (panel === null) return;\n const ownerDocument = panel.ownerDocument;\n const previousFocus = ownerDocument.activeElement;\n returnFocusRef.current =\n previousFocus instanceof HTMLElement &&\n previousFocus !== ownerDocument.body\n ? previousFocus\n : null;\n const unregister = registerOpenDrawer(ownerDocument, {\n panel: () => panelRef.current,\n requestClose,\n });\n panel.focus({ preventScroll: true });\n return unregister;\n }, [open, panelRef, requestClose]);\n\n const previousOpenRef = React.useRef(open);\n React.useLayoutEffect(() => {\n let cancelDeferredFocus: (() => void) | undefined;\n if (previousOpenRef.current && !open) {\n onBeforeCloseAutoFocus?.();\n const returnFocus = returnFocusRef.current;\n if (\n returnFocus?.isConnected &&\n returnFocus.closest('[aria-hidden=\"true\"], [inert]') === null\n ) {\n returnFocus.focus({ preventScroll: true });\n if (returnFocus.ownerDocument.activeElement !== returnFocus) {\n const ownerWindow = returnFocus.ownerDocument.defaultView;\n if (ownerWindow !== null) {\n const frame = ownerWindow.requestAnimationFrame(() => {\n if (\n returnFocus.isConnected &&\n returnFocus.closest('[aria-hidden=\"true\"], [inert]') === null\n ) {\n returnFocus.focus({ preventScroll: true });\n }\n onAfterCloseAutoFocus?.();\n });\n cancelDeferredFocus = () => ownerWindow.cancelAnimationFrame(frame);\n }\n }\n }\n returnFocusRef.current = null;\n if (cancelDeferredFocus === undefined) {\n onAfterCloseAutoFocus?.();\n }\n }\n previousOpenRef.current = open;\n return cancelDeferredFocus;\n }, [onAfterCloseAutoFocus, onBeforeCloseAutoFocus, open]);\n}\n\ntype PersistentDrawerDrag = {\n pointerId: number;\n startY: number;\n lastY: number;\n lastTimeMs: number;\n velocityY: number;\n height: number;\n};\n\nexport function PersistentResponsiveDrawerShell({\n open,\n onOpenChange,\n onAfterCloseAutoFocus,\n closeOnBackdropClick = true,\n srLabel,\n labelledBy,\n describedBy,\n contentClassName,\n motionDurationMs = 220,\n onContentAnimationEnd,\n children,\n}: PersistentResponsiveDrawerShellProps) {\n const panelRef = React.useRef(null);\n const backdropRef = React.useRef(null);\n const dragRef = React.useRef(null);\n const settledStateRef = React.useRef(null);\n const labelId = React.useId();\n const portalScopeProps = usePortalScopeProps();\n const transition = `transform ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const backdropTransition = `opacity ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const onOpenChangeRef = React.useRef(onOpenChange);\n React.useLayoutEffect(() => {\n onOpenChangeRef.current = onOpenChange;\n }, [onOpenChange]);\n const requestClose = React.useCallback(() => {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n onOpenChangeRef.current(false);\n }, []);\n const prepareCloseAutoFocus = React.useCallback(() => {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n }, []);\n\n usePersistentOverlayFocus({\n onAfterCloseAutoFocus,\n onBeforeCloseAutoFocus: prepareCloseAutoFocus,\n open,\n panelRef,\n requestClose,\n });\n\n useDrawerKeyboardInset(panelRef, open);\n\n const reportSettled = React.useCallback(\n (settledOpen: boolean) => {\n if (settledStateRef.current === settledOpen) {\n return;\n }\n settledStateRef.current = settledOpen;\n onContentAnimationEnd?.(settledOpen);\n },\n [onContentAnimationEnd],\n );\n\n React.useEffect(() => {\n settledStateRef.current = null;\n const timeout = window.setTimeout(\n () => reportSettled(open),\n motionDurationMs + 50,\n );\n return () => window.clearTimeout(timeout);\n }, [motionDurationMs, open, reportSettled]);\n\n const setDragPosition = React.useCallback(\n (offsetY: number, height: number, animate: boolean) => {\n const panel = panelRef.current;\n const backdrop = backdropRef.current;\n if (panel === null || backdrop === null) {\n return;\n }\n panel.style.transition = animate ? transition : \"none\";\n panel.style.transform = `translate3d(0, ${offsetY}px, 0)`;\n backdrop.style.transition = animate ? backdropTransition : \"none\";\n backdrop.style.opacity = String(\n Math.max(0, Math.min(1, 1 - offsetY / height)),\n );\n },\n [backdropTransition, transition],\n );\n\n const handleDragStart = React.useCallback(\n (event: React.PointerEvent) => {\n if (!open || event.button !== 0) {\n return;\n }\n event.currentTarget.setPointerCapture(event.pointerId);\n const nowMs = Date.now();\n const height = Math.max(panelRef.current?.clientHeight ?? 0, 1);\n dragRef.current = {\n pointerId: event.pointerId,\n startY: event.clientY,\n lastY: event.clientY,\n lastTimeMs: nowMs,\n velocityY: 0,\n height,\n };\n setDragPosition(0, height, false);\n event.preventDefault();\n },\n [open, setDragPosition],\n );\n\n const handleDragMove = React.useCallback(\n (event: React.PointerEvent) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n const nowMs = Date.now();\n const elapsedMs = nowMs - drag.lastTimeMs;\n if (elapsedMs > 0) {\n drag.velocityY = ((event.clientY - drag.lastY) / elapsedMs) * 1000;\n drag.lastY = event.clientY;\n drag.lastTimeMs = nowMs;\n }\n setDragPosition(\n Math.max(0, event.clientY - drag.startY),\n drag.height,\n false,\n );\n event.preventDefault();\n },\n [setDragPosition],\n );\n\n const finishDrag = React.useCallback(\n (event: React.PointerEvent, cancelled: boolean) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n dragRef.current = null;\n const offsetY = Math.max(0, event.clientY - drag.startY);\n const shouldClose =\n !cancelled &&\n (offsetY >= drag.height * PERSISTENT_DRAWER_CLOSE_RATIO ||\n drag.velocityY >= PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC);\n if (shouldClose) {\n setDragPosition(drag.height, drag.height, true);\n requestClose();\n } else {\n setDragPosition(0, drag.height, true);\n }\n event.preventDefault();\n },\n [requestClose, setDragPosition],\n );\n\n const portalTarget = typeof document === \"undefined\" ? null : document.body;\n if (portalTarget === null) {\n return null;\n }\n\n return createPortal(\n <>\n event.preventDefault()}\n />\n {\n if (\n event.currentTarget === event.target &&\n event.propertyName === \"transform\"\n ) {\n reportSettled(open);\n }\n }}\n >\n finishDrag(event, false)}\n onPointerCancel={(event) => finishDrag(event, true)}\n >\n
\n
\n {srLabel === undefined ? null : (\n

\n {srLabel}\n

\n )}\n {children}\n \n ,\n portalTarget,\n );\n}\n", "type": "registry:ui", "target": "components/ui/responsive-overlay.tsx" } diff --git a/packages/shared-ui/src/components/ui/responsive-overlay.tsx b/packages/shared-ui/src/components/ui/responsive-overlay.tsx index d4315364bb..39d83c00b7 100644 --- a/packages/shared-ui/src/components/ui/responsive-overlay.tsx +++ b/packages/shared-ui/src/components/ui/responsive-overlay.tsx @@ -10,6 +10,7 @@ import { preventOverlayTriggerSelection, } from "./overlay-trigger.js"; import { useIsCompactViewport } from "./hooks/use-compact-viewport.js"; +import { usePointerCoarse } from "./hooks/use-pointer-coarse.js"; import { usePortalScopeProps } from "../../lib/portal-scope.js"; import { cn } from "../../lib/utils.js"; @@ -100,12 +101,23 @@ function useDrawerKeyboardInset( }, [open, panelRef]); } +export function useResponsiveOverlayBehavior() { + const presentation = useIsCompactViewport() ? "drawer" : "floating"; + const isPointerCoarse = usePointerCoarse(); + + return { + presentation, + supportsHover: presentation === "floating" && !isPointerCoarse, + }; +} + export function useResponsiveRoot( controlledOpen: boolean | undefined, controlledOnChange: ((open: boolean) => void) | undefined, defaultOpen: boolean = false, ): ResponsiveOverlayContextValue { - const isCompactViewport = useIsCompactViewport(); + const { presentation } = useResponsiveOverlayBehavior(); + const isCompactViewport = presentation === "drawer"; const [internalOpen, setInternalOpen] = React.useState(defaultOpen); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen;