From f8d2d753fec644f65d64b44f161ae345453734e0 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 1 Mar 2026 12:20:49 +0900 Subject: [PATCH] =?UTF-8?q?#206=20=E3=82=BF=E3=82=B0=E8=A3=9C=E5=AE=8C?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/PostFormTagsArea.tsx | 6 +- frontend/src/components/TagSearch.tsx | 2 + frontend/src/pages/posts/PostSearchPage.tsx | 82 +++++++++++++++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index c7775af..92450c1 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -1,3 +1,5 @@ +// TODO: TagSearch と共通化する. + import { useRef, useState } from 'react' import TagSearchBox from '@/components/TagSearchBox' @@ -81,9 +83,7 @@ export default (({ tags, setTags }: Props) => { const pos = (ev.target as HTMLTextAreaElement).selectionStart await recompute (pos) }} - onFocus={() => { - setFocused (true) - }} + onFocus={() => setFocused (true)} onBlur={() => { setFocused (false) setSuggestionsVsbl (false) diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index 6e7a8bd..13f72a4 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -1,3 +1,5 @@ +// TODO: タグ入力系すべてに同様の処理あるため共通化する. + import { useEffect, useState } from 'react' import { useNavigate, useLocation } from 'react-router-dom' diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 9d9c825..1ce25a9 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -5,16 +5,20 @@ import { useLocation, useNavigate } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' +import TagSearchBox from '@/components/TagSearchBox' import DateTimeField from '@/components/common/DateTimeField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' +import { apiGet } from '@/lib/api' import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' -import type { FC, FormEvent } from 'react' +import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react' + +import type { Tag } from '@/types' const setIf = (qs: URLSearchParams, k: string, v: string | null) => { @@ -46,11 +50,14 @@ export default (() => { const qUpdatedFrom = query.get ('updated_from') const qUpdatedTo = query.get ('updated_to') + const [activeIndex, setActiveIndex] = useState (-1) const [createdFrom, setCreatedFrom] = useState (qCreatedFrom) const [createdTo, setCreatedTo] = useState (qCreatedTo) const [matchType, setMatchType] = useState (qMatch ?? 'all') const [originalCreatedFrom, setOriginalCreatedFrom] = useState (qOriginalCreatedFrom) const [originalCreatedTo, setOriginalCreatedTo] = useState (qOriginalCreatedTo) + const [suggestions, setSuggestions] = useState ([]) + const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [tagsStr, setTagsStr] = useState (qTags) const [title, setTitle] = useState (qTitle ?? '') const [updatedFrom, setUpdatedFrom] = useState (qUpdatedFrom) @@ -88,6 +95,58 @@ export default (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) + // TODO: TagSearch からのコピペのため,共通化を考へる. + const whenChanged = async (ev: ChangeEvent) => { + setTagsStr (ev.target.value) + + const q = ev.target.value.trim ().split (' ').at (-1) + if (!(q)) + { + setSuggestions ([]) + return + } + + const data = await apiGet ('/tags/autocomplete', { params: { q } }) + setSuggestions (data.filter (t => t.postCount > 0)) + if (suggestions.length > 0) + setSuggestionsVsbl (true) + } + + // TODO: TagSearch からのコピペのため,共通化を考へる. + const handleKeyDown = (ev: KeyboardEvent) => { + switch (ev.key) + { + case 'ArrowDown': + ev.preventDefault () + setActiveIndex (i => Math.min (i + 1, suggestions.length - 1)) + setSuggestionsVsbl (true) + break + + case 'ArrowUp': + ev.preventDefault () + setActiveIndex (i => Math.max (i - 1, -1)) + setSuggestionsVsbl (true) + break + + case 'Enter': + if (activeIndex < 0) + break + ev.preventDefault () + const selected = suggestions[activeIndex] + selected && handleTagSelect (selected) + break + + case 'Escape': + ev.preventDefault () + setSuggestionsVsbl (false) + break + } + if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) + { + setSuggestionsVsbl (false) + } + } + const search = async () => { const qs = new URLSearchParams () setIf (qs, 'tags', tagsStr) @@ -104,6 +163,15 @@ export default (() => { navigate (`${ location.pathname }?${ qs.toString () }`) } + // TODO: TagSearch からのコピペのため,共通化を考へる. + const handleTagSelect = (tag: Tag) => { + const parts = tagsStr.split (' ') + parts[parts.length - 1] = tag.name + setTagsStr (parts.join (' ') + ' ') + setSuggestions ([]) + setActiveIndex (-1) + } + const handleSearch = (e: FormEvent) => { e.preventDefault () search () @@ -140,13 +208,21 @@ export default (() => { {/* タグ */} -
+
setTagsStr (e.target.value)} + onChange={whenChanged} + onFocus={() => setSuggestionsVsbl (true)} + onBlur={() => setSuggestionsVsbl (false)} + onKeyDown={handleKeyDown} className="w-full border p-2 rounded"/> + 0 ? suggestions : [] as Tag[]} + activeIndex={activeIndex} + onSelect={handleTagSelect}/>