fix: chat UI fixes
This commit is contained in:
@@ -33,6 +33,7 @@ import { ForwardDialog } from "@/components/chat/forward-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { channelAuthorizer } from "@/lib/realtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useVisualViewport } from "@/lib/use-visual-viewport";
|
||||
import {
|
||||
chat,
|
||||
conversationTitle,
|
||||
@@ -63,6 +64,13 @@ export default function ChatThreadPage() {
|
||||
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
||||
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
||||
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// While the keyboard is up the thread is sized from the visual viewport
|
||||
// instead of dvh — see useVisualViewport for why dvh can't do this on iOS.
|
||||
// Declared with the other hooks: the early return below means anything
|
||||
// placed after it would run conditionally.
|
||||
const viewport = useVisualViewport();
|
||||
const keyboardOpen = viewport?.keyboardOpen ?? false;
|
||||
// Guards against two loads racing (scroll handler + the short-page effect).
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
@@ -429,8 +437,30 @@ export default function ChatThreadPage() {
|
||||
// space, and calc() requires whitespace around a binary `-` — written
|
||||
// without it the whole declaration is invalid and dropped, which
|
||||
// collapses this to height:auto.
|
||||
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
||||
// Only while the keyboard is closed; otherwise the inline style below
|
||||
// takes over and these would fight it.
|
||||
!keyboardOpen &&
|
||||
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
||||
)}
|
||||
style={
|
||||
keyboardOpen && viewport
|
||||
? {
|
||||
// Pinned to the visible strip above the keyboard. Fixed rather
|
||||
// than in-flow because iOS scrolls the layout viewport instead
|
||||
// of resizing it, so an in-flow column keeps its old height and
|
||||
// hides the composer underneath. left/right 0 with the existing
|
||||
// mx-auto keeps the desktop column centred.
|
||||
position: "fixed",
|
||||
top: viewport.offsetTop,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: viewport.height,
|
||||
// Above the app's fixed bottom nav (z-40) so the composer can
|
||||
// never be painted over by it.
|
||||
zIndex: 50,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<Link
|
||||
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
||||
import { ogDefault } from "@/lib/og";
|
||||
@@ -43,6 +43,16 @@ const bricolage = Bricolage_Grotesque({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
// Ask the browser to shrink the layout viewport when the software keyboard
|
||||
// opens, which is the native fix for a bottom-anchored composer. Chrome and
|
||||
// Android honour it; Safari does not, which is why the chat thread also
|
||||
// tracks window.visualViewport by hand.
|
||||
interactiveWidget: "resizes-content",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// Resolves all relative OG/Twitter image URLs and canonical links to
|
||||
// absolute URLs (required for correct social previews + SEO; Next warns
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Search for people and add them to a group.
|
||||
* Search for people and add them to a group. Bottom drawer on mobile, modal on
|
||||
* desktop — see ResponsiveDialog.
|
||||
*
|
||||
* Search runs through the same-origin /api/search/users proxy the search page
|
||||
* uses. It returns handles, which is exactly what the member API takes — no id
|
||||
@@ -9,12 +10,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { chat } from "@/lib/chat";
|
||||
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||
|
||||
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
||||
|
||||
@@ -31,6 +34,7 @@ export function AddMembersDialog({
|
||||
onAdded: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [query, setQuery] = useState("");
|
||||
// Results are stored with the query they belong to, so what's displayed can
|
||||
// be derived: a stale page from a previous query simply stops matching, and
|
||||
@@ -68,12 +72,6 @@ export function AddMembersDialog({
|
||||
return () => clearTimeout(timer);
|
||||
}, [q, searchable]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const toggle = (handle: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -100,85 +98,70 @@ export function AddMembersDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
|
||||
<div className="absolute inset-0" onClick={onClose} aria-hidden />
|
||||
<div className="relative z-10 flex max-h-[75vh] w-full max-w-md flex-col overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Add members</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<ResponsiveDialog
|
||||
title="Add members"
|
||||
description="Search for people to add to this group."
|
||||
onClose={onClose}
|
||||
toolbar={
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
// Not auto-focused on mobile: the keyboard would open mid-way
|
||||
// through the drawer's slide-up and fight the animation.
|
||||
autoFocus={!isMobile}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name or handle"
|
||||
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name or handle"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!searchable ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
Type at least two characters to search.
|
||||
</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{visible.map((u) => (
|
||||
<li key={u.handle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u.handle)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||
>
|
||||
<UserAvatar
|
||||
src={u.avatar_url}
|
||||
name={u.name}
|
||||
className="size-9 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">
|
||||
{u.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
@{u.handle}
|
||||
</p>
|
||||
</div>
|
||||
{picked.has(u.handle) && (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={picked.size === 0 || busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{picked.size === 0
|
||||
? "Select people to add"
|
||||
: `Add ${picked.size} ${picked.size === 1 ? "person" : "people"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={picked.size === 0 || busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{picked.size === 0
|
||||
? "Select people to add"
|
||||
: `Add ${picked.size} ${picked.size === 1 ? "person" : "people"}`}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!searchable ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
Type at least two characters to search.
|
||||
</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{visible.map((u) => (
|
||||
<li key={u.handle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u.handle)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||
>
|
||||
<UserAvatar
|
||||
src={u.avatar_url}
|
||||
name={u.name}
|
||||
className="size-9 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">{u.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
@{u.handle}
|
||||
</p>
|
||||
</div>
|
||||
{picked.has(u.handle) && (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,7 +271,12 @@ export function ChatComposer({
|
||||
submit();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2 text-sm",
|
||||
// 16px on touch devices: iOS Safari zooms the whole page when a
|
||||
// focused field computes below that, which on a composer means the
|
||||
// thread jumps every time the keyboard opens. Pointer-based devices
|
||||
// keep the app's 14px chrome.
|
||||
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2",
|
||||
"text-sm pointer-coarse:text-base",
|
||||
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
|
||||
@@ -175,7 +175,7 @@ export function GroupSettingsTab({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"
|
||||
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
|
||||
Save
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Create a group: name it, pick people, done.
|
||||
* Create a group: name it, pick people, done. Bottom drawer on mobile, modal on
|
||||
* desktop — see ResponsiveDialog.
|
||||
*
|
||||
* Members are named by handle, matching the member-management API — search
|
||||
* results already identify people that way, so nothing needs id plumbing.
|
||||
@@ -12,15 +13,18 @@ import { useRouter } from "next/navigation";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { chat } from "@/lib/chat";
|
||||
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||
|
||||
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
||||
|
||||
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [title, setTitle] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
// Results are stored with the query they belong to, so what's displayed can
|
||||
@@ -56,12 +60,6 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [q, searchable]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const toggle = (u: Candidate) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -85,29 +83,21 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
|
||||
<div className="absolute inset-0" onClick={onClose} aria-hidden />
|
||||
<div className="relative z-10 flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">New group</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b border-border p-3">
|
||||
<ResponsiveDialog
|
||||
title="New group"
|
||||
description="Name the group and choose who to include."
|
||||
onClose={onClose}
|
||||
toolbar={
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
autoFocus
|
||||
// Not auto-focused on mobile: the keyboard would open mid-way
|
||||
// through the drawer's slide-up and fight the animation.
|
||||
autoFocus={!isMobile}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder="Group name"
|
||||
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"
|
||||
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
@@ -115,7 +105,7 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Add people"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -135,54 +125,51 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!searchable ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
Search to add people. You can also add them later.
|
||||
</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{visible.map((u) => (
|
||||
<li key={u.handle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||
>
|
||||
<UserAvatar
|
||||
src={u.avatar_url}
|
||||
name={u.name}
|
||||
className="size-9 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">{u.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
@{u.handle}
|
||||
</p>
|
||||
</div>
|
||||
{picked.has(u.handle) && (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={title.trim() === "" || busy}
|
||||
onClick={create}
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={title.trim() === "" || busy}
|
||||
onClick={create}
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!searchable ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
Search to add people. You can also add them later.
|
||||
</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{visible.map((u) => (
|
||||
<li key={u.handle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||
>
|
||||
<UserAvatar
|
||||
src={u.avatar_url}
|
||||
name={u.name}
|
||||
className="size-9 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">{u.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
@{u.handle}
|
||||
</p>
|
||||
</div>
|
||||
{picked.has(u.handle) && (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The shell the chat dialogs sit in: a bottom drawer on mobile, a centred
|
||||
* modal on desktop.
|
||||
*
|
||||
* Extracted because "Add members" and "New group" are the same shape — title,
|
||||
* a fixed toolbar of inputs, a scrolling list of people, a confirm button —
|
||||
* and each would otherwise carry its own copy of the drawer/modal split.
|
||||
*
|
||||
* Follows the pattern already used by ReportDialog and ShareModal: the same
|
||||
* `useMediaQuery("(max-width: 767px)")` breakpoint, vaul's Drawer below it,
|
||||
* and a portalled modal above.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
export function ResponsiveDialog({
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
toolbar,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
onClose: () => void;
|
||||
/** Fixed block under the title — search fields, selected chips. */
|
||||
toolbar?: ReactNode;
|
||||
/** Pinned action row at the bottom. */
|
||||
footer?: ReactNode;
|
||||
/** The scrolling region between toolbar and footer. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
|
||||
// Desktop only: Escape to dismiss, and lock the page behind the modal. The
|
||||
// drawer does both itself, and doubling up on the scroll lock fights vaul's
|
||||
// own body handling on iOS.
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [isMobile, onClose]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open onOpenChange={(o) => !o && onClose()}>
|
||||
<DrawerContent className="bg-card">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{toolbar && <div className="border-b border-border px-4 pb-3">{toolbar}</div>}
|
||||
{/* Capped rather than flex-1: a drawer sizes to its content, so an
|
||||
unbounded list would grow the sheet past the screen. */}
|
||||
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
|
||||
{children}
|
||||
</div>
|
||||
{footer && <div className="border-t border-border p-4">{footer}</div>}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
|
||||
>
|
||||
<div className="flex max-h-[80vh] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-xl">
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-[family-name:var(--font-title)] text-lg font-semibold text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{toolbar && <div className="border-b border-border p-3">{toolbar}</div>}
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
{footer && <div className="border-t border-border p-3">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Tracks the *visual* viewport — the part of the page not covered by the
|
||||
* software keyboard.
|
||||
*
|
||||
* Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard
|
||||
* opens. The dynamic viewport units respond to browser chrome collapsing, not
|
||||
* to the keyboard, so a composer pinned to the bottom of a `100dvh` column
|
||||
* ends up underneath it. `window.visualViewport` is the only thing iOS updates
|
||||
* reliably, so the thread is sized from that instead while the keyboard is up.
|
||||
*
|
||||
* `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to
|
||||
* reveal the focused field rather than resizing it, so a panel pinned to the
|
||||
* visible area has to follow that offset or it drifts off-screen.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type VisualViewport = {
|
||||
/** Visible height in px, excluding the keyboard. */
|
||||
height: number;
|
||||
/** How far the layout viewport has scrolled to reveal the focused field. */
|
||||
offsetTop: number;
|
||||
/** True once the visible area is materially shorter than the window. */
|
||||
keyboardOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The
|
||||
* threshold keeps ordinary chrome changes from being read as a keyboard.
|
||||
*/
|
||||
const KEYBOARD_THRESHOLD_PX = 120;
|
||||
|
||||
export function useVisualViewport(): VisualViewport | null {
|
||||
const [state, setState] = useState<VisualViewport | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
// Absent on older browsers — callers fall back to their CSS height.
|
||||
if (!vv) return;
|
||||
|
||||
const apply = () => {
|
||||
setState({
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX,
|
||||
});
|
||||
};
|
||||
|
||||
apply();
|
||||
vv.addEventListener("resize", apply);
|
||||
// iOS fires scroll (not resize) as the layout viewport shifts under the
|
||||
// keyboard, so both are needed to stay pinned.
|
||||
vv.addEventListener("scroll", apply);
|
||||
return () => {
|
||||
vv.removeEventListener("resize", apply);
|
||||
vv.removeEventListener("scroll", apply);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user