All files / src/components sliding-panel.tsx

83.67% Statements 41/49
60% Branches 15/25
93.33% Functions 14/15
83.67% Lines 41/49

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222                                    8x                                                                                     8x                     4x   4x 4x     4x   4x       4x 4x 4x 4x                                             4x   4x 4x     4x       4x     4x 4x     4x         4x 4x   3x       3x 2x         1x 1x       4x 4x 4x         4x 4x   4x         4x 4x 2x 2x       4x   4x 4x       4x                                         1x                                          
import {
  type FC,
  useRef,
  useEffect,
  type ReactNode,
  type HTMLAttributes,
} from 'react';
import { createPortal } from 'react-dom';
import cn from 'classnames';
import { frame } from 'timing-functions';
 
import Button from './button';
 
import CloseIcon from '../svg/times.svg';
 
import '../styles/components/sliding-panel.scss';
 
const focusable =
  'button:not([disabled]), [href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])';
 
export type LRBelowHeader = {
  /**
   * Where the sliding panel should appear
   */
  position: 'left' | 'right';
  /**
   * Horizontal position of the arrow if the panel appears below the page header.
   * Also works as a flag to display the arrow and display below the header
   */
  arrowX?: number;
};
 
export type TBSlidingPanel = {
  /**
   * Where the sliding panel should appear
   */
  position: 'top' | 'bottom';
  arrowX?: never;
};
 
type SlidingPanelProps = {
  /**
   * What happens when close is triggered. Responsability of the user of the compoent
   */
  onClose?: (reason: 'outside' | 'x-button' | 'navigation' | 'escape') => void;
  /**
   * Size of the panel once opened
   */
  size?: 'small' | 'medium' | 'large' | 'full-screen';
  /**
   * Title of the panel
   */
  title?: ReactNode;
  /**
   * Pathname of current location. When this changes the panel is closed.
   */
  pathname: string;
} & (LRBelowHeader | TBSlidingPanel);
 
const SlidingPanel: FC<
  SlidingPanelProps & Omit<HTMLAttributes<HTMLDivElement>, 'title'>
> = ({
  children,
  onClose,
  position,
  size = 'medium',
  title,
  arrowX,
  className,
  pathname,
  ...props
}) => {
  const nodeRef = useRef<HTMLDivElement>(null);
 
  const onCloseRef = useRef<SlidingPanelProps['onClose']>(onClose);
  onCloseRef.current = onClose;
 
  // onMount/onUnmount
  useEffect(() => {
    // keep track of the currently active element (likely the button used)
    const previousActiveElement = document.activeElement as HTMLElement | null;
 
    // Mutation observer for when the content of the sliding panel changes
    // (because all the content might not be there immediatly)
    let mutationObs: MutationObserver | null = null;
    const focusTarget = nodeRef.current?.querySelector<HTMLElement>(focusable);
    if (focusTarget) {
      focusTarget.focus();
    } else E{
      // if there is no focusable element, wait for one to be rendered
      mutationObs = new MutationObserver(() => {
        // Get the first focusable element in the panel
        const focusTarget =
          nodeRef.current?.querySelector<HTMLElement>(focusable);
        if (focusTarget) {
          // If there is one, focus it and disconnect the observer
          focusTarget.focus();
          mutationObs?.disconnect();
        }
      });
      if (nodeRef.current) {
        // Connect the observer to the panel
        mutationObs?.observe(nodeRef.current, {
          childList: true,
          subtree: true,
        });
      }
    }
 
    // Clean up
    return () => {
      // Return focus to previously active element on unmount if still there
      Eif (previousActiveElement && document.contains(previousActiveElement)) {
        previousActiveElement?.focus();
      }
      // Disconnect here too, just in case it didn't have a chance to do so
      mutationObs?.disconnect();
    };
  }, []);
 
  useEffect(() => {
    // Make sure to add it to the reference anyway every time to handle the
    // double renders in React in dev mode
    onCloseRef.current = onClose;
    return () => {
      // Lose the reference to the onClose function on unmount because we might
      // call it on the next frame but it will already be unmounted
      onCloseRef.current = undefined;
    };
  }, [onClose]);
 
  // Handle closing the sliding panel when there's a click outside
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      // loop through all the currently opened panels
      for (const panel of document.querySelectorAll<HTMLDivElement>(
        '.sliding-panel'
      )) {
        // if the click event was within one, bail out of the whole function
        if (panel.contains(e.target as Node)) {
          return;
        }
      }
      // If none of the panels contains the target, close the panel.
      // Wait a frame in order to let other event listeners run before.
      frame().then(() => {
        onCloseRef.current?.('outside');
      });
    };
 
    document.addEventListener('click', handleClickOutside, true);
    return () => {
      document.removeEventListener('click', handleClickOutside, true);
    };
  }, []);
 
  // Handle closing the sliding panel when there's a path change
  const pathnameRef = useRef(pathname);
  useEffect(() => {
    // If the pathname changed
    Iif (pathnameRef.current !== pathname) {
      onCloseRef.current?.('navigation');
    }
  }, [pathname]);
 
  useEffect(() => {
    const listener = (event: KeyboardEvent) => {
      Eif (event.key === 'Escape') {
        onCloseRef.current?.('escape');
      }
    };
 
    document.addEventListener('keydown', listener, { passive: true });
 
    return () => {
      document.removeEventListener('keydown', listener);
    };
  }, []);
 
  return createPortal(
    <aside
      data-testid="sliding-panel"
      className={cn(
        'sliding-panel',
        `sliding-panel--${position}`,
        `sliding-panel--${position}--${size}`,
        Number.isFinite(arrowX) && `sliding-panel--${position}--below-header`,
        className
      )}
      ref={nodeRef}
      {...props}
    >
      {title && (
        <div className="sliding-panel__header">
          {title && (
            <span className="small sliding-panel__header__title">{title}</span>
          )}
 
          <Button
            variant="tertiary"
            onClick={() => onCloseRef.current?.('x-button')}
            className="sliding-panel__header__buttons"
            title="Close panel"
          >
            <CloseIcon />
          </Button>
          {Number.isFinite(arrowX) && (
            <div
              className="sliding-panel__header__arrow"
              style={{ left: arrowX }}
            />
          )}
        </div>
      )}
      <div className="sliding-panel__content">{children}</div>
    </aside>,
    document.body
  );
};
 
export default SlidingPanel;