Add button-driven search filters

This commit is contained in:
2026-08-10 04:17:54 +09:00
parent 079aaa1664
commit 8eb299f46d
12 changed files with 984 additions and 72 deletions
+4 -1
View File
@@ -8,6 +8,7 @@ deliberate post detail link, or manually open a valid
## Scope
- User timelines and raw X search syntax
- Button-driven search filters for author, period, language, content type, replies, and reposts
- Popular (`Top`) and chronological (`Latest`) search
- Optional `filter:follows` search
- Authenticated account list selection and list timelines from a deliberate URL or ID
@@ -27,7 +28,9 @@ they do not add related-post recommendations or an account-discovery surface.
- `/` shows the empty user input.
- `/:handle` shows a user timeline.
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` shows search results.
- `/search` shows search results. Its typed query parameters preserve the query,
author, period, language, content type, exclusions, ranking, and follows filter
so filtered searches can be reloaded or shared.
- `/i/lists` shows the authenticated account's lists.
- `/i/lists/:listId` shows a list timeline.
- `/:handle/status/:tweetId` shows one selected post and its visible conversation.
@@ -175,10 +175,14 @@ None of these functions exposes the Bird client, arbitrary method names, arbitra
### Search Flow
1. Trim and validate the raw query.
2. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
3. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
4. Return normalized tweets and the next cursor.
1. Keep the raw query and structured author, period, language, content, and
exclusion controls in typed URL search parameters.
2. Compile selected controls into a deterministic advanced search expression
using `from:`, `since:`, `until:`, `lang:`, and `filter:` operators.
3. Trim and validate the compiled query, including its 512-character limit.
4. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
5. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
6. Return normalized tweets and the next cursor.
Advanced X search grammar remains available because the raw query is otherwise passed unchanged.
@@ -207,7 +211,11 @@ The selected layout is **Focused Tabs**:
- no content on first load;
- results directly below the submitted controls.
The User tab contains one target input and a Display button. The Search tab contains a query input, a Popular/Latest segmented control, a Following only checkbox, and a Search button.
The User tab contains one target input and a Display button. The Search tab
contains a query input; buttons that open inline author, period, language,
content-type, and exclusion controls; removable active-condition chips; a
collapsible compiled-query preview; a Popular/Latest segmented control; a
Following only checkbox; and a Search button.
Submitting a user target normalizes it to a handle and navigates to `/:handle`.
Submitting a search updates the route's typed search parameters. Changing tabs
@@ -344,7 +352,9 @@ The work is complete when:
target or query, following a post detail link, or manually opening a valid
status URL.
3. A valid handle or profile URL loads that user's posts.
4. Search supports Top, Latest, and the follows filter.
4. Search supports Top, Latest, the follows filter, and button-driven author,
period, language, content-type, reply, and repost conditions while preserving
raw advanced search syntax.
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
6. Relay credentials and Bird remain server-only.
7. No excluded discovery or mutation feature is reachable.
+19
View File
@@ -22,3 +22,22 @@ test('search results have no accessibility violations', async ({
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('open filters and active condition chips have no accessibility violations', async ({
a11y,
page,
searchPage,
}) => {
await searchPage.goTo()
await searchPage.queryInputLocator.fill('TypeScript')
await page.getByRole('button', { name: '投稿者' }).click()
await page.getByLabel('投稿者').fill('@tan_stack')
await page.getByRole('button', { name: '言語' }).click()
await page.getByLabel('日本語').check()
await expect(
page.getByRole('button', { name: '投稿者 @tan_stack を解除' }),
).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
+12
View File
@@ -194,6 +194,18 @@ test('keeps keyboard focus intentional and discovery or mutation controls absent
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
await searchPage.tabTo(searchPage.queryInputLocator)
await searchPage.tabTo(
searchPage.page.getByRole('button', { name: '投稿者' }),
)
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '期間' }))
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '言語' }))
await searchPage.tabTo(
searchPage.page.getByRole('button', { name: '投稿の種類' }),
)
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '除外' }))
await searchPage.tabTo(
searchPage.page.getByText('検索式を確認', { exact: true }),
)
await expect(searchPage.latestRankingLocator).toBeChecked()
await searchPage.tabTo(searchPage.latestRankingLocator)
await searchPage.tabTo(searchPage.followingOnlyLocator)
+72 -17
View File
@@ -4,6 +4,19 @@ import { ListForm } from './list-form'
import { SearchForm } from './search-form'
import { UserForm } from './user-form'
const searchValues = {
q: '',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
product: 'Latest' as const,
following: false,
}
describe('UserForm', () => {
it('submits only after the user enters a target', () => {
const onSubmit = vi.fn()
@@ -58,40 +71,82 @@ describe('ListForm', () => {
describe('SearchForm', () => {
it('uses a labelled grouping for ranking controls', () => {
render(
<SearchForm
initialQuery=""
initialProduct="Latest"
initialFollowing={false}
onSubmit={vi.fn()}
/>,
)
render(<SearchForm initialValues={searchValues} onSubmit={vi.fn()} />)
expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument()
})
it('submits the selected ranking and follows filter', () => {
const onSubmit = vi.fn()
render(
<SearchForm
initialQuery=""
initialProduct="Latest"
initialFollowing={false}
onSubmit={onSubmit}
/>,
)
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('検索語'), {
target: { value: 'AI lang:ja' },
target: { value: ' AI lang:ja ' },
})
fireEvent.click(screen.getByLabelText('人気順'))
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
expect(screen.getByText('AI lang:ja filter:follows')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
q: 'AI lang:ja',
from: '',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
product: 'Top',
following: true,
})
})
it('submits conditions selected with the filter buttons', () => {
const onSubmit = vi.fn()
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('検索語'), {
target: { value: 'TypeScript' },
})
fireEvent.click(screen.getByRole('button', { name: '投稿者' }))
fireEvent.change(screen.getByLabelText('投稿者'), {
target: { value: '@tan_stack' },
})
fireEvent.click(screen.getByRole('button', { name: '言語' }))
fireEvent.click(screen.getByLabelText('日本語'))
fireEvent.click(screen.getByRole('button', { name: '投稿の種類' }))
fireEvent.click(screen.getByLabelText('画像'))
fireEvent.click(screen.getByRole('button', { name: '除外' }))
fireEvent.click(screen.getByLabelText('返信'))
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
...searchValues,
q: 'TypeScript',
from: '@tan_stack',
lang: 'ja',
content: 'images',
excludeReplies: true,
})
})
it('removes only the selected condition from its active chip', () => {
const onSubmit = vi.fn()
render(
<SearchForm
initialValues={{ ...searchValues, q: 'AI', from: 'yuta', lang: 'ja' }}
onSubmit={onSubmit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' }))
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
...searchValues,
q: 'AI',
lang: 'ja',
})
})
})
+363 -37
View File
@@ -1,37 +1,57 @@
import type { SearchProduct } from '@yuta/bird'
import { type FormEvent, useState } from 'react'
import { buildSearchQuery } from '../inputs'
import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs'
type SearchValues = {
q: string
export type SearchValues = AdvancedSearchQuery & {
product: SearchProduct
following: boolean
}
type Props = {
initialQuery: string
initialProduct: SearchProduct
initialFollowing: boolean
initialValues: SearchValues
onSubmit: (values: SearchValues) => void
}
export function SearchForm({
initialQuery,
initialProduct,
initialFollowing,
onSubmit,
}: Props) {
const [q, setQuery] = useState(initialQuery)
const [product, setProduct] = useState(initialProduct)
const [following, setFollowing] = useState(initialFollowing)
type FilterName = 'author' | 'period' | 'language' | 'content' | 'exclude'
const languageLabels = { all: 'すべて', ja: '日本語', en: '英語' } as const
const contentLabels = {
all: 'すべて',
images: '画像',
videos: '動画',
links: 'リンク',
} as const
function localDate(daysAgo: number): string {
const date = new Date()
date.setDate(date.getDate() - daysAgo)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function SearchForm({ initialValues, onSubmit }: Props) {
const [values, setValues] = useState(initialValues)
const [openFilter, setOpenFilter] = useState<FilterName>()
const [error, setError] = useState('')
const update = <Key extends keyof SearchValues>(
key: Key,
value: SearchValues[Key],
) => setValues((current) => ({ ...current, [key]: value }))
const submit = (event: FormEvent) => {
event.preventDefault()
const normalized = {
...values,
q: values.q.trim(),
from: values.from.trim(),
}
try {
const query = buildSearchQuery(q, false)
buildFilteredSearchQuery(normalized, normalized.following)
setError('')
onSubmit({ q: query, product, following })
onSubmit(normalized)
} catch (cause) {
setError(
cause instanceof Error ? cause.message : '入力内容を確認してください。',
@@ -39,33 +59,337 @@ export function SearchForm({
}
}
const toggleFilter = (filter: FilterName) => {
setOpenFilter((current) => (current === filter ? undefined : filter))
}
const setRecentPeriod = (days: number) => {
setValues((current) => ({
...current,
since: localDate(days - 1),
until: '',
}))
}
let preview = '検索語または投稿者を入力すると表示されます。'
try {
preview = buildFilteredSearchQuery(values, values.following)
} catch {
// The submit error gives the actionable message; the preview stays quiet.
}
const filterButtons: Array<{
name: FilterName
label: string
active: boolean
}> = [
{ name: 'author', label: '投稿者', active: Boolean(values.from) },
{
name: 'period',
label: '期間',
active: Boolean(values.since || values.until),
},
{ name: 'language', label: '言語', active: values.lang !== 'all' },
{
name: 'content',
label: '投稿の種類',
active: values.content !== 'all',
},
{
name: 'exclude',
label: '除外',
active: values.excludeReplies || values.excludeReposts,
},
]
return (
<form className="intent-form" onSubmit={submit}>
<label className="field">
<span>検索語</span>
<form className="intent-form search-form" onSubmit={submit}>
<div className="field search-query">
<label htmlFor="search-query">検索語</label>
<input
aria-describedby="search-query-help"
autoComplete="off"
aria-describedby={error ? 'query-error' : undefined}
aria-invalid={Boolean(error)}
id="search-query"
name="q"
value={q}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="検索語や高度な検索演算子"
value={values.q}
onChange={(event) => update('q', event.currentTarget.value)}
placeholder="キーワードや高度な検索式"
/>
{error ? (
<span className="field-error" id="query-error" role="alert">
{error}
</span>
<span className="field-help" id="search-query-help">
演算子を直接入力することもできます。
</span>
</div>
<fieldset className="search-filter-buttons">
<legend>条件を追加</legend>
{filterButtons.map((filter) => (
<button
aria-controls={`search-filter-${filter.name}`}
aria-expanded={openFilter === filter.name}
className="filter-button"
data-active={filter.active || undefined}
key={filter.name}
onClick={() => toggleFilter(filter.name)}
type="button"
>
{filter.label}
{filter.active ? <span aria-hidden="true">●</span> : null}
</button>
))}
</fieldset>
{openFilter ? (
<fieldset
className="search-filter-panel"
id={`search-filter-${openFilter}`}
>
<legend>
{filterButtons.find((item) => item.name === openFilter)?.label}
の条件
</legend>
{openFilter === 'author' ? (
<div className="field compact-field">
<label htmlFor="search-author">投稿者</label>
<input
aria-describedby="search-author-help"
autoComplete="off"
id="search-author"
name="from"
onChange={(event) => update('from', event.currentTarget.value)}
placeholder="@handle"
value={values.from}
/>
<span className="field-help" id="search-author-help">
@は省略できます。
</span>
</div>
) : null}
{openFilter === 'period' ? (
<>
<fieldset className="segmented search-filter-options">
<legend>すぐ選ぶ</legend>
<button onClick={() => setRecentPeriod(1)} type="button">
今日
</button>
<button onClick={() => setRecentPeriod(7)} type="button">
過去7日
</button>
<button onClick={() => setRecentPeriod(30)} type="button">
過去30日
</button>
<button
onClick={() =>
setValues((current) => ({
...current,
since: '',
until: '',
}))
}
type="button"
>
すべて
</button>
</fieldset>
<div className="date-fields">
<div className="field compact-field">
<label htmlFor="search-since">開始日</label>
<input
id="search-since"
name="since"
onChange={(event) =>
update('since', event.currentTarget.value)
}
type="date"
value={values.since}
/>
<span className="field-help" />
</div>
<div className="field compact-field">
<label htmlFor="search-until">
終了日(この日は含まない)
</label>
<input
id="search-until"
name="until"
onChange={(event) =>
update('until', event.currentTarget.value)
}
type="date"
value={values.until}
/>
<span className="field-help" />
</div>
</div>
</>
) : null}
{openFilter === 'language' ? (
<fieldset className="segmented search-filter-options">
<legend>言語</legend>
{(
Object.keys(languageLabels) as Array<
keyof typeof languageLabels
>
).map((value) => (
<label key={value}>
<input
checked={values.lang === value}
name="lang"
onChange={() => update('lang', value)}
type="radio"
value={value}
/>
{languageLabels[value]}
</label>
))}
</fieldset>
) : null}
{openFilter === 'content' ? (
<fieldset className="segmented search-filter-options">
<legend>投稿の種類</legend>
{(
Object.keys(contentLabels) as Array<keyof typeof contentLabels>
).map((value) => (
<label key={value}>
<input
checked={values.content === value}
name="content"
onChange={() => update('content', value)}
type="radio"
value={value}
/>
{contentLabels[value]}
</label>
))}
</fieldset>
) : null}
{openFilter === 'exclude' ? (
<fieldset className="search-exclusions">
<legend>検索結果から除外</legend>
<label className="check">
<input
checked={values.excludeReplies}
onChange={(event) =>
update('excludeReplies', event.currentTarget.checked)
}
type="checkbox"
/>
返信
</label>
<label className="check">
<input
checked={values.excludeReposts}
onChange={(event) =>
update('excludeReposts', event.currentTarget.checked)
}
type="checkbox"
/>
リポスト
</label>
</fieldset>
) : null}
</fieldset>
) : null}
<ul aria-label="選択中の条件" className="active-filters">
{values.from ? (
<li>
<button
aria-label={`投稿者 ${values.from} を解除`}
onClick={() => update('from', '')}
type="button"
>
{values.from.startsWith('@') ? values.from : `@${values.from}`}{' '}
<span aria-hidden="true">×</span>
</button>
</li>
) : null}
</label>
<fieldset className="segmented">
{values.since || values.until ? (
<li>
<button
aria-label="期間を解除"
onClick={() =>
setValues((current) => ({
...current,
since: '',
until: '',
}))
}
type="button"
>
{values.since || '指定なし'} → {values.until || '現在'}{' '}
<span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.lang !== 'all' ? (
<li>
<button
aria-label={`言語 ${languageLabels[values.lang]} を解除`}
onClick={() => update('lang', 'all')}
type="button"
>
{languageLabels[values.lang]} <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.content !== 'all' ? (
<li>
<button
aria-label={`投稿の種類 ${contentLabels[values.content]} を解除`}
onClick={() => update('content', 'all')}
type="button"
>
{contentLabels[values.content]} <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.excludeReplies ? (
<li>
<button
aria-label="返信の除外を解除"
onClick={() => update('excludeReplies', false)}
type="button"
>
返信を除外 <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.excludeReposts ? (
<li>
<button
aria-label="リポストの除外を解除"
onClick={() => update('excludeReposts', false)}
type="button"
>
リポストを除外 <span aria-hidden="true">×</span>
</button>
</li>
) : null}
</ul>
<details className="search-query-preview">
<summary>検索式を確認</summary>
<code>{preview}</code>
</details>
{error ? (
<p className="field-error search-error" id="query-error" role="alert">
{error}
</p>
) : null}
<fieldset className="segmented search-ranking">
<legend>並び順</legend>
{(['Top', 'Latest'] as const).map((value) => (
<label key={value}>
<input
checked={product === value}
checked={values.product === value}
name="product"
onChange={() => setProduct(value)}
onChange={() => update('product', value)}
type="radio"
value={value}
/>
@@ -73,15 +397,17 @@ export function SearchForm({
</label>
))}
</fieldset>
<label className="check">
<label className="check search-following">
<input
checked={following}
onChange={(event) => setFollowing(event.currentTarget.checked)}
checked={values.following}
onChange={(event) => update('following', event.currentTarget.checked)}
type="checkbox"
/>
フォロー中のみ
</label>
<button type="submit">検索</button>
<button className="search-submit" type="submit">
検索
</button>
</form>
)
}
+134
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
buildAdvancedSearchQuery,
buildFilteredSearchQuery,
buildSearchQuery,
normalizeListTarget,
normalizeTweetId,
normalizeUserTarget,
postSearchRouteSchema,
threadPageInputSchema,
} from './inputs'
@@ -108,3 +111,134 @@ describe('buildSearchQuery', () => {
expect(() => buildSearchQuery(' ', false)).toThrow()
})
})
describe('buildAdvancedSearchQuery', () => {
it('builds a query from button-friendly filters', () => {
expect(
buildAdvancedSearchQuery({
q: 'TypeScript',
from: '@tan_stack',
since: '2026-08-04',
until: '2026-08-11',
lang: 'ja',
content: 'images',
excludeReplies: true,
excludeReposts: true,
}),
).toBe(
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
)
})
it('allows an author search without keywords', () => {
expect(
buildAdvancedSearchQuery({
q: '',
from: 'tan_stack',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toBe('from:tan_stack')
})
it('rejects filters that have no keyword or author', () => {
expect(() =>
buildAdvancedSearchQuery({
q: '',
from: '',
since: '2026-08-04',
until: '',
lang: 'ja',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toThrow('検索語または投稿者を入力してください。')
})
it('rejects an invalid author and reversed dates', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, from: 'not-valid!' }),
).toThrow('投稿者のハンドルを確認してください。')
expect(() =>
buildAdvancedSearchQuery({
...base,
since: '2026-08-11',
until: '2026-08-10',
}),
).toThrow('終了日は開始日より後の日付を選んでください。')
})
it('rejects nonexistent dates and a compiled query over 512 characters', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, since: '2026-02-30' }),
).toThrow('開始日を確認してください。')
expect(() =>
buildAdvancedSearchQuery({ ...base, q: 'a'.repeat(510), lang: 'ja' }),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('buildFilteredSearchQuery', () => {
it('includes the follows filter in the final query and enforces its length', () => {
const input = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'ja' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(buildFilteredSearchQuery(input, true)).toBe(
'TypeScript lang:ja filter:follows',
)
expect(() =>
buildFilteredSearchQuery({ ...input, q: 'a'.repeat(498) }, true),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('postSearchRouteSchema', () => {
it('keeps omitted filter parameters optional', () => {
expect(
postSearchRouteSchema.parse({
q: 'AI',
product: 'Latest',
following: false,
}),
).toEqual({
q: 'AI',
product: 'Latest',
following: false,
})
})
})
+99 -2
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const TWEET_ID = /^\d{1,32}$/
const LIST_ID = /^\d{1,32}$/
@@ -11,6 +12,23 @@ const tweetIdSchema = z
export class InputError extends Error {}
const searchLanguageSchema = z.enum(['all', 'ja', 'en'])
const searchContentSchema = z.enum(['all', 'images', 'videos', 'links'])
type SearchLanguage = z.infer<typeof searchLanguageSchema>
type SearchContent = z.infer<typeof searchContentSchema>
export type AdvancedSearchQuery = {
q: string
from: string
since: string
until: string
lang: SearchLanguage
content: SearchContent
excludeReplies: boolean
excludeReposts: boolean
}
export const threadPageInputSchema = z.union([
z.object({
tweetId: tweetIdSchema,
@@ -45,10 +63,87 @@ export const searchPageInputSchema = z.object({
export const postSearchRouteSchema = z.object({
q: z.string().catch(''),
from: z
.string()
.refine((value) => !value || HANDLE.test(value.replace(/^@/, '')))
.optional()
.catch(undefined),
since: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
until: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
lang: searchLanguageSchema.optional().catch(undefined),
content: searchContentSchema.optional().catch(undefined),
excludeReplies: z.boolean().optional().catch(undefined),
excludeReposts: z.boolean().optional().catch(undefined),
product: z.enum(['Top', 'Latest']).catch('Latest'),
following: z.boolean().catch(false),
})
function isValidDate(value: string): boolean {
if (!ISO_DATE.test(value)) return false
const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year ?? 0, (month ?? 0) - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() + 1 === month &&
date.getUTCDate() === day
)
}
function requireDate(value: string, label: string): string {
if (!isValidDate(value)) {
throw new InputError(`${label}を確認してください。`)
}
return value
}
function normalizeSearchAuthor(raw: string): string {
const value = raw.trim().replace(/^@/, '')
if (!HANDLE.test(value)) {
throw new InputError('投稿者のハンドルを確認してください。')
}
return value
}
export function buildAdvancedSearchQuery(input: AdvancedSearchQuery): string {
const query = input.q.trim()
const author = input.from.trim()
if (!query && !author) {
throw new InputError('検索語または投稿者を入力してください。')
}
const parts = [query]
if (author) parts.push(`from:${normalizeSearchAuthor(author)}`)
const since = input.since ? requireDate(input.since, '開始日') : ''
const until = input.until ? requireDate(input.until, '終了日') : ''
if (since && until && since >= until) {
throw new InputError('終了日は開始日より後の日付を選んでください。')
}
if (since) parts.push(`since:${since}`)
if (until) parts.push(`until:${until}`)
if (input.lang !== 'all') parts.push(`lang:${input.lang}`)
if (input.content !== 'all') parts.push(`filter:${input.content}`)
if (input.excludeReplies) parts.push('-filter:replies')
if (input.excludeReposts) parts.push('-filter:retweets')
return buildSearchQuery(parts.filter(Boolean).join(' '), false)
}
export function buildFilteredSearchQuery(
input: AdvancedSearchQuery,
following: boolean,
): string {
return buildSearchQuery(buildAdvancedSearchQuery(input), following)
}
export function normalizeTweetId(raw: string): string {
const value = raw.trim()
if (!TWEET_ID.test(value)) {
@@ -129,8 +224,10 @@ export function buildSearchQuery(raw: string, following: boolean): string {
if (!query) {
throw new InputError('検索語を入力してください。')
}
if (query.length > 512) {
const finalQuery =
following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
if (finalQuery.length > 512) {
throw new InputError('検索語は 512 文字以内で入力してください。')
}
return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
return finalQuery
}
+30 -1
View File
@@ -157,13 +157,42 @@ describe('post feed route wiring', () => {
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'search',
query: 'AI lang:ja',
query: 'AI lang:ja filter:follows',
product: 'Top',
following: true,
}),
)
})
it('compiles button-selected search conditions from the URL', async () => {
await renderRoute(
'/search?q=TypeScript&from=%40tan_stack&since=2026-08-04&until=2026-08-11&lang=ja&content=images&excludeReplies=true&excludeReposts=true&product=Latest&following=false',
)
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'search',
query:
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
product: 'Latest',
following: false,
}),
)
expect(
screen.getByRole('button', { name: '投稿者 @tan_stack を解除' }),
).toBeVisible()
})
it('keeps contradictory date parameters local and idle', async () => {
await renderRoute(
'/search?q=TypeScript&since=2026-08-11&until=2026-08-10&product=Latest&following=false',
)
fireEvent.click(screen.getByRole('button', { name: '期間' }))
expect(await screen.findByLabelText('開始日')).toHaveValue('2026-08-11')
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a decimal status path into a deliberate thread request', async () => {
await renderRoute('/yuta/status/123')
+44 -8
View File
@@ -1,34 +1,70 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import { SearchForm } from '#/features/posts/components/search-form'
import { postSearchRouteSchema } from '#/features/posts/inputs'
import {
SearchForm,
type SearchValues,
} from '#/features/posts/components/search-form'
import {
buildFilteredSearchQuery,
postSearchRouteSchema,
} from '#/features/posts/inputs'
export const Route = createFileRoute('/search')({
validateSearch: (search) => postSearchRouteSchema.parse(search),
component: SearchRoute,
})
function compileQuery(values: SearchValues): string | undefined {
if (!values.q && !values.from) return undefined
try {
return buildFilteredSearchQuery(values, values.following)
} catch {
return undefined
}
}
function SearchRoute() {
const values = Route.useSearch()
const navigate = Route.useNavigate()
const formValues = {
...values,
from: values.from ?? '',
since: values.since ?? '',
until: values.until ?? '',
lang: values.lang ?? 'all',
content: values.content ?? 'all',
excludeReplies: values.excludeReplies ?? false,
excludeReposts: values.excludeReposts ?? false,
} satisfies SearchValues
const query = compileQuery(formValues)
const formKey = [
formValues.q,
formValues.from,
formValues.since,
formValues.until,
formValues.lang,
formValues.content,
formValues.excludeReplies,
formValues.excludeReposts,
formValues.product,
formValues.following,
].join(':')
return (
<AppShell active="search">
<h1>何を確認しますか?</h1>
<p className="intro">入力した条件だけを検索します。</p>
<SearchForm
initialFollowing={values.following}
initialProduct={values.product}
initialQuery={values.q}
key={`${values.q}:${values.product}:${values.following}`}
initialValues={formValues}
key={formKey}
onSubmit={(search) => navigate({ search })}
/>
<PostFeed
request={
values.q
query
? {
kind: 'search',
query: values.q,
query,
product: values.product,
following: values.following,
}
+190
View File
@@ -805,6 +805,7 @@ video.media {
letter-spacing: 0;
}
.field > label:first-child,
.field > span:first-child {
margin-bottom: var(--space-xs);
}
@@ -945,6 +946,175 @@ button[data-state="success"] {
white-space: nowrap;
}
/* Hallmark · component: search filter builder · genre: editorial · theme: Garden
* states: default · hover · focus · active · disabled · loading · error · success
* contrast: pass (40–41)
*/
.search-query,
.search-filter-buttons,
.search-filter-panel,
.active-filters,
.search-query-preview,
.search-error {
grid-column: 1 / -1;
}
.field-help {
min-height: 1lh;
margin-top: var(--space-2xs);
color: var(--secondary);
font-size: var(--text-sm);
font-weight: 400;
}
.search-filter-buttons,
.active-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.search-filter-buttons {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.search-filter-buttons legend,
.search-filter-panel > legend {
width: 100%;
margin-bottom: var(--space-xs);
color: var(--secondary);
font-size: var(--text-sm);
font-weight: 700;
}
.search-filter-buttons .filter-button,
.active-filters button,
.search-filter-options > button {
min-height: 2.75rem;
padding: var(--space-xs) var(--space-sm);
border-color: var(--control-border);
background: var(--paper);
color: var(--ink);
font-size: var(--text-sm);
}
.search-filter-buttons .filter-button {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.search-filter-buttons .filter-button span {
color: var(--accent);
font-size: var(--text-xs);
}
.search-filter-buttons .filter-button[aria-expanded="true"],
.search-filter-buttons .filter-button[data-active="true"],
.active-filters button {
border-color: var(--accent);
}
.search-filter-panel {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-md);
min-width: 0;
margin: 0;
padding: var(--space-md);
border-block: var(--rule-hairline) solid var(--border);
background: var(--color-paper-3);
color: var(--ink);
}
.search-filter-panel .field input,
.search-filter-panel .segmented label,
.search-filter-panel button {
background: var(--paper);
}
.compact-field {
width: 100%;
}
.date-fields {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-md);
min-width: 0;
}
.search-filter-options,
.search-exclusions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.search-filter-options legend,
.search-exclusions legend {
width: 100%;
margin-bottom: var(--space-xs);
color: var(--secondary);
font-size: var(--text-sm);
font-weight: 700;
}
.active-filters {
min-height: 0;
margin: 0;
padding: 0;
list-style: none;
}
.active-filters:empty {
display: none;
}
.active-filters button {
color: var(--accent);
}
.search-query-preview {
min-width: 0;
color: var(--secondary);
font-size: var(--text-sm);
}
.search-query-preview summary {
min-height: 2.75rem;
width: fit-content;
padding-block: var(--space-sm);
cursor: pointer;
color: var(--accent);
font-weight: 700;
white-space: nowrap;
}
.search-query-preview code {
display: block;
max-width: 100%;
padding: var(--space-sm) var(--space-md);
overflow-wrap: anywhere;
border-left: var(--rule-active) solid var(--border);
background: var(--paper);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
}
.search-error {
margin: 0;
}
.list-picker {
margin-bottom: var(--space-xl);
padding: var(--space-lg);
@@ -1150,6 +1320,18 @@ button[data-state="success"] {
color: var(--color-accent-ink);
}
.search-filter-buttons .filter-button:hover,
.active-filters button:hover,
.search-filter-options > button:hover {
border-color: var(--accent);
background: var(--surface);
color: var(--ink);
}
.active-filters button:hover {
color: var(--accent-hover);
}
.list-choices a:hover {
border-color: var(--border);
background: var(--color-paper-3);
@@ -1170,6 +1352,14 @@ button[data-state="success"] {
grid-column: 1 / -1;
}
.search-form .compact-field {
grid-column: auto;
}
.date-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.intent-form:not(:has(.segmented)) > button {
align-self: start;
margin-top: calc(1lh + var(--space-xs));
+1
View File
@@ -22,6 +22,7 @@
"YuMincho", "Hiragino Mincho ProN", "Noto Serif JP", ui-serif, serif;
--font-body:
"Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", ui-sans-serif, sans-serif;
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
--space-3xs: 0.125rem;
--space-2xs: 0.25rem;