diff --git a/frontend/src/components/PostFormTagsArea.test.tsx b/frontend/src/components/PostFormTagsArea.test.tsx index 5687cd2..ebda4a3 100644 --- a/frontend/src/components/PostFormTagsArea.test.tsx +++ b/frontend/src/components/PostFormTagsArea.test.tsx @@ -12,24 +12,36 @@ const api = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) describe ('PostFormTagsArea', () => { - it ('updates text and fetches autocomplete for the selected token', async () => { + it ('replaces the selected token and restores the caret without losing other tags', async () => { const setTags = vi.fn () api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })]) - renderWithProviders () + const { rerender } = renderWithProviders ( + ) - const textarea = screen.getByRole ('textbox') + const textarea = screen.getByRole ('textbox') fireEvent.focus (textarea) - fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } }) - fireEvent.change (textarea, { target: { value: '虹夏' } }) + fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } }) + rerender () + fireEvent.select (textarea, { target: { selectionStart: 6, selectionEnd: 6 } }) await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/autocomplete', - { locale: 'ja', params: { q: '虹', nico: '0' }, - signal: expect.any (AbortSignal) }, - ) + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹な', nico: '0' }, + signal: expect.any (AbortSignal) }) }) - expect (setTags).toHaveBeenCalledWith ('虹夏') + expect (setTags).toHaveBeenCalledWith ('ぼっち 虹な 後続') + + fireEvent.mouseDown (await screen.findByText ('虹夏')) + expect (setTags).toHaveBeenLastCalledWith ('ぼっち 虹夏 後続') + rerender () + + await waitFor (() => { + expect (textarea).toHaveFocus () + expect (textarea.selectionStart).toBe (7) + expect (textarea.selectionEnd).toBe (7) + }) + expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () }) }) diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 6e72a11..83a4ee9 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -1,13 +1,10 @@ -// TODO: TagSearch と共通化する. +import { useRef, useState } from 'react' -import { useEffect, useRef, useState } from 'react' - -import { useLocale } from '@/lib/useLocale' +import { useTagAutocomplete } from '@/lib/useTagAutocomplete' import { useUITranslation } from '@/lib/useUITranslation' import TagSearchBox from '@/components/TagSearchBox' import FormField from '@/components/common/FormField' import TextArea from '@/components/common/TextArea' -import { apiGet } from '@/lib/api' import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react' @@ -41,24 +38,13 @@ type Props = Omit, 'value' | 'onChange' | ' const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { const { label } = useUITranslation ('navigation') - const locale = useLocale () const ref = useRef (null) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [focused, setFocused] = useState (false) - const [request, setRequest] = useState ({ query: '' }) - const [result, setResult] = useState<{ - locale: string - request: { query: string } - tags: Tag[] }> ({ locale, request, tags: [] }) const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token - const suggestions = (result.locale === locale && result.request === request - && request.query === currentToken) ? result.tags : [] - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const handleTagSelect = (tag: Tag) => { - setSuggestionsVsbl (false) - setRequest ({ query: '' }) const textarea = ref.current! const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ') setTags (newValue) @@ -70,52 +56,18 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { }) } + const { inputProps, suggestionProps, requestSuggestions } = + useTagAutocomplete ({ token: currentToken, onSelect: handleTagSelect }) + const recompute = (pos: number, v: string = tags) => { const { start, end, token } = getTokenAt (v, pos) - setRequest ({ query: token }) - setSuggestionsVsbl (token.trim () !== '') + requestSuggestions (token) if (!(token.trim ())) - { - setSuggestionsVsbl (false) - return - } + return setBounds ({ start, end }) } - useEffect (() => { - if (request.query.trim () === '') - return - const controller = new AbortController () - let active = true - void (async () => { - try - { - const tags = await apiGet ('/tags/autocomplete', { - locale, signal: controller.signal, params: { q: request.query, nico: '0' } }) - if (active) - { - setResult ({ locale, request, - tags: tags.filter (tag => tag.postCount > 0) }) - } - } - catch - { - if (active) - setResult ({ locale, request, tags: [] }) - } - }) () - return () => { - active = false - controller.abort () - } - }, [request, locale]) - - useEffect (() => { - setSuggestionsVsbl (false) - setRequest ({ query: '' }) - }, [locale]) - return ( {({ describedBy, invalid }) => ( @@ -134,15 +86,10 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { onFocus={() => setFocused (true)} onBlur={() => { setFocused (false) - setSuggestionsVsbl (false) + inputProps.onBlur () }}/> {focused && ( - 0 - ? suggestions - : [] as Tag[]} - activeIndex={-1} - onSelect={handleTagSelect}/>)} + )} )} ) } diff --git a/frontend/src/components/TagSearch.test.tsx b/frontend/src/components/TagSearch.test.tsx index b66e411..4f87e6d 100644 --- a/frontend/src/components/TagSearch.test.tsx +++ b/frontend/src/components/TagSearch.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,6 +8,8 @@ import * as api from '@/lib/api' import { buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import type { Tag } from '@/types' + const LocationOutput = () => { const location = useLocation () @@ -117,4 +119,39 @@ describe ('TagSearch', () => { expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3') expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () }) + + it ('aborts obsolete requests and ignores their late suggestions', async () => { + const responses: ((tags: Tag[]) => void)[] = [] + vi.mocked (api.apiGet).mockImplementation (() => new Promise (resolve => { + responses.push (resolve) + })) + const { unmount } = renderSearch () + const input = screen.getByRole ('textbox') + + fireEvent.change (input, { target: { value: '虹' } }) + const previousSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal + expect (previousSignal).toBeInstanceOf (AbortSignal) + expect (previousSignal?.aborted).toBe (false) + + fireEvent.change (input, { target: { value: 'ぼ' } }) + expect (previousSignal?.aborted).toBe (true) + expect (api.apiGet).toHaveBeenLastCalledWith ('/tags/autocomplete', { + locale: 'ja', params: { q: 'ぼ', nico: '1' }, + signal: expect.any (AbortSignal) }) + const currentSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal + + await act (async () => { + responses[1] ([buildTag ({ name: 'ぼっち' })]) + }) + expect (screen.getByText ('ぼっち')).toBeInTheDocument () + + await act (async () => { + responses[0] ([buildTag ({ name: '虹夏' })]) + }) + expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () + expect (screen.getByText ('ぼっち')).toBeInTheDocument () + + unmount () + expect (currentSignal?.aborted).toBe (true) + }) }) diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index af5a686..434b1a8 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -1,82 +1,46 @@ -// TODO: タグ入力系すべてに同様の処理あるため共通化する. - -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useLocale, useLocaleNavigate } from '@/lib/useLocale' +import TagSearchBox from '@/components/TagSearchBox' +import { useLocaleNavigate } from '@/lib/useLocale' +import { useTagAutocomplete } from '@/lib/useTagAutocomplete' import { useUITranslation } from '@/lib/useUITranslation' -import { apiGet } from '@/lib/api' import { inputClass } from '@/lib/utils' -import TagSearchBox from './TagSearchBox' - -import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react' +import type { ChangeEvent, FC, FormEvent } from 'react' import type { Tag } from '@/types' const TagSearch: FC = () => { const { message } = useUITranslation ('shared') - const locale = useLocale () const location = useLocation () const navigate = useLocaleNavigate () - const compositionFlgRef = useRef (false) - - const [activeIndex, setActiveIndex] = useState (-1) const [search, setSearch] = useState ('') - const [request, setRequest] = useState ({ query: '' }) - const [result, setResult] = useState<{ - locale: string - request: { query: string } - tags: Tag[] }> ({ locale, request, tags: [] }) - const suggestions = result.locale === locale && result.request === request - ? result.tags : [] - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) - const whenChanged = (ev: ChangeEvent) => { - const value = ev.target.value - const query = value.trim ().split (' ').at (-1) ?? '' - setSearch (value) - setRequest ({ query }) - setActiveIndex (-1) - setSuggestionsVsbl (query !== '') + const handleTagSelect = (tag: Tag) => { + const parts = search.split (' ') + parts[parts.length - 1] = ( + (parts[parts.length - 1].slice (0, 4).toLowerCase () === 'not:') + ? ('not:' + tag.name) + : tag.name) + setSearch (parts.join (' ') + ' ') } - useEffect (() => { - if (request.query === '') - return - const controller = new AbortController () - let active = true - void (async () => { - try - { - const tags = await apiGet ('/tags/autocomplete', { - locale, signal: controller.signal, params: { q: request.query } }) - if (active) - { - setResult ({ locale, request, - tags: tags.filter (tag => tag.postCount > 0) }) - } - } - catch - { - if (active) - setResult ({ locale, request, tags: [] }) - } - }) () - return () => { - active = false - controller.abort () - } - }, [request, locale]) + const { inputProps, suggestionProps, requestSuggestions, + clearSuggestions, compositionFlgRef } = useTagAutocomplete ({ + token: search.trim ().split (' ').at (-1) ?? '', + nicoFlg: true, + onSelect: handleTagSelect }) - useEffect (() => { - setActiveIndex (-1) - setSuggestionsVsbl (false) - }, [locale]) + const whenChanged = (event: ChangeEvent) => { + const value = event.target.value + setSearch (value) + requestSuggestions (value.trim ().split (' ').at (-1) ?? '') + } - const handleSubmit = (ev: FormEvent) => { - ev.preventDefault () + const handleSubmit = (event: FormEvent) => { + event.preventDefault () if (compositionFlgRef.current) return @@ -89,61 +53,7 @@ const TagSearch: FC = () => { .filter (t => t !== 'type:or') .join (' '), match })).toString () }`) - setSuggestionsVsbl (false) - setRequest ({ query: '' }) - } - - const handleKeyDown = (ev: KeyboardEvent) => { - if (compositionFlgRef.current - || ev.nativeEvent.isComposing - || ev.nativeEvent.keyCode === 229) - { - if (ev.key === 'Enter') - ev.preventDefault () - return - } - - 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': - { - const selected = suggestionsVsbl ? suggestions[activeIndex] : null - if (selected == null) - break - ev.preventDefault () - handleTagSelect (selected) - } - break - - case 'Escape': - ev.preventDefault () - setSuggestionsVsbl (false) - break - } - } - - const handleTagSelect = (tag: Tag) => { - const parts = search.split (' ') - parts[parts.length - 1] = ( - (parts[parts.length - 1].slice(0, 4).toLowerCase () === 'not:') - ? ('not:' + tag.name) - : tag.name) - setSearch (parts.join (' ') + ' ') - setRequest ({ query: '' }) - setSuggestionsVsbl (false) - setActiveIndex (-1) + clearSuggestions () } useEffect (() => { @@ -151,33 +61,21 @@ const TagSearch: FC = () => { const anyFlg = query.get ('match') === 'any' const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }` setSearch (tagsQuery) - setRequest ({ query: '' }) - setActiveIndex (-1) - setSuggestionsVsbl (false) - }, [location.search]) + clearSuggestions () + }, [location.search, clearSuggestions]) return (
setSuggestionsVsbl (true)} - onBlur={() => setSuggestionsVsbl (false)} - onKeyDown={handleKeyDown} - onCompositionStart={() => { - compositionFlgRef.current = true - }} - onCompositionEnd={() => { - compositionFlgRef.current = false - }} className={inputClass (false, 'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/> - + ) } diff --git a/frontend/src/components/common/TagInput.test.tsx b/frontend/src/components/common/TagInput.test.tsx index af8cb9a..1a4ab7d 100644 --- a/frontend/src/components/common/TagInput.test.tsx +++ b/frontend/src/components/common/TagInput.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagInput from '@/components/common/TagInput' @@ -20,18 +20,20 @@ describe ('TagInput', () => { const setValue = vi.fn () api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) - render () + const { rerender } = render ( + ) fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } }) + rerender () await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/autocomplete', - { locale: 'ja', params: { q: '虹夏', nico: '0' }, - signal: expect.any (AbortSignal) }, - ) + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹夏', nico: '0' }, + signal: expect.any (AbortSignal) }) }) expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') + expect (await screen.findByText ('虹夏')).toBeInTheDocument () }) it ('does not fetch when the last token is blank', () => { @@ -54,10 +56,51 @@ describe ('TagInput', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/autocomplete', - { locale: 'ja', params: { q: '虹夏', nico: '1' }, - signal: expect.any (AbortSignal) }, - ) + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹夏', nico: '1' }, + signal: expect.any (AbortSignal) }) }) }) + + it ('preserves negation and blocks suggestion selection during composition', async () => { + const setValue = vi.fn () + api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏' })]) + const { rerender } = render () + const input = screen.getByRole ('textbox') + + fireEvent.change (input, { target: { value: 'ぼっち NOT:虹' } }) + rerender () + await screen.findByText ('虹夏') + expect (api.apiGet).toHaveBeenLastCalledWith ( + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹', nico: '0' }, + signal: expect.any (AbortSignal) }) + + fireEvent.keyDown (input, { key: 'ArrowDown' }) + fireEvent.compositionStart (input) + expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (false) + expect (setValue).toHaveBeenCalledTimes (1) + + fireEvent.compositionEnd (input) + fireEvent.keyDown (input, { key: 'Enter' }) + + expect (setValue).toHaveBeenLastCalledWith ('ぼっち not:虹夏 ') + expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () + }) + + it.each ([false, true]) ('includes unused tags only with includeUnused=%s', async unusedFlg => { + const tag = buildTag ({ name: '虹夏', postCount: 0 }) + const response = Promise.resolve ([tag]) + api.apiGet.mockReturnValueOnce (response) + const props = { setValue: vi.fn (), includeUnused: unusedFlg } + const { rerender } = render () + + fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹' } }) + rerender () + await act (async () => { + await response + }) + + expect (screen.queryByText ('虹夏') != null).toBe (unusedFlg) + }) }) diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index d2967ff..b519e62 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -1,11 +1,8 @@ -import { useEffect, useState } from 'react' - -import { useLocale } from '@/lib/useLocale' import TagSearchBox from '@/components/TagSearchBox' -import { apiGet } from '@/lib/api' +import { useTagAutocomplete } from '@/lib/useTagAutocomplete' import { inputClass } from '@/lib/utils' -import type { FC, ChangeEvent, KeyboardEvent } from 'react' +import type { FC, ChangeEvent } from 'react' import type { Tag } from '@/types' @@ -19,136 +16,39 @@ type Props = { setValue: (value: string) => void } -const TagInput: FC = ({ - includeNico = false, - includeUnused = false, - describedBy, - invalid, - value, - setValue, -}) => { - const locale = useLocale () - const [activeIndex, setActiveIndex] = useState (-1) - const [request, setRequest] = useState ({ query: '' }) - const [result, setResult] = useState<{ - locale: string - includeNico: boolean - request: { query: string } - tags: Tag[] }> ({ locale, includeNico, request, tags: [] }) - const suggestions = (result.locale === locale && result.includeNico === includeNico - && result.request === request - && request.query === value.trim ().split (' ').at (-1)) ? result.tags : [] - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) - - // TODO: TagSearch からのコピペのため,共通化を考へる. - const whenChanged = (ev: ChangeEvent) => { - setValue (ev.target.value) - const query = ev.target.value.trim ().split (' ').at (-1) ?? '' - setRequest ({ query }) - setActiveIndex (-1) - setSuggestionsVsbl (query !== '') - } - - useEffect (() => { - if (request.query === '') - return - const controller = new AbortController () - let active = true - void (async () => { - try - { - const tags = await apiGet ('/tags/autocomplete', { - locale, signal: controller.signal, - params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } }) - if (active) - { - setResult ({ locale, includeNico, request, - tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) }) - } - } - catch - { - if (active) - setResult ({ locale, includeNico, request, tags: [] }) - } - }) () - return () => { - active = false - controller.abort () - } - }, [request, locale, includeNico, includeUnused]) - - useEffect (() => { - setActiveIndex (-1) - setSuggestionsVsbl (false) - setRequest ({ query: '' }) - }, [locale, includeNico]) - - // TODO: TagSearch からのコピペのため,共通化を考へる. +const TagInput: FC = ( + { includeNico: nicoFlg = false, + includeUnused: unusedFlg = false, + describedBy, invalid, value, setValue }, +) => { const handleTagSelect = (tag: Tag) => { const parts = value?.split (' ') const negative = /^not:/i.test (parts[parts.length - 1]) parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}` setValue (parts.join (' ') + ' ') - setRequest ({ query: '' }) - setActiveIndex (-1) } - // 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 + const { inputProps, suggestionProps, requestSuggestions } = + useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '', + nicoFlg, unusedFlg, onSelect: handleTagSelect }) - 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] - if (selected) - handleTagSelect (selected) - } - break - - case 'Escape': - ev.preventDefault () - setSuggestionsVsbl (false) - break - } - if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) - { - setSuggestionsVsbl (false) - } + const whenChanged = (event: ChangeEvent) => { + setValue (event.target.value) + const token = event.target.value.trim ().split (' ').at (-1) ?? '' + requestSuggestions (token, token.replace (/^not:/i, '')) } return (
setSuggestionsVsbl (true)} - onBlur={() => setSuggestionsVsbl (false)} - onKeyDown={handleKeyDown} className={inputClass (invalid)}/> - 0 ? suggestions : [] as Tag[]} - activeIndex={activeIndex} - onSelect={handleTagSelect}/> +
) } diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 6096b49..9a17a10 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -11,6 +11,17 @@ import type { Deerjikist, TagVersion } from '@/types' +export const fetchTagAutocomplete = async ( + query: string, + nicoFlg: boolean, + locale: LocaleCode, + signal: AbortSignal, +): Promise => + await apiGet ('/tags/autocomplete', { + locale, signal, + params: { q: query, nico: nicoFlg ? '1' : '0' } }) + + export const fetchTags = async ( { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, updatedFrom, updatedTo, deprecated, diff --git a/frontend/src/lib/useTagAutocomplete.ts b/frontend/src/lib/useTagAutocomplete.ts new file mode 100644 index 0000000..a5e716b --- /dev/null +++ b/frontend/src/lib/useTagAutocomplete.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { fetchTagAutocomplete } from '@/lib/tags' +import { useLocale } from '@/lib/useLocale' + +import type { KeyboardEvent } from 'react' + +import type { Tag } from '@/types' + + +export const useTagAutocomplete = ( + { token, onSelect, nicoFlg = false, unusedFlg = false }: { + token: string + onSelect: (tag: Tag) => void + nicoFlg?: boolean + unusedFlg?: boolean }, +) => { + const locale = useLocale () + const compositionFlgRef = useRef (false) + const [activeIndex, setActiveIndex] = useState (-1) + const [visible, setVisible] = useState (false) + const [request, setRequest] = useState ({ token: '', query: '' }) + const [result, setResult] = useState<{ + locale: string + nicoFlg: boolean + unusedFlg: boolean + request: { token: string; query: string } + tags: Tag[] }> ({ locale, nicoFlg, unusedFlg, request, tags: [] }) + const suggestions = (result.locale === locale && result.nicoFlg === nicoFlg + && result.unusedFlg === unusedFlg && result.request === request + && request.token === token) ? result.tags : [] + + const requestSuggestions = (token: string, query: string = token) => { + setRequest ({ token, query }) + setActiveIndex (-1) + setVisible (token.trim () !== '') + } + + const clearSuggestions = useCallback (() => { + setRequest ({ token: '', query: '' }) + setActiveIndex (-1) + setVisible (false) + }, []) + + useEffect (() => { + if (request.token.trim () === '') + return + const controller = new AbortController () + let active = true + void (async () => { + try + { + const tags = await fetchTagAutocomplete ( + request.query, nicoFlg, locale, controller.signal) + if (active) + { + setResult ({ locale, nicoFlg, unusedFlg, request, + tags: unusedFlg ? tags : tags.filter (tag => tag.postCount > 0) }) + } + } + catch + { + if (active) + setResult ({ locale, nicoFlg, unusedFlg, request, tags: [] }) + } + }) () + return () => { + active = false + controller.abort () + } + }, [request, locale, nicoFlg, unusedFlg]) + + useEffect (() => { + clearSuggestions () + }, [locale, nicoFlg, clearSuggestions]) + + const handleTagSelect = (tag: Tag) => { + clearSuggestions () + onSelect (tag) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (compositionFlgRef.current + || event.nativeEvent.isComposing + || event.nativeEvent.keyCode === 229) + { + if (event.key === 'Enter') + event.preventDefault () + return + } + + switch (event.key) + { + case 'ArrowDown': + event.preventDefault () + setActiveIndex (index => Math.min (index + 1, suggestions.length - 1)) + setVisible (true) + break + + case 'ArrowUp': + event.preventDefault () + setActiveIndex (index => Math.max (index - 1, -1)) + setVisible (true) + break + + case 'Enter': + { + const selected = visible ? suggestions[activeIndex] : null + if (selected == null) + { + setVisible (false) + break + } + event.preventDefault () + handleTagSelect (selected) + } + break + + case 'Escape': + event.preventDefault () + setVisible (false) + break + } + } + + return { + requestSuggestions, clearSuggestions, compositionFlgRef, + inputProps: { + onFocus: () => setVisible (true), + onBlur: () => setVisible (false), + onKeyDown: handleKeyDown, + onCompositionStart: () => { + compositionFlgRef.current = true + }, + onCompositionEnd: () => { + compositionFlgRef.current = false + } }, + suggestionProps: { + suggestions: visible ? suggestions : [], + activeIndex, + onSelect: handleTagSelect } } +}