spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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