Merge branch 'fix/gif-picker-scrolling' into 'main'
Fix GIF picker scroll inside dialogs by portaling into modal container See merge request soapbox-pub/ditto!45
This commit is contained in:
@@ -3,11 +3,12 @@
|
||||
* comments/replies on any Nostr event.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
@@ -121,6 +122,7 @@ interface CommentsModalProps {
|
||||
|
||||
export function CommentsSheet({ event, open, onClose }: CommentsModalProps) {
|
||||
const { data: rawComments = [], isLoading, dataUpdatedAt } = useEventComments(event);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
|
||||
// Only show comments once the query has actually fetched for this event.
|
||||
// `dataUpdatedAt === 0` means the query has never resolved — show nothing.
|
||||
@@ -130,56 +132,63 @@ export function CommentsSheet({ event, open, onClose }: CommentsModalProps) {
|
||||
return rawComments.filter((e) => seen.has(e.id) ? false : (seen.add(e.id), true));
|
||||
}, [rawComments, dataUpdatedAt]);
|
||||
|
||||
const modalRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setPortalContainer(node ?? undefined);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm animate-in fade-in-0 duration-200"
|
||||
className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm cursor-pointer animate-in fade-in-0 duration-200"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
/>
|
||||
|
||||
{/* Modal — centered, rounded */}
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="pointer-events-auto w-full max-w-lg max-h-[80vh] flex flex-col bg-background/90 backdrop-blur-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 fade-in-0 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 shrink-0">
|
||||
<h3 className="font-semibold text-sm">{event?.kind === 1 ? 'Replies' : 'Comments'}</h3>
|
||||
<button
|
||||
className="p-1.5 rounded-full hover:bg-secondary transition-colors text-muted-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compose — top */}
|
||||
{event && (
|
||||
<div className="shrink-0 -mb-px overflow-hidden">
|
||||
<ComposeBox replyTo={event} compact placeholder={event?.kind === 1 ? 'Add a reply…' : 'Add a comment…'} />
|
||||
<PortalContainerProvider value={portalContainer}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 shrink-0">
|
||||
<h3 className="font-semibold text-sm">{event?.kind === 1 ? 'Replies' : 'Comments'}</h3>
|
||||
<button
|
||||
className="p-1.5 rounded-full hover:bg-secondary transition-colors text-muted-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border/50">
|
||||
{isLoading ? (
|
||||
<div className="py-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => <CommentSkeleton key={i} />)}
|
||||
{/* Compose — top */}
|
||||
{event && (
|
||||
<div className="shrink-0 -mb-px overflow-hidden">
|
||||
<ComposeBox replyTo={event} compact placeholder={event?.kind === 1 ? 'Add a reply…' : 'Add a comment…'} />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{event?.kind === 1 ? 'No replies yet. Be the first!' : 'No comments yet. Be the first!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((reply) => <CommentRow key={reply.id} event={reply} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border/50">
|
||||
{isLoading ? (
|
||||
<div className="py-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => <CommentSkeleton key={i} />)}
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{event?.kind === 1 ? 'No replies yet. Be the first!' : 'No comments yet. Be the first!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((reply) => <CommentRow key={reply.id} event={reply} />)
|
||||
)}
|
||||
</div>
|
||||
</PortalContainerProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+119
-121
@@ -11,7 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { EmojiPicker } from '@/components/EmojiPicker';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
@@ -1178,132 +1178,130 @@ export function ComposeBox({
|
||||
)}
|
||||
|
||||
{/* Emoji / GIF picker */}
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className={cn(
|
||||
'p-2 rounded-full transition-colors',
|
||||
pickerOpen
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
|
||||
)}
|
||||
>
|
||||
<Smile className="size-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{!pickerOpen && <TooltipContent>Emoji / GIF</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent
|
||||
className="w-auto max-w-fit p-0 gap-0 border-border rounded-xl overflow-hidden [&>button]:hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('emoji')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'emoji'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<Smile className="size-3.5" />
|
||||
Emoji
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('gif')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'gif'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="1" y="1" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||||
<text x="9" y="9" textAnchor="middle" dominantBaseline="central" fontSize="7" fontWeight="700" fontFamily="system-ui,sans-serif" fill="currentColor" letterSpacing="0.5">GIF</text>
|
||||
</svg>
|
||||
GIF
|
||||
</button>
|
||||
{customEmojis.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('stickers')}
|
||||
className={cn(
|
||||
'p-2 rounded-full transition-colors',
|
||||
pickerOpen
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'stickers'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<Smile className="size-[18px]" />
|
||||
<Sticker className="size-3.5" />
|
||||
Stickers
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{!pickerOpen && <TooltipContent>Emoji / GIF</TooltipContent>}
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-auto p-0 border-border"
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('emoji')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'emoji'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<Smile className="size-3.5" />
|
||||
Emoji
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('gif')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'gif'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="1" y="1" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||||
<text x="9" y="9" textAnchor="middle" dominantBaseline="central" fontSize="7" fontWeight="700" fontFamily="system-ui,sans-serif" fill="currentColor" letterSpacing="0.5">GIF</text>
|
||||
</svg>
|
||||
GIF
|
||||
</button>
|
||||
{customEmojis.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('stickers')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'stickers'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<Sticker className="size-3.5" />
|
||||
Stickers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Picker content */}
|
||||
{pickerTab === 'emoji' ? (
|
||||
<EmojiPicker
|
||||
customEmojis={customEmojis}
|
||||
onSelect={(selection) => {
|
||||
if (selection.type === 'native') {
|
||||
insertEmoji(selection.emoji);
|
||||
} else {
|
||||
insertEmoji(`:${selection.shortcode}:`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : pickerTab === 'stickers' ? (
|
||||
<div className="w-[316px] h-[435px]">
|
||||
{customEmojis.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Sticker className="size-8 opacity-40" />
|
||||
<p className="text-sm">No sticker packs yet</p>
|
||||
<p className="text-xs">Add emoji packs to your profile to use stickers</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="grid grid-cols-4 gap-1.5 p-2">
|
||||
{customEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji.shortcode}
|
||||
type="button"
|
||||
title={emoji.shortcode}
|
||||
onClick={() => {
|
||||
setContent((prev) => (prev ? prev + '\n' + emoji.url : emoji.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<GifPicker onSelect={(gif) => {
|
||||
setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
{/* Picker content */}
|
||||
{pickerTab === 'emoji' ? (
|
||||
<EmojiPicker
|
||||
customEmojis={customEmojis}
|
||||
onSelect={(selection) => {
|
||||
if (selection.type === 'native') {
|
||||
insertEmoji(selection.emoji);
|
||||
} else {
|
||||
insertEmoji(`:${selection.shortcode}:`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : pickerTab === 'stickers' ? (
|
||||
<div className="w-[316px] h-[435px]">
|
||||
{customEmojis.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Sticker className="size-8 opacity-40" />
|
||||
<p className="text-sm">No sticker packs yet</p>
|
||||
<p className="text-xs">Add emoji packs to your profile to use stickers</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="grid grid-cols-4 gap-1.5 p-2">
|
||||
{customEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji.shortcode}
|
||||
type="button"
|
||||
title={emoji.shortcode}
|
||||
onClick={() => {
|
||||
setContent((prev) => (prev ? prev + '\n' + emoji.url : emoji.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<GifPicker onSelect={(gif) => {
|
||||
setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Content warning (NIP-36) */}
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
@@ -48,14 +49,20 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
const isQuote = !!quotedEvent;
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [hasPreviewableContent, setHasPreviewableContent] = useState(false);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
|
||||
const isProfileRoot = !isUrl && event instanceof Object && 'kind' in event && event.kind === 0;
|
||||
const title = titleOverride ?? (isUrl ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post');
|
||||
const placeholder = placeholderOverride ?? (isUrl ? 'Write a comment...' : isReply ? "What's on your mind?" : isQuote ? 'Add a comment...' : "What's happening?");
|
||||
|
||||
const dialogContentRef = useCallback((node: HTMLElement | null) => {
|
||||
setPortalContainer(node ?? undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
ref={dialogContentRef}
|
||||
className="max-w-[520px] max-h-[85vh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden flex flex-col"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -64,71 +71,73 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
textarea?.focus();
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12 shrink-0">
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<PortalContainerProvider value={portalContainer}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12 shrink-0">
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Preview toggle */}
|
||||
{hasPreviewableContent && (
|
||||
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
|
||||
<button
|
||||
onClick={() => setPreviewMode(false)}
|
||||
className={cn(
|
||||
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
|
||||
!previewMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewMode(true)}
|
||||
className={cn(
|
||||
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
|
||||
previewMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Preview toggle */}
|
||||
{hasPreviewableContent && (
|
||||
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
|
||||
<button
|
||||
onClick={() => setPreviewMode(false)}
|
||||
className={cn(
|
||||
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
|
||||
!previewMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewMode(true)}
|
||||
className={cn(
|
||||
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
|
||||
previewMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embedded original post (reply only, not for URL roots or quotes) */}
|
||||
{event && !isUrl && !isQuote && (
|
||||
<div className="overflow-y-auto min-h-0 shrink">
|
||||
<EmbeddedPost event={event} />
|
||||
{/* Embedded original post (reply only, not for URL roots or quotes) */}
|
||||
{event && !isUrl && !isQuote && (
|
||||
<div className="overflow-y-auto min-h-0 shrink">
|
||||
<EmbeddedPost event={event} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose area */}
|
||||
<div className="shrink-0">
|
||||
<ComposeBox
|
||||
replyTo={isQuote ? undefined : (event ?? undefined)}
|
||||
quotedEvent={quotedEvent ?? undefined}
|
||||
onSuccess={() => { onOpenChange(false); onSuccess?.(); }}
|
||||
placeholder={placeholder}
|
||||
forceExpanded
|
||||
hideAvatar
|
||||
previewMode={previewMode}
|
||||
onHasPreviewableContentChange={setHasPreviewableContent}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose area */}
|
||||
<div className="shrink-0">
|
||||
<ComposeBox
|
||||
replyTo={isQuote ? undefined : (event ?? undefined)}
|
||||
quotedEvent={quotedEvent ?? undefined}
|
||||
onSuccess={() => { onOpenChange(false); onSuccess?.(); }}
|
||||
placeholder={placeholder}
|
||||
forceExpanded
|
||||
hideAvatar
|
||||
previewMode={previewMode}
|
||||
onHasPreviewableContentChange={setHasPreviewableContent}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
</div>
|
||||
</PortalContainerProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[150] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-[250] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[150] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -15,17 +15,13 @@ const DialogClose = DialogPrimitive.Close
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, onClick, ...props }, ref) => (
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[150] bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-[250] bg-black/60 backdrop-blur-sm cursor-pointer data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -34,19 +30,15 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, onClick, ...props }, ref) => (
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[150] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { usePortalContainer } from "@/contexts/PortalContainerContext"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
@@ -10,20 +11,24 @@ const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[200] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
const portalContainer = usePortalContainer();
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal container={portalContainer}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[200] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
})
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Provides a DOM element for Radix portals (Popover, Tooltip, DropdownMenu, etc.)
|
||||
* to render into. When set, portaled content renders inside the container element
|
||||
* instead of document.body.
|
||||
*
|
||||
* This is necessary when a Popover is opened inside a Radix Dialog, because the
|
||||
* Dialog's RemoveScroll blocks wheel/touch scroll events on elements outside the
|
||||
* Dialog's DOM tree. By portaling into the Dialog's content element, the Popover
|
||||
* stays within the RemoveScroll boundary and scrolling works correctly.
|
||||
*/
|
||||
const PortalContainerContext = createContext<HTMLElement | undefined>(undefined);
|
||||
|
||||
export const PortalContainerProvider = PortalContainerContext.Provider;
|
||||
|
||||
export function usePortalContainer(): HTMLElement | undefined {
|
||||
return useContext(PortalContainerContext);
|
||||
}
|
||||
Reference in New Issue
Block a user