Files
eranos/src/components/StickerPicker.tsx
T
Chad Curtis 3aa08ba93e Overhaul compose box UX: inline picker, draft autosave, prevent accidental dismiss
- Move emoji/GIF/sticker picker from popover to inline panel in all contexts
- Add pill-style tab highlights for inline picker (emoji, GIF, stickers)
- Auto-save compose drafts to localStorage with debounce, keyed by context
- Prevent accidental modal dismissal (backdrop click blocked)
- Theme emoji picker to match Ditto design system (search, nav, scrollbar)
- Fix emoji picker width (dynamicWidth + shadow DOM overrides)
- Create StickerPicker component with search bar
- Restructure modal layout: scrollable content, sticky toolbar, fixed picker
- Mobile keyboard handling (blur/refocus textarea around picker)
- Move Tenor attribution into GIF search bar as inline hint
- Bump picker height to 280px
- Auto-focus textarea on modal open (iOS keyboard fix)
- Remove rounded corners from feed compose box
2026-04-17 00:01:38 -05:00

99 lines
3.6 KiB
TypeScript

import { useState, useMemo, useRef, useEffect } from 'react';
import { Search, X, Sticker } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import type { CustomEmoji } from '@/hooks/useCustomEmojis';
interface StickerPickerProps {
customEmojis: CustomEmoji[];
onSelect: (emoji: CustomEmoji) => void;
/** Fixed height for the picker. Defaults to 350px. */
height?: number;
/** Auto-focus the search input on mount (default true on desktop). */
autoFocus?: boolean;
}
export function StickerPicker({ customEmojis, onSelect, height = 350, autoFocus = true }: StickerPickerProps) {
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [autoFocus]);
const filtered = useMemo(() => {
if (!query.trim()) return customEmojis;
const q = query.toLowerCase();
return customEmojis.filter((e) => e.shortcode.toLowerCase().includes(q));
}, [customEmojis, query]);
if (customEmojis.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2" style={{ height }}>
<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>
);
}
return (
<div className="flex flex-col" style={{ height }}>
{/* Search input */}
<div className="px-3 pt-3 pb-2 shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search stickers..."
className="pl-8 pr-8 h-9 text-base md:text-sm bg-muted/50 border-0 rounded-lg"
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
{/* Results */}
<ScrollArea className="flex-1 min-h-0">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No stickers found</p>
<p className="text-xs mt-1">Try a different search term</p>
</div>
) : (
<div className="grid grid-cols-4 gap-1.5 p-2">
{filtered.map((emoji) => (
<button
key={emoji.shortcode}
type="button"
title={emoji.shortcode}
onClick={() => onSelect(emoji)}
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
>
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
</button>
))}
</div>
)}
</ScrollArea>
</div>
);
}