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 : }
|