SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
12.4 KB · 266 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import * as DialogPrimitive from "@radix-ui/react-dialog";4import { X } from "lucide-react";5import { cn } from "@/lib/utils";6import { useIsMobile, usePrefersReducedMotion } from "@/lib/client/hooks";78/**9 * BottomSheet — native-feeling mobile sheet built on Radix Dialog (focus trap, a11y, Esc).10 *11 * - drag handle + swipe-to-dismiss (pointer events; direction-locked so inner scrolling still works)12 * - snap points: `snap="content" | "half" | "full"` (max height); `expandable` lets the user drag up to full13 * - background blur, safe-area padding, `100dvh`-based sizing, keyboard-aware (`--kb`)14 *15 * Use `ResponsiveDialog` when the same content must be a centered dialog on desktop.16 */17export type SheetSnap = "content" | "half" | "full";1819export interface BottomSheetProps {20  open: boolean;21  onOpenChange: (open: boolean) => void;22  title?: React.ReactNode;23  description?: React.ReactNode;24  children: React.ReactNode;25  /** Sticky footer (e.g. primary action). Rendered above the safe area. */26  footer?: React.ReactNode;27  snap?: SheetSnap;28  /** Allow dragging up from `content`/`half` to `full`. */29  expandable?: boolean;30  /** Hide the visual title but keep it for screen readers. */31  hideTitle?: boolean;32  showClose?: boolean;33  className?: string;34  bodyClassName?: string;35  /** Prevent closing by tapping the overlay / swiping. */36  modal?: boolean;37  /** Remove body padding (lists that manage their own). */38  flush?: boolean;39  /** Let a fixed header (e.g. search input) sit above the scrolling body. */40  header?: React.ReactNode;41}4243const SNAP_MAX: Record<SheetSnap, string> = {44  content: "max-h-[min(88dvh,calc(100dvh-var(--kb)-16px))]",45  half: "h-[min(56dvh,calc(100dvh-var(--kb)-16px))]",46  full: "h-[calc(100dvh-var(--kb)-var(--sat)-8px)]",47};4849export function BottomSheet({ open, onOpenChange, title, description, children, footer, snap = "content", expandable = true, hideTitle, showClose = false, className, bodyClassName, modal = false, flush, header }: BottomSheetProps) {50  const reduced = usePrefersReducedMotion();51  const [current, setCurrent] = React.useState<SheetSnap>(snap);52  const [dragY, setDragY] = React.useState(0);53  const [dragging, setDragging] = React.useState(false);54  const bodyRef = React.useRef<HTMLDivElement>(null);55  const startY = React.useRef<number | null>(null);56  const startScrollTop = React.useRef(0);57  const axis = React.useRef<"y" | "none" | null>(null);5859  React.useEffect(() => {60    if (open) {61      // eslint-disable-next-line react-hooks/set-state-in-effect62      setCurrent(snap);63      setDragY(0);64    }65  }, [open, snap]);6667  const onPointerDown = (e: React.PointerEvent, fromHandle: boolean) => {68    if (e.pointerType === "mouse" && !fromHandle) return;69    startY.current = e.clientY;70    startScrollTop.current = bodyRef.current?.scrollTop ?? 0;71    axis.current = fromHandle ? "y" : null;72  };73  const onPointerMove = (e: React.PointerEvent) => {74    if (startY.current === null) return;75    const dy = e.clientY - startY.current;76    if (axis.current === null) {77      // Only take over when the body is scrolled to the top and the user pulls down.78      if (Math.abs(dy) < 8) return;79      axis.current = dy > 0 && startScrollTop.current <= 0 ? "y" : "none";80    }81    if (axis.current !== "y") return;82    if (dy > 0) {83      setDragging(true);84      setDragY(dy);85    } else if (expandable && current !== "full") {86      setDragging(true);87      setDragY(Math.max(dy, -80));88    }89  };90  const onPointerUp = () => {91    if (startY.current === null) return;92    const dy = dragY;93    startY.current = null;94    axis.current = null;95    setDragging(false);96    setDragY(0);97    if (dy > 110 && !modal) onOpenChange(false);98    else if (dy < -40 && expandable && current !== "full") setCurrent("full");99    else if (dy > 60 && current === "full" && snap !== "full") setCurrent(snap);100  };101102  return (103    <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>104      <DialogPrimitive.Portal>105        <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[3px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />106        <DialogPrimitive.Content107          onOpenAutoFocus={(e) => e.preventDefault()}108          className={cn(109            "fixed inset-x-0 bottom-0 z-50 flex flex-col bg-bg-elevated text-fg shadow-sheet focus:outline-none",110            "rounded-t-[22px] border-t border-border/70",111            SNAP_MAX[current],112            !dragging && !reduced && "data-[state=open]:animate-sheet-in data-[state=closed]:animate-sheet-out transition-[height,max-height] duration-300 ease-[var(--ease-out-soft)]",113            className,114          )}115          style={dragging ? { transform: `translateY(${Math.max(0, dragY)}px)` } : undefined}116          onPointerMove={onPointerMove}117          onPointerUp={onPointerUp}118          onPointerCancel={onPointerUp}119        >120          {/* Handle: always draggable */}121          <div className="shrink-0 cursor-grab touch-none select-none active:cursor-grabbing" onPointerDown={(e) => onPointerDown(e, true)} aria-hidden>122            <div className="sheet-handle" />123          </div>124          <div className={cn("shrink-0 px-5", title && !hideTitle ? "pb-2 pt-1" : "")}>125            <DialogPrimitive.Title className={cn("text-[17px] font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Sheet"}</DialogPrimitive.Title>126            {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-[13px] text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}127            {showClose ? (128              <DialogPrimitive.Close className="tap absolute right-3 top-3 rounded-full bg-bg-muted p-1.5 text-fg-muted hover:text-fg" aria-label="Close">129                <X className="size-4" />130              </DialogPrimitive.Close>131            ) : null}132          </div>133          {header ? <div className="shrink-0">{header}</div> : null}134          <div ref={bodyRef} onPointerDown={(e) => onPointerDown(e, false)} className={cn("min-h-0 flex-1 overflow-y-auto contain-scroll scrollbar-thin", flush ? "" : "px-4 pb-4", !footer && "pb-[max(16px,var(--sab))]", bodyClassName)}>135            {children}136          </div>137          {footer ? <div className="shrink-0 border-t border-border bg-bg-elevated px-4 pt-3 pb-[max(12px,var(--sab))]">{footer}</div> : null}138        </DialogPrimitive.Content>139      </DialogPrimitive.Portal>140    </DialogPrimitive.Root>141  );142}143144/* ------------------------------------------------------------------------------------------------ */145146export interface ResponsiveDialogProps extends Omit<BottomSheetProps, "snap" | "expandable"> {147  /** Desktop width. */148  size?: "sm" | "md" | "lg" | "xl" | "full";149  /** Mobile snap point (default content). */150  snap?: SheetSnap;151  expandable?: boolean;152  /** Desktop variant: centered dialog (default) or right side panel. */153  desktop?: "dialog" | "panel";154  /** Desktop: remove the default body padding. */155  desktopFlush?: boolean;156}157158const SIZES = { sm: "sm:max-w-sm", md: "sm:max-w-lg", lg: "sm:max-w-2xl", xl: "sm:max-w-4xl", full: "sm:max-w-[min(96vw,1200px)]" };159160/**161 * Bottom sheet on phones, centered dialog (or side panel) on ≥ md. One API, two native-feeling shapes.162 */163export function ResponsiveDialog({ size = "md", desktop = "dialog", desktopFlush, ...props }: ResponsiveDialogProps) {164  const isMobile = useIsMobile();165  if (isMobile) return <BottomSheet {...props} />;166  const { open, onOpenChange, title, description, children, footer, hideTitle, showClose = true, className, bodyClassName, modal, flush, header } = props;167  const isPanel = desktop === "panel";168  return (169    <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>170      <DialogPrimitive.Portal>171        <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />172        <DialogPrimitive.Content173          className={cn(174            "fixed z-50 flex flex-col border border-border bg-bg-elevated text-fg shadow-lg focus:outline-none",175            isPanel176              ? "inset-y-2 right-2 w-[min(440px,calc(100vw-16px))] rounded-2xl data-[state=open]:animate-fade-up data-[state=closed]:animate-fade-out"177              : cn("left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 rounded-2xl max-h-[min(86vh,900px)] data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out", SIZES[size]),178            className,179          )}180        >181          <div className={cn("shrink-0", title && !hideTitle ? "px-6 pt-5 pb-3" : "")}>182            <DialogPrimitive.Title className={cn("text-base font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Dialog"}</DialogPrimitive.Title>183            {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-sm text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}184          </div>185          {showClose ? (186            <DialogPrimitive.Close className="absolute right-3 top-3 rounded-md p-1.5 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg" aria-label="Close">187              <X className="size-4" />188            </DialogPrimitive.Close>189          ) : null}190          {header ? <div className="shrink-0">{header}</div> : null}191          <div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", flush || desktopFlush ? "" : "px-6 pb-6", bodyClassName)}>{children}</div>192          {footer ? <div className="shrink-0 border-t border-border px-6 py-3">{footer}</div> : null}193        </DialogPrimitive.Content>194      </DialogPrimitive.Portal>195    </DialogPrimitive.Root>196  );197}198199/* ------------------------------------------------------------------------------------------------ */200201export interface ActionSheetItem {202  key: string;203  label: React.ReactNode;204  icon?: React.ReactNode;205  hint?: React.ReactNode;206  onSelect: () => void;207  destructive?: boolean;208  disabled?: boolean;209  /** Renders a check mark (selection lists). */210  selected?: boolean;211}212213export interface ActionSheetProps {214  open: boolean;215  onOpenChange: (open: boolean) => void;216  title?: React.ReactNode;217  description?: React.ReactNode;218  items: (ActionSheetItem | "separator")[];219  /** Optional content above the list (e.g. a preview of the message). */220  children?: React.ReactNode;221  cancelLabel?: string;222}223224/**225 * iOS-style action list inside a bottom sheet. Use for long-press / "more" menus on phones.226 * On desktop callers should keep using DropdownMenu; `useIsMobile()` decides.227 */228export function ActionSheet({ open, onOpenChange, title, description, items, children, cancelLabel = "Cancel" }: ActionSheetProps) {229  return (230    <BottomSheet open={open} onOpenChange={onOpenChange} title={title} description={description} hideTitle={!title} expandable={false} flush bodyClassName="px-3 pb-[max(12px,var(--sab))]">231      {children ? <div className="px-2 pb-3">{children}</div> : null}232      <div className="overflow-hidden rounded-2xl bg-bg-subtle">233        {items.map((it, i) =>234          it === "separator" ? (235            <div key={`sep-${i}`} className="h-2 bg-bg" />236          ) : (237            <button238              key={it.key}239              type="button"240              disabled={it.disabled}241              onClick={() => {242                onOpenChange(false);243                // Let the sheet start closing before running navigation-heavy handlers.244                setTimeout(it.onSelect, 10);245              }}246              className={cn(247                "flex min-h-[52px] w-full items-center gap-3 px-4 text-left text-[16px] transition-colors active:bg-bg-muted disabled:opacity-40 [&_svg]:size-[18px] [&_svg]:shrink-0",248                i > 0 && items[i - 1] !== "separator" && "border-t border-hairline",249                it.destructive ? "text-danger" : "text-fg",250              )}251            >252              <span className={cn("text-fg-muted", it.destructive && "text-danger")}>{it.icon}</span>253              <span className="min-w-0 flex-1 truncate">{it.label}</span>254              {it.hint ? <span className="text-[13px] text-fg-subtle">{it.hint}</span> : null}255              {it.selected ? <span className="text-accent">✓</span> : null}256            </button>257          ),258        )}259      </div>260      <button type="button" onClick={() => onOpenChange(false)} className="mt-2 flex min-h-[52px] w-full items-center justify-center rounded-2xl bg-bg-subtle text-[16px] font-semibold text-fg active:bg-bg-muted">261        {cancelLabel}262      </button>263    </BottomSheet>264  );265}266