All files / Bandstand/src/components Dropdown.tsx

100% Statements 45/45
92.85% Branches 13/14
100% Functions 5/5
100% Lines 45/45

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    1x           23x 23x 23x 23x 23x 23x 23x 23x                 23x 23x 23x   23x 22x     6x 5x 5x 6x 6x 6x 6x 6x 6x 6x 23x   23x 23x 23x 23x 23x 23x 23x 23x 23x   23x 23x 23x 6x 6x 3x   6x   6x 6x   23x   23x  
'use client'
 
import {ReactNode, useEffect, useRef, useState} from 'react'
 
/**
 * A button that opens a small menu. It closes on a tap or click anywhere
 * outside it, on Escape, and after choosing any link or button inside it.
 */
export default function Dropdown({
  trigger,
  label,
  align = 'right',
  triggerClassName = '',
  panelClassName = 'w-56',
  children,
}: {
  trigger: ReactNode
  /** Accessible name when the trigger is only a symbol */
  label?: string
  /** Which edge of the trigger the menu lines up with */
  align?: 'left' | 'right'
  triggerClassName?: string
  panelClassName?: string
  children: ReactNode
}) {
  const [open, setOpen] = useState(false)
  const ref = useRef<HTMLDivElement>(null)
 
  useEffect(() => {
    if (!open) return
    // pointerdown, not click: iOS doesn't fire click on non-interactive
    // elements, so a tap on plain page background would otherwise be missed
    const onDown = (e: PointerEvent) => {
      if (!ref.current?.contains(e.target as Node)) setOpen(false)
    }
    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)
    document.addEventListener('pointerdown', onDown)
    document.addEventListener('keydown', onKey)
    return () => {
      document.removeEventListener('pointerdown', onDown)
      document.removeEventListener('keydown', onKey)
    }
  }, [open])
 
  return (
    <div ref={ref} className="relative">
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
        aria-haspopup="menu"
        aria-label={label}
        className={triggerClassName}
      >
        {trigger}
      </button>
      {open && (
        <div
          onClick={(e) =>
            (e.target as Element).closest('a,button') && setOpen(false)
          }
          className={`absolute z-40 mt-1 rounded-xl border border-line-2 bg-panel p-1.5 shadow-xl ${align === 'right' ? 'right-0' : 'left-0'} ${panelClassName}`}
        >
          {children}
        </div>
      )}
    </div>
  )
}