SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
3.8 KB · 117 lines tsx
Raw Blame History
1'use client';2import { X } from 'lucide-react';3import { useEffect, useRef, type ReactNode } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';67/**8 * Sheet primitive on a native <dialog> (focus trap, Esc, inert background for free).9 * Mobile: bottom sheet with drag-to-close (swipe down ≥ 80 px). Desktop (md+): right-hand drawer10 * when `side="drawer"`, centred panel when `side="center"`.11 */12export function BottomSheet({13  open,14  onClose,15  title,16  children,17  side = 'drawer',18  labelledBy,19  className,20}: {21  open: boolean;22  onClose: () => void;23  title?: ReactNode;24  children: ReactNode;25  side?: 'drawer' | 'center' | 'full';26  labelledBy?: string;27  className?: string;28}) {29  const ref = useRef<HTMLDialogElement>(null);30  const startY = useRef<number | null>(null);3132  useEffect(() => {33    const d = ref.current;34    if (!d) return;35    if (open && !d.open) {36      d.showModal();37      document.documentElement.style.overflow = 'hidden';38    } else if (!open && d.open) {39      d.close();40    }41    return () => {42      document.documentElement.style.overflow = '';43    };44  }, [open]);4546  useEffect(() => {47    const d = ref.current;48    if (!d) return;49    const onCancel = (e: Event) => {50      e.preventDefault();51      onClose();52    };53    const onCloseEvt = () => {54      document.documentElement.style.overflow = '';55      if (open) onClose();56    };57    d.addEventListener('cancel', onCancel);58    d.addEventListener('close', onCloseEvt);59    return () => {60      d.removeEventListener('cancel', onCancel);61      d.removeEventListener('close', onCloseEvt);62    };63  }, [onClose, open]);6465  const onPointerDown = (e: React.PointerEvent) => {66    startY.current = e.clientY;67  };68  const onPointerUp = (e: React.PointerEvent) => {69    if (startY.current != null && e.clientY - startY.current > 80) onClose();70    startY.current = null;71  };7273  return (74    <dialog75      ref={ref}76      aria-labelledby={labelledBy}77      onClick={(e) => {78        if (e.target === e.currentTarget) onClose();79      }}80      className={cn(81        'm-0 max-h-none max-w-none bg-transparent p-0 text-ink backdrop:bg-ink/40 backdrop:backdrop-blur-[1px]',82        'fixed inset-0 h-full w-full',83        'open:flex open:items-end open:justify-center',84        side === 'drawer' && 'md:open:items-stretch md:open:justify-end',85        side === 'center' && 'md:open:items-center',86      )}87    >88      {open ? (89        <div90          className={cn(91            'animate-sheet relative flex w-full flex-col bg-surface shadow-sheet',92            'max-h-[92dvh] rounded-t-md',93            side === 'drawer' && 'md:h-full md:max-h-none md:w-[440px] md:rounded-none md:border-l md:border-rule',94            side === 'center' && 'md:max-h-[85vh] md:w-[560px] md:rounded-md md:border md:border-rule',95            side === 'full' && 'h-[100dvh] max-h-none rounded-none md:h-auto md:max-h-[85vh] md:w-[640px] md:self-start md:mt-[8vh] md:rounded-md md:border md:border-rule',96            className,97          )}98        >99          <div100            className="flex items-center gap-3 border-b border-rule px-4 py-3 md:px-5"101            onPointerDown={onPointerDown}102            onPointerUp={onPointerUp}103            style={{ touchAction: 'none' }}104          >105            <span aria-hidden className="absolute left-1/2 top-1.5 h-1 w-10 -translate-x-1/2 rounded-full bg-rule-strong md:hidden" />106            <div className="min-w-0 flex-1 pt-1 text-sm font-semibold md:pt-0">{title}</div>107            <button type="button" onClick={onClose} className="tap -mr-2 grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2" aria-label={t('common.close')}>108              <X size={18} aria-hidden />109            </button>110          </div>111          <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6 pt-3 safe-bottom md:px-5">{children}</div>112        </div>113      ) : null}114    </dialog>115  );116}117