コミットを比較

..
3 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 76ca985096 AI: address issue #320 2026-11-13 07:40:54 +09:00
みてるぞ f05d8a4b3a 集計で独裁者以外がほとんど検索条件を指定できないバグ (#444) (#452)
Reviewed-on: #452
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-09 00:03:26 +09:00
みてるぞ dbae864f24 広場のタグ検索がスマホで利かない問題 (#435) (#453)
Reviewed-on: #453
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-08 14:52:38 +09:00
4個のファイルの変更、180行の追加、38行の削除
+8 -2
ファイルの表示
@@ -36,10 +36,13 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string
setTags: (tags: string) => void
label?: ReactNode
errors?: ReactNode[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
const PostFormTagsArea: FC<Props> = (
{ tags, setTags, label: fieldLabel, errors, ...rest },
) => {
const { label } = useUITranslation ('navigation')
const locale = useLocale ()
const ref = useRef<HTMLTextAreaElement> (null)
@@ -117,7 +120,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
}, [locale])
return (
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
<FormField
className="relative w-full"
label={fieldLabel ?? label ('tags')}
messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
+120
ファイルの表示
@@ -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 <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderSearch = () => renderWithProviders (
<>
<TagSearch/>
<LocationOutput/>
</>, { 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<HTMLInputElement> ('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<HTMLInputElement> ('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 ()
})
})
+44 -24
ファイルの表示
@@ -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<HTMLFormElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="relative w-full">
<form onSubmit={handleSubmit} className="relative w-full">
<input type="text"
enterKeyHint="search"
data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text}
value={search}
@@ -153,12 +167,18 @@ const TagSearch: FC = () => {
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')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
</form>)
}
export default TagSearch
+8 -12
ファイルの表示
@@ -6,11 +6,10 @@ import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import TagLink from '@/components/TagLink'
import SortHeader from '@/components/SortHeader'
import FieldError from '@/components/common/FieldError'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TextArea from '@/components/common/TextArea'
import useDialogue from '@/lib/dialogues/useDialogue'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
@@ -357,21 +356,18 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
dark:bg-rose-950/30">
<td colSpan={editable ? 5 : 4} className="p-3">
<div className="space-y-2">
<label htmlFor={`nico-links-${ tag.id }`}
className="block text-sm font-semibold">
{label ('nico.tagsToLink')}
</label>
<TextArea
<PostFormTagsArea
id={`nico-links-${ tag.id }`}
value={rawTags[tag.id] ?? ''}
invalid={(errorsByTagId[tag.id] ?? []).length > 0}
label={label ('nico.tagsToLink')}
aria-label={message ('nico.tagsToLink').text}
tags={rawTags[tag.id] ?? ''}
errors={errors}
className="min-h-24 resize-y"
placeholder={message ('nico.tagsPlaceholder').text}
lang={message ('nico.tagsPlaceholder').language}
onChange={e => setRawTags (rawTags => ({
setTags={value => setRawTags (rawTags => ({
...rawTags,
[tag.id]: e.target.value}))}/>
<FieldError messages={errors}/>
[tag.id]: value}))}/>
<div className="flex justify-end gap-2">
<button
type="button"