From a38582b0ff2af1d538e89b6628168d02876dfef3 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 8 Nov 2026 14:34:32 +0900 Subject: [PATCH] #435 --- frontend/src/components/TagSearch.test.tsx | 120 +++++++++++++++++++++ frontend/src/components/TagSearch.tsx | 68 +++++++----- 2 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/TagSearch.test.tsx diff --git a/frontend/src/components/TagSearch.test.tsx b/frontend/src/components/TagSearch.test.tsx new file mode 100644 index 0000000..b66e411 --- /dev/null +++ b/frontend/src/components/TagSearch.test.tsx @@ -0,0 +1,120 @@ +import { 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' + +import TagSearch from '@/components/TagSearch' +import * as api from '@/lib/api' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + + +const LocationOutput = () => { + const location = useLocation () + return {location.pathname}{location.search} +} + + +const renderSearch = () => renderWithProviders ( + <> + + + , { route: '/ja/posts?page=3' }) + + +describe ('TagSearch', () => { + beforeEach (() => { + vi.spyOn (api, 'apiGet').mockResolvedValue ([]) + }) + + it ('searches through native submission without an Enter key event', () => { + renderSearch () + const input = screen.getByRole ('textbox') + fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } }) + + expect (input.form).toBeInstanceOf (HTMLFormElement) + fireEvent.submit (input.form!) + + const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' }) + expect (screen.getByTestId ('location')).toHaveTextContent ( + `/ja/posts?${ query }`) + expect (input).toHaveValue ('虹夏 not:test type:or') + }) + + it ('requests a search action from the software keyboard', () => { + renderSearch () + + expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search') + }) + + it ('searches through the form when Enter is pressed', async () => { + const user = userEvent.setup () + renderSearch () + + await user.type (screen.getByRole ('textbox'), '虹夏{Enter}') + + const query = new URLSearchParams ({ tags: '虹夏', match: 'all' }) + expect (screen.getByTestId ('location')).toHaveTextContent ( + `/ja/posts?${ query }`) + }) + + it.each ([ + { label: 'composition flag', composing: true, keyCode: 13 }, + { label: 'legacy IME code', composing: false, keyCode: 229 }]) ( + 'cancels confirmation Enter with $label', ({ composing, keyCode }) => { + renderSearch () + const input = screen.getByRole ('textbox') + fireEvent.change (input, { target: { value: '虹夏' } }) + const uncancelled = fireEvent.keyDown (input, { + key: 'Enter', isComposing: composing, keyCode }) + + expect (uncancelled).toBe (false) + expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3') + expect (input).toHaveValue ('虹夏') + }) + + it ('cancels Enter throughout composition even without keyboard IME flags', () => { + renderSearch () + const input = screen.getByRole ('textbox') + fireEvent.compositionStart (input) + fireEvent.change (input, { target: { value: '虹夏' } }) + const uncancelled = fireEvent.keyDown (input, { + key: 'Enter', isComposing: false, keyCode: 13 }) + + expect (uncancelled).toBe (false) + expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3') + expect (input).toHaveValue ('虹夏') + }) + + it ('blocks direct submission during composition and allows it after confirmation', () => { + renderSearch () + const input = screen.getByRole ('textbox') + fireEvent.compositionStart (input) + fireEvent.change (input, { target: { value: '虹夏' } }) + fireEvent.submit (input.form!) + + expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3') + expect (input).toHaveValue ('虹夏') + + fireEvent.compositionEnd (input) + fireEvent.submit (input.form!) + + const query = new URLSearchParams ({ tags: '虹夏', match: 'all' }) + expect (screen.getByTestId ('location')).toHaveTextContent ( + `/ja/posts?${ query }`) + }) + + it ('selects the active suggestion without submitting the form', async () => { + vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })]) + const user = userEvent.setup () + renderSearch () + + await user.type (screen.getByRole ('textbox'), '虹') + await screen.findByText ('虹夏') + await user.keyboard ('{ArrowDown}{Enter}') + + expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ') + expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3') + expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index aa9675f..af5a686 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -1,6 +1,6 @@ // TODO: タグ入力系すべてに同様の処理あるため共通化する. -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { useLocale, useLocaleNavigate } from '@/lib/useLocale' @@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils' import TagSearchBox from './TagSearchBox' -import type { ChangeEvent, FC, KeyboardEvent } from 'react' +import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react' import type { Tag } from '@/types' @@ -20,6 +20,7 @@ const TagSearch: FC = () => { const locale = useLocale () const location = useLocation () const navigate = useLocaleNavigate () + const compositionFlgRef = useRef (false) const [activeIndex, setActiveIndex] = useState (-1) const [search, setSearch] = useState ('') @@ -74,7 +75,34 @@ const TagSearch: FC = () => { setSuggestionsVsbl (false) }, [locale]) + const handleSubmit = (ev: FormEvent) => { + ev.preventDefault () + if (compositionFlgRef.current) + return + + const parts = search.split (' ') + const match = + parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all' + navigate (`/posts?${ + (new URLSearchParams ({ + tags: parts.map (t => t.toLowerCase ()) + .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': @@ -90,13 +118,12 @@ const TagSearch: FC = () => { break case 'Enter': - if (activeIndex < 0) - break - ev.preventDefault () { - const selected = suggestions[activeIndex] - if (selected) - handleTagSelect (selected) + const selected = suggestionsVsbl ? suggestions[activeIndex] : null + if (selected == null) + break + ev.preventDefault () + handleTagSelect (selected) } break @@ -105,20 +132,6 @@ const TagSearch: FC = () => { setSuggestionsVsbl (false) break } - if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) - { - const parts = search.split (' ') - const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all' - navigate (`/posts?${ - (new URLSearchParams ({ - tags: parts.map (t => t.toLowerCase ()) - .filter (t => t !== 'type:or') - .join (' '), - match })) - .toString () }`) - setSuggestionsVsbl (false) - setRequest ({ query: '' }) - } } const handleTagSelect = (tag: Tag) => { @@ -144,8 +157,9 @@ const TagSearch: FC = () => { }, [location.search]) return ( -
+
{ onFocus={() => 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')}/> -
) + ) } export default TagSearch -- 2.53.0