LCOV - code coverage report
Current view: top level - dashql-app/src/ui/foundations - anchored_overlay.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 93.9 % 82 77
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { Overlay, OverlayProps, OverlayVisibility } from './overlay.js';
       4              : import { FocusTrapHookArgs, FocusZoneHookArgs, useFocusTrap, useFocusZone } from './focus.js';
       5              : import { PositionSettings, useAnchoredPosition } from './anchored_position.js';
       6              : import { useRenderForcingRef } from './rerender_ref.js';
       7              : 
       8              : interface AnchoredOverlayPropsWithAnchor {
       9              :     /// A custom function component used to render the anchor element.
      10              :     /// Will receive the selected text as `children` prop when an item is activated.
      11              :     renderAnchor: <T extends React.HTMLAttributes<HTMLElement>>(props: T) => React.ReactElement;
      12              :     /// An override to the internal ref that will be spread on to the renderAnchor
      13              :     anchorRef?: React.RefObject<HTMLElement | null>;
      14              :     /// An override to the internal id that will be spread on to the renderAnchor
      15              :     anchorId?: string;
      16              : }
      17              : 
      18              : interface AnchoredOverlayPropsWithoutAnchor {
      19              :     /// A custom function component used to render the anchor element.
      20              :     /// When renderAnchor is null, an anchorRef is required.
      21              :     renderAnchor: null;
      22              :     /// An override to the internal renderAnchor ref that will be used to position the overlay.
      23              :     /// When renderAnchor is null this can be used to make an anchor that is detached from ActionMenu.
      24              :     anchorRef: React.RefObject<Element | null>;
      25              :     /// An override to the internal id that will be spread on to the renderAnchor
      26              :     anchorId?: string;
      27              : }
      28              : 
      29              : export type AnchoredOverlayWrapperAnchorProps =
      30              :     | Partial<AnchoredOverlayPropsWithAnchor>
      31              :     | AnchoredOverlayPropsWithoutAnchor
      32              : 
      33              : interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width' | 'minWidth' | 'maxWidth' | 'maxHeight'> {
      34              :     /// The React children
      35              :     children?: React.ReactElement;
      36              :     /// Determines whether the overlay portion of the component should be shown or not
      37              :     open: boolean;
      38              :     /// A callback which is called whenever the overlay is currently closed and an "open gesture" is detected.
      39              :     onOpen?: (gesture: 'anchor-click' | 'anchor-key-press', event?: React.KeyboardEvent<HTMLElement>) => unknown;
      40              :     /// A callback which is called whenever the overlay is currently open and a "close gesture" is detected.
      41              :     onClose?: (gesture: 'anchor-click' | 'click-outside' | 'escape') => unknown;
      42              :     /// Optional ref to return focus to when the overlay closes. Defaults to anchorRef.
      43              :     returnFocusRef?: React.RefObject<HTMLElement | null>;
      44              :     /// Props to be spread on the internal `Overlay` component.
      45              :     overlayProps?: Partial<OverlayProps>;
      46              :     /// Settings to apply to the Focus Zone on the internal `Overlay` component.
      47              :     focusTrapSettings?: Partial<FocusTrapHookArgs>;
      48              :     /// Settings to apply to the Focus Zone on the internal `Overlay` component.
      49              :     focusZoneSettings?: Partial<FocusZoneHookArgs>;
      50              :     /// Forces position measurement after a detached anchor moves without layout changes.
      51              :     positionRevision?: number;
      52              : }
      53              : 
      54              : export type AnchoredOverlayProps = AnchoredOverlayBaseProps &
      55              :     (AnchoredOverlayPropsWithAnchor | AnchoredOverlayPropsWithoutAnchor) &
      56              :     Partial<Pick<PositionSettings, 'align' | 'side' | 'anchorOffset' | 'alignmentOffset'>>
      57              : 
      58              : /// An `AnchoredOverlay` provides an anchor that will open a floating overlay positioned relative to the anchor.
      59              : /// The overlay can be opened and navigated using keyboard or mouse.
      60            1 : export function AnchoredOverlay(args: AnchoredOverlayProps) {
      61            1 :     const altAnchorRef = React.useRef<HTMLElement | null>(null);
      62            1 :     const anchorRef = args.anchorRef ?? altAnchorRef;
      63            1 :     const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
      64            1 :     const anchorId = React.useId()
      65              : 
      66            1 :     const onClickOutside = React.useCallback(() => args.onClose?.('click-outside'), [args.onClose])
      67            1 :     const onEscape = React.useCallback(() => args.onClose?.('escape'), [args.onClose])
      68              : 
      69            1 :     const onAnchorKeyDown = React.useCallback(
      70            1 :         (event: React.KeyboardEvent<HTMLElement>) => {
      71            1 :             if (!event.defaultPrevented) {
      72            1 :                 if (!args.open && ['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(event.key)) {
      73            0 :                     args.onOpen?.('anchor-key-press', event)
      74            0 :                     event.preventDefault()
      75            0 :                 }
      76              :             }
      77            1 :         },
      78            1 :         [args.open, args.onOpen],
      79            1 :     )
      80            1 :     const onAnchorClick = React.useCallback(
      81            1 :         (event: React.MouseEvent<HTMLElement>) => {
      82            1 :             if (event.defaultPrevented || event.button !== 0) {
      83            0 :                 return
      84              :             }
      85            1 :             if (!args.open) {
      86            1 :                 args.onOpen?.('anchor-click')
      87              :             } else {
      88            0 :                 args.onClose?.('anchor-click')
      89              :             }
      90            1 :         },
      91            1 :         [args.open, args.onOpen, args.onClose],
      92            1 :     )
      93              : 
      94            1 :     const { position } = useAnchoredPosition({
      95            1 :         anchorElementRef: anchorRef,
      96            1 :         floatingElementRef: overlayRef,
      97            1 :         side: args.side,
      98            1 :         align: args.align,
      99            1 :         alignmentOffset: args.alignmentOffset,
     100            1 :         anchorOffset: args.anchorOffset,
     101            1 :         observeWindow: args.open,
     102            1 :     }, [overlayRef.current, args.positionRevision])
     103              : 
     104            1 :     React.useEffect(() => {
     105              :         // ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening
     106            1 :         if (!args.open && overlayRef.current) {
     107            1 :             updateOverlayRef(null)
     108              :         }
     109            1 :     }, [args.open, overlayRef, updateOverlayRef])
     110              : 
     111            1 :     useFocusZone({
     112            1 :         containerRef: overlayRef,
     113            1 :         disabled: !args.open || !position,
     114            1 :         ...args.focusZoneSettings,
     115            1 :     })
     116            1 :     useFocusTrap({ containerRef: overlayRef, disabled: !args.open || !position, ...args.focusTrapSettings })
     117              : 
     118            1 :     return (
     119            1 :         <>
     120            1 :             {args.renderAnchor &&
     121            1 :                 args.renderAnchor({
     122            1 :                     ref: anchorRef,
     123            1 :                     id: anchorId,
     124            1 :                     'aria-haspopup': 'true',
     125            1 :                     'aria-expanded': args.open,
     126            1 :                     tabIndex: 0,
     127            1 :                     onClick: onAnchorClick,
     128            1 :                     onKeyDown: onAnchorKeyDown,
     129            1 :                 })}
     130            1 :             {args.open ? (
     131            1 :                 <Overlay
     132            1 :                     ref={updateOverlayRef}
     133            1 :                     returnFocusRef={args.returnFocusRef}
     134            1 :                     onClickOutside={onClickOutside}
     135            1 :                     ignoreClickRefs={[anchorRef]}
     136            1 :                     onEscape={onEscape}
     137            1 :                     role="none"
     138            1 :                     preventFocusOnOpen
     139            1 :                     visibility={position ? OverlayVisibility.Visible : OverlayVisibility.Hidden}
     140            1 :                     height={args.height}
     141            1 :                     width={args.width}
     142            1 :                     minWidth={args.minWidth}
     143            1 :                     maxWidth={args.maxWidth}
     144            1 :                     maxHeight={args.maxHeight}
     145            1 :                     top={position?.top || 0}
     146            1 :                     left={position?.left || 0}
     147            1 :                     anchorSide={position?.anchorSide}
     148            1 :                     {...args.overlayProps}
     149              :                 >
     150            1 :                     {args.children}
     151            1 :                 </Overlay>
     152            1 :             ) : null}
     153            1 :         </>
     154              :     )
     155            1 : }
        

Generated by: LCOV version 2.0-1