Files
Mastodon/app/javascript/mastodon/components/popover/index.tsx
T

204 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from 'react';
import type {
ReferenceType,
Placement,
OffsetOptions,
Strategy,
} from '@floating-ui/react-dom';
import {
useFloating,
autoUpdate,
inline,
offset as offsetMiddleware,
shift,
flip as flipMiddleware,
size,
hide,
} from '@floating-ui/react-dom';
import { useOnClickOutside } from '@/mastodon/hooks/useOnClickOutside';
import { Portal } from './portal';
export interface PopoverProps {
/**
* Set the visibility of the popover
*/
isOpen?: boolean;
/**
* Callback triggered when the popover needs to be closed
* (e.g. because the Esc key was pressed).
*/
onClose: (e: Event) => void;
/**
* Pass an element that the popover should be attached to.
* Must come from `useState`, not `useRef`.
*/
reference: ReferenceType | null;
/**
* Pass the popover element (useful if you also need the element
* in the parent and already have it in state).
* Must come from `useState`, not `useRef`.
*/
popoverElement?: HTMLElement | null;
/**
* Control which element the popover will be rendered into.
* Passing `null` will render the popover in place.
*/
container?: HTMLElement | null;
/**
* Control where the overlay element is positioned in relation
* to the reference element
*/
placement?: Placement;
/**
* Control the distance between popover and reference
*/
offset?: OffsetOptions;
/**
* Allow the popover to flip to the other side of the reference
* if there's no room in the specified direction. Enabled by default.
*/
flip?: boolean;
/**
* Change the positioning strategy, defaults to 'fixed'
* but can be changed to 'absolute'
*/
strategy?: Strategy;
/**
* Adapt the popover's width to match that of the reference,
* useful when attached to text input.
*/
matchReferenceWidth?: boolean;
/**
* Control whether to close the popover when clicking outside
* of it (enabled by default).
*/
closeOnClickOutside?: boolean;
/**
* Render prop that must return the popover element.
*/
children: (value: {
/**
* The popover's final computed placement. Can be used to
* adjust the popover's style based on its position.
*/
placement: Placement | undefined;
/**
* Re-computes the popover's position – call this when an
* action inside of the popover has changed its size.
*/
update: () => void;
/**
* These props must be passed to the popover element to
* enable it to be sized and positioned. The `ref` prop
* is not passed when the `popoverElement` prop is provided.
*/
props: Record<string, unknown> & {
ref?: React.RefCallback<HTMLElement>;
style: React.CSSProperties;
};
}) => React.ReactNode;
}
export const Popover: React.FC<PopoverProps> = ({
isOpen,
onClose,
reference,
popoverElement,
placement = 'bottom',
offset,
strategy = 'fixed',
flip = true,
container,
matchReferenceWidth = false,
closeOnClickOutside = true,
children,
}) => {
const {
placement: computedPlacement,
update,
refs,
floatingStyles,
middlewareData,
elements,
} = useFloating({
elements: {
reference,
floating: popoverElement,
},
placement,
strategy,
whileElementsMounted: autoUpdate,
middleware: [
offsetMiddleware(offset),
inline(),
shift(),
flip ? flipMiddleware() : null,
matchReferenceWidth
? size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
});
},
})
: null,
hide(),
],
});
// Close when clicking outside the popover
useOnClickOutside(
[
elements.floating,
// Only pass reference if it's not a "virtual element"
elements.reference instanceof Element ? elements.reference : null,
],
(e) => {
onClose(e);
},
isOpen && closeOnClickOutside,
);
// Close when pressing Escape
useEffect(() => {
if (!isOpen) {
return () => null;
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose(event);
}
}
document.addEventListener('keyup', closeOnEscape);
return () => {
document.removeEventListener('keyup', closeOnEscape);
};
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
return (
<Portal container={container}>
{children({
placement: computedPlacement,
update,
props: {
ref: popoverElement ? undefined : refs.setFloating,
style: floatingStyles,
'data-popover-placement': computedPlacement,
'data-popover-reference-hidden': middlewareData.hide?.referenceHidden,
'data-popover-escaped': middlewareData.hide?.escaped,
},
})}
</Portal>
);
};