From e1da3b777f4319b3241c71652a07463ed5c05134 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 27 Jan 2026 09:13:33 +0900 Subject: [PATCH 01/17] feat: improve emoji picker keyboard navigation --- src/App.tsx | 3 + src/ui/components/AccountSelector.tsx | 3 + src/ui/components/ComposeBox.tsx | 248 +++++++++++++++++++++++++- src/ui/components/ReactionPicker.tsx | 233 +++++++++++++++++++++++- src/ui/content/shortcuts.ts | 10 ++ 5 files changed, 493 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ec3ba50..4d359cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -499,6 +499,9 @@ export const App = () => { if (event.defaultPrevented) { return; } + if (document.querySelector('[data-emoji-picker-open="true"]')) { + return; + } const hasOverlayBackdrop = document.querySelector( ".overlay-backdrop, .image-modal, .confirm-modal, .profile-modal, .status-modal, .settings-modal, .info-modal" ); diff --git a/src/ui/components/AccountSelector.tsx b/src/ui/components/AccountSelector.tsx index f4d9acb..90052d2 100644 --- a/src/ui/components/AccountSelector.tsx +++ b/src/ui/components/AccountSelector.tsx @@ -59,6 +59,9 @@ export const AccountSelector = ({ if (!dropdownOpen) { return; } + if (document.querySelector('[data-emoji-picker-open="true"]')) { + return; + } if (!detailsRef.current?.contains(document.activeElement)) { return; } diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index 86eb88e..66071ec 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -87,6 +87,7 @@ export const ComposeBox = ({ const fileInputRef = useRef(null); const emojiToggleRef = useRef(null); const cwToggleRef = useRef(null); + const emojiPanelRef = useRef(null); // useImageZoom 훅 사용 const { @@ -128,6 +129,8 @@ export const ComposeBox = ({ searchEmojis } = useEmojiManager(account, api, false); + const lastEmojiStatusRef = useRef(emojiStatus); + useEffect(() => { if (emojiStatus !== "error") { lastEmojiErrorRef.current = null; @@ -175,6 +178,11 @@ export const ComposeBox = ({ return searchEmojis(emojiSearchQuery); }, [emojiSearchQuery, searchEmojis]); + const emojiById = useMemo( + () => new Map(activeEmojis.map((emoji) => [emoji.id, emoji])), + [activeEmojis] + ); + const hasEmojiSearch = emojiSearchQuery.trim().length > 0; useEffect(() => { @@ -393,6 +401,16 @@ export const ComposeBox = ({ const handleKeyDown = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); + if (emojiPanelOpen) { + if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "e") { + if (emojiToggleRef.current && !emojiToggleRef.current.disabled) { + event.preventDefault(); + setEmojiPanelOpen(false); + emojiToggleRef.current.focus(); + } + } + return; + } const activeElement = document.activeElement; const isTextField = isEditableElement(activeElement); const isInsideCompose = @@ -487,7 +505,7 @@ export const ComposeBox = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [cwEnabled, setEmojiPanelOpen, toggleCw]); + }, [cwEnabled, emojiPanelOpen, setEmojiPanelOpen, toggleCw]); const findEmojiQuery = useCallback( (value: string, cursor: number) => { @@ -588,6 +606,200 @@ export const ComposeBox = ({ addToRecent(emoji.id); }; + const setCategoryExpanded = useCallback( + (categoryId: string, expanded: boolean) => { + if (categoryId === "recent") { + setRecentOpen((current) => (current === expanded ? current : expanded)); + return; + } + const isExpanded = expandedCategories.has(categoryId); + if (isExpanded !== expanded) { + toggleCategory(categoryId); + } + }, + [expandedCategories, toggleCategory] + ); + + const getEmojiNavItems = useCallback(() => { + const panel = emojiPanelRef.current; + if (!panel) { + return [] as HTMLElement[]; + } + return Array.from(panel.querySelectorAll("[data-emoji-nav]")); + }, []); + + useEffect(() => { + if (!emojiPanelOpen) { + lastEmojiStatusRef.current = emojiStatus; + return; + } + if (emojiStatus === "loaded" && lastEmojiStatusRef.current !== "loaded") { + const items = getEmojiNavItems(); + const focusTarget = items[0] ?? emojiPanelRef.current; + if (focusTarget) { + requestAnimationFrame(() => { + focusTarget.focus(); + }); + } + } + lastEmojiStatusRef.current = emojiStatus; + }, [emojiPanelOpen, emojiStatus, getEmojiNavItems]); + + useEffect(() => { + if (!emojiPanelOpen) { + return; + } + const panel = emojiPanelRef.current; + if (!panel) { + return; + } + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement && panel.contains(activeElement)) { + return; + } + const items = getEmojiNavItems(); + const focusTarget = items[0] ?? panel; + requestAnimationFrame(() => { + focusTarget.focus(); + }); + }, [emojiPanelOpen, emojiCategories.length, customEmojiCategories.length, standardEmojiCategories.length, emojiSearchResults.length, getEmojiNavItems]); + + const handleEmojiPanelKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { + return; + } + + const key = event.key; + if (key === "Escape") { + event.preventDefault(); + setEmojiPanelOpen(false); + emojiToggleRef.current?.focus(); + return; + } + + const items = getEmojiNavItems(); + if (items.length === 0) { + return; + } + + const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const currentIndex = activeElement ? items.indexOf(activeElement) : -1; + const currentItem = currentIndex >= 0 ? items[currentIndex] : null; + const navType = currentItem?.dataset.emojiNav; + const categoryId = currentItem?.dataset.emojiCategoryId; + + const focusItem = (index: number) => { + const nextIndex = Math.min(items.length - 1, Math.max(0, index)); + items[nextIndex]?.focus(); + }; + + const findEmojiByDirection = (direction: "down" | "up"): HTMLElement | null => { + if (!currentItem) { + return null; + } + const currentRect = currentItem.getBoundingClientRect(); + const currentCenterX = currentRect.left + currentRect.width / 2; + const currentCenterY = currentRect.top + currentRect.height / 2; + const emojiItems = items.filter((item) => item.dataset.emojiNav === "emoji"); + let bestItem: HTMLElement | null = null; + let bestDy = Number.POSITIVE_INFINITY; + let bestDx = Number.POSITIVE_INFINITY; + emojiItems.forEach((item) => { + if (item === currentItem) { + return; + } + const rect = item.getBoundingClientRect(); + const centerY = rect.top + rect.height / 2; + const centerX = rect.left + rect.width / 2; + const dy = centerY - currentCenterY; + if (direction === "down" && dy <= 0) { + return; + } + if (direction === "up" && dy >= 0) { + return; + } + const absDy = Math.abs(dy); + const dx = Math.abs(centerX - currentCenterX); + if (absDy < bestDy || (absDy === bestDy && dx < bestDx)) { + bestItem = item; + bestDy = absDy; + bestDx = dx; + } + }); + return bestItem; + }; + + if (key === "Enter") { + if (navType === "emoji") { + event.preventDefault(); + const emojiId = currentItem?.dataset.emojiId; + const emoji = emojiId ? emojiById.get(emojiId) : null; + if (emoji) { + handleEmojiSelect(emoji); + } + } + return; + } + + if (key === "ArrowLeft" || key === "ArrowRight") { + if (navType === "category" && categoryId) { + event.preventDefault(); + setCategoryExpanded(categoryId, key === "ArrowRight"); + return; + } + event.preventDefault(); + const direction = key === "ArrowRight" ? 1 : -1; + const nextIndex = + currentIndex >= 0 ? currentIndex + direction : key === "ArrowRight" ? 0 : items.length - 1; + focusItem(nextIndex); + return; + } + + if (key === "ArrowDown" || key === "ArrowUp") { + event.preventDefault(); + if (navType === "emoji") { + const targetEmoji = findEmojiByDirection(key === "ArrowDown" ? "down" : "up"); + if (targetEmoji) { + targetEmoji.focus(); + return; + } + if (key === "ArrowDown") { + const nextCategory = items + .slice(currentIndex + 1) + .find((item) => item.dataset.emojiNav === "category"); + if (nextCategory) { + nextCategory.focus(); + return; + } + } + if (key === "ArrowUp") { + const previousCategory = items + .slice(0, Math.max(0, currentIndex)) + .reverse() + .find((item) => item.dataset.emojiNav === "category"); + if (previousCategory) { + previousCategory.focus(); + return; + } + } + return; + } + const direction = key === "ArrowDown" ? 1 : -1; + const nextIndex = + currentIndex >= 0 ? currentIndex + direction : key === "ArrowDown" ? 0 : items.length - 1; + focusItem(nextIndex); + } + }, + [emojiById, getEmojiNavItems, handleEmojiSelect, setCategoryExpanded] + ); + const handleToggleCategory = (categoryId: string) => { if (categoryId === "recent") { setRecentOpen((current) => !current); @@ -615,7 +827,15 @@ export const ComposeBox = ({ }, [attachments]); return ( -
+
{ + if (emojiPanelOpen) { + event.stopPropagation(); + } + }} + > {accountSelectorNode ? (
{accountSelectorNode}
) : null} @@ -845,7 +1065,15 @@ export const ComposeBox = ({ {emojiPanelOpen ? ( -
+
{!account ?

계정을 선택해주세요.

: null} {account ? (
@@ -891,6 +1119,8 @@ export const ComposeBox = ({ onClick={() => handleEmojiSelect(emoji)} aria-label={`이모지 ${emoji.label}`} title={emoji.shortcode ? `:${emoji.shortcode}:` : undefined} + data-emoji-nav="emoji" + data-emoji-id={emoji.id} > {emoji.unicode ? (