71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { createPortal } from 'react-dom';
|
||
|
|
import { X } from 'lucide-react';
|
||
|
|
|
||
|
|
interface BottomSheetProps {
|
||
|
|
title: string;
|
||
|
|
subtitle?: string;
|
||
|
|
onClose: () => void;
|
||
|
|
children: React.ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Full-width panel that slides up from the bottom of the screen.
|
||
|
|
//
|
||
|
|
// Replaces the small popovers on the compact rows: those were 256px
|
||
|
|
// wide and pinned to a button in the middle of the row, so on a phone
|
||
|
|
// they ran off the left edge. Rendered into <body> through a portal so
|
||
|
|
// no parent's stacking context or overflow can clip it, and it sits
|
||
|
|
// above the BottomNav.
|
||
|
|
export default function BottomSheet({ title, subtitle, onClose, children }: BottomSheetProps) {
|
||
|
|
const [mounted, setMounted] = useState(false);
|
||
|
|
useEffect(() => setMounted(true), []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const onKey = (e: KeyboardEvent) => {
|
||
|
|
if (e.key === 'Escape') onClose();
|
||
|
|
};
|
||
|
|
document.addEventListener('keydown', onKey);
|
||
|
|
return () => document.removeEventListener('keydown', onKey);
|
||
|
|
}, [onClose]);
|
||
|
|
|
||
|
|
if (!mounted) return null;
|
||
|
|
|
||
|
|
return createPortal(
|
||
|
|
<div
|
||
|
|
data-bottom-sheet
|
||
|
|
className="fixed inset-0 z-[90] flex items-end bg-black/35 animate-fade-in"
|
||
|
|
onClick={onClose}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
aria-label={title}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
className="w-full max-h-[80vh] overflow-y-auto bg-white rounded-t-2xl px-4 pt-2 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-[0_-8px_24px_rgba(0,0,0,0.12)]"
|
||
|
|
>
|
||
|
|
<div className="w-10 h-1 rounded-full bg-[rgba(38,37,30,0.2)] mx-auto mb-3" aria-hidden="true" />
|
||
|
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||
|
|
<div className="min-w-0">
|
||
|
|
<p className="text-[13px] text-[rgba(38,37,30,0.6)]">{title}</p>
|
||
|
|
{subtitle && (
|
||
|
|
<p className="text-[18px] font-semibold text-[#26251e] break-words leading-snug">{subtitle}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClose}
|
||
|
|
aria-label="Close"
|
||
|
|
className="w-10 h-10 flex-shrink-0 rounded-lg border border-[rgba(38,37,30,0.15)] flex items-center justify-center active:bg-[rgba(38,37,30,0.06)] touch-manipulation"
|
||
|
|
>
|
||
|
|
<X size={18} aria-hidden="true" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
</div>,
|
||
|
|
document.body,
|
||
|
|
);
|
||
|
|
}
|