Popover.tsx 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import React, { useLayoutEffect, useRef, useEffect } from "react";
  2. import "./Popover.scss";
  3. import { unstable_batchedUpdates } from "react-dom";
  4. import { queryFocusableElements } from "../utils";
  5. import { KEYS } from "../keys";
  6. type Props = {
  7. top?: number;
  8. left?: number;
  9. children?: React.ReactNode;
  10. onCloseRequest?(event: PointerEvent): void;
  11. fitInViewport?: boolean;
  12. offsetLeft?: number;
  13. offsetTop?: number;
  14. viewportWidth?: number;
  15. viewportHeight?: number;
  16. };
  17. export const Popover = ({
  18. children,
  19. left,
  20. top,
  21. onCloseRequest,
  22. fitInViewport = false,
  23. offsetLeft = 0,
  24. offsetTop = 0,
  25. viewportWidth = window.innerWidth,
  26. viewportHeight = window.innerHeight,
  27. }: Props) => {
  28. const popoverRef = useRef<HTMLDivElement>(null);
  29. const container = popoverRef.current;
  30. useEffect(() => {
  31. if (!container) {
  32. return;
  33. }
  34. const handleKeyDown = (event: KeyboardEvent) => {
  35. if (event.key === KEYS.TAB) {
  36. const focusableElements = queryFocusableElements(container);
  37. const { activeElement } = document;
  38. const currentIndex = focusableElements.findIndex(
  39. (element) => element === activeElement,
  40. );
  41. if (currentIndex === 0 && event.shiftKey) {
  42. focusableElements[focusableElements.length - 1].focus();
  43. event.preventDefault();
  44. event.stopImmediatePropagation();
  45. } else if (
  46. currentIndex === focusableElements.length - 1 &&
  47. !event.shiftKey
  48. ) {
  49. focusableElements[0].focus();
  50. event.preventDefault();
  51. event.stopImmediatePropagation();
  52. }
  53. }
  54. };
  55. container.addEventListener("keydown", handleKeyDown);
  56. return () => container.removeEventListener("keydown", handleKeyDown);
  57. }, [container]);
  58. // ensure the popover doesn't overflow the viewport
  59. useLayoutEffect(() => {
  60. if (fitInViewport && popoverRef.current) {
  61. const element = popoverRef.current;
  62. const { x, y, width, height } = element.getBoundingClientRect();
  63. //Position correctly when clicked on rightmost part or the bottom part of viewport
  64. if (x + width - offsetLeft > viewportWidth) {
  65. element.style.left = `${viewportWidth - width - 10}px`;
  66. }
  67. if (y + height - offsetTop > viewportHeight) {
  68. element.style.top = `${viewportHeight - height}px`;
  69. }
  70. //Resize to fit viewport on smaller screens
  71. if (height >= viewportHeight) {
  72. element.style.height = `${viewportHeight - 20}px`;
  73. element.style.top = "10px";
  74. element.style.overflowY = "scroll";
  75. }
  76. if (width >= viewportWidth) {
  77. element.style.width = `${viewportWidth}px`;
  78. element.style.left = "0px";
  79. element.style.overflowX = "scroll";
  80. }
  81. }
  82. }, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
  83. useEffect(() => {
  84. if (onCloseRequest) {
  85. const handler = (event: PointerEvent) => {
  86. if (!popoverRef.current?.contains(event.target as Node)) {
  87. unstable_batchedUpdates(() => onCloseRequest(event));
  88. }
  89. };
  90. document.addEventListener("pointerdown", handler, false);
  91. return () => document.removeEventListener("pointerdown", handler, false);
  92. }
  93. }, [onCloseRequest]);
  94. return (
  95. <div className="popover" style={{ top, left }} ref={popoverRef}>
  96. {children}
  97. </div>
  98. );
  99. };