/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import { autoPlacement, autoUpdate, flip, type Placement, shift, useFloating } from '@floating-ui/react'; import {div, fragment} from '@xh/hoist/cmp/layout'; import {Content, hoistCmp, HoistModel, HoistProps, useLocalModel, XH} from '@xh/hoist/core'; import '@xh/hoist/mobile/register'; import {action, makeObservable, observable} from '@xh/hoist/mobx'; import {elementFromContent} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import {isFunction, isNil} from 'lodash'; import {ReactPortal} from 'react'; import ReactDom from 'react-dom'; import './Popover.scss'; export interface PopoverProps extends HoistProps { /** Component to display inside the popover */ content: Content; /** Whether the popover is visible. Passing this prop puts the popover in controlled mode */ isOpen?: boolean; /** * Callback invoked in controlled mode when the popover open state _would_ change due to * user interaction. */ onInteraction?: (nextOpenState: boolean) => void; /** True to disable user interaction */ disabled?: boolean; /** Whether to display a semi-transparent backdrop behind the popover */ backdrop?: boolean; /** The position (relative to the target) at which the popover should appear. Default 'auto' */ position?: | 'top-left' | 'top' | 'top-right' | 'right-top' | 'right' | 'right-bottom' | 'bottom-right' | 'bottom' | 'bottom-left' | 'left-bottom' | 'left' | 'left-top' | 'auto'; /** Optional className applied to the popover content wrapper. */ popoverClassName?: string; } /** * Popovers display floating content next to a target element. * * The API is based on a stripped-down version of Blueprint's Popover component * that is used on Desktop. Popover is built on top of the Floating UI library. * * @see https://floating-ui.com/ */ export const [Popover, popover] = hoistCmp.withFactory({ displayName: 'Popover', className: 'xh-popover', render({ children, className, content, disabled = false, backdrop = false, position = 'auto', popoverClassName }) { const impl = useLocalModel(PopoverModel), isAuto = position === 'auto', placement = isAuto ? undefined : impl.menuPositionToPlacement(position), // Use Floating UI's own `refs.setReference`/`setFloating` callback refs rather than // the controlled `elements` option. This lets Floating UI manage the element state // (and trigger its own re-renders/repositioning) internally, instead of relying on a // MobX observer re-render when an observable ref is set during the commit phase - a // dependency that does not reliably fire under React 19 for the portaled content. {refs, floatingStyles} = useFloating({ placement, strategy: 'fixed', middleware: [isAuto ? autoPlacement() : flip(), shift({padding: 10})], whileElementsMounted: autoUpdate }); return div({ className, items: [ div({ ref: refs.setReference, className: 'xh-popover__target-wrapper', items: children, onClick: () => { if (disabled) return; impl.toggleOpen(); } }), ReactDom.createPortal( fragment({ omit: !impl.isOpen, items: [ div({ ref: refs.setFloating, style: floatingStyles, className: classNames( 'xh-popover__content-wrapper', popoverClassName ), items: elementFromContent(content) }), div({ className: classNames( 'xh-popover__content-overlay', backdrop ? 'xh-popover__content-overlay--backdrop' : null ), onClick: () => impl.setIsOpen(false) }) ] }), impl.getOrCreatePortalDiv() ) as ReactPortal ] }); } }); class PopoverModel extends HoistModel { override xhImpl = true; @observable isOpen; _onInteraction; _controlledMode = false; constructor() { super(); makeObservable(this); } override onLinked() { // Popovers are automatically closed on app route changes to avoid navigating the // app underneath the popover in an unsettling way. (i.e. via browser back button) this.addReaction({ track: () => XH.routerState, run: () => this.setIsOpen(false) }); this.addReaction({ track: () => this.componentProps.isOpen, run: isOpen => { if (!isNil(isOpen)) { this.setControlledMode(isOpen); } }, fireImmediately: true }); } @action setControlledMode(isOpen) { this.isOpen = isOpen; this._controlledMode = true; const {onInteraction} = this.componentProps; if (isFunction(onInteraction)) { this._onInteraction = onInteraction; } } @action setIsOpen(isOpen) { if (this._controlledMode) { this._onInteraction?.(isOpen); } else { this.isOpen = isOpen; } } @action toggleOpen() { this.setIsOpen(!this.isOpen); } getOrCreatePortalDiv() { const id = 'xh-popover-portal'; let portal = document.getElementById(id); if (!portal) { portal = document.createElement('div'); portal.id = id; document.body.appendChild(portal); } return portal; } /** * Convert a menu position to a Floating UI placement (the vocabulary is shared with * Popper.js). This allows us to use the same position names as desktop, and is inspired * by Blueprint's similar implementation: * https://github.com/palantir/blueprint/blob/develop/packages/core/src/components/popover/popoverMigrationUtils.ts */ menuPositionToPlacement(position: string): Placement { switch (position) { case 'top-left': return 'top-start'; case 'top-right': return 'top-end'; case 'right-top': return 'right-start'; case 'right-bottom': return 'right-end'; case 'bottom-left': return 'bottom-start'; case 'bottom-right': return 'bottom-end'; case 'left-top': return 'left-start'; case 'left-bottom': return 'left-end'; default: return position as Placement; } } }