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 ## Scope
- User timelines and raw X search syntax - 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 - Popular (`Top`) and chronological (`Latest`) search
- Optional `filter:follows` search - Optional `filter:follows` search
- Authenticated account list selection and list timelines from a deliberate URL or ID - 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. - `/` shows the empty user input.
- `/:handle` shows a user timeline. - `/: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` shows the authenticated account's lists.
- `/i/lists/:listId` shows a list timeline. - `/i/lists/:listId` shows a list timeline.
- `/:handle/status/:tweetId` shows one selected post and its visible conversation. - `/: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 ### Search Flow
1. Trim and validate the raw query. 1. Keep the raw query and structured author, period, language, content, and
2. Append `filter:follows` when the following toggle is enabled and the operator is not already present. exclusion controls in typed URL search parameters.
3. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`. 2. Compile selected controls into a deterministic advanced search expression
4. Return normalized tweets and the next cursor. 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. 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; - no content on first load;
- results directly below the submitted controls. - 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 user target normalizes it to a handle and navigates to `/:handle`.
Submitting a search updates the route's typed search parameters. Changing tabs 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 target or query, following a post detail link, or manually opening a valid
status URL. status URL.
3. A valid handle or profile URL loads that user's posts. 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. 5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
6. Relay credentials and Bird remain server-only. 6. Relay credentials and Bird remain server-only.
7. No excluded discovery or mutation feature is reachable. 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() const result = await a11y().analyze()
expect(result.violations).toEqual([]) 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.page.getByRole('link', { name: 'リスト' }))
await searchPage.tabTo(searchPage.queryInputLocator) 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 expect(searchPage.latestRankingLocator).toBeChecked()
await searchPage.tabTo(searchPage.latestRankingLocator) await searchPage.tabTo(searchPage.latestRankingLocator)
await searchPage.tabTo(searchPage.followingOnlyLocator) await searchPage.tabTo(searchPage.followingOnlyLocator)
+72 -17
View File
@@ -4,6 +4,19 @@ import { ListForm } from './list-form'
import { SearchForm } from './search-form' import { SearchForm } from './search-form'
import { UserForm } from './user-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', () => { describe('UserForm', () => {
it('submits only after the user enters a target', () => { it('submits only after the user enters a target', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
@@ -58,40 +71,82 @@ describe('ListForm', () => {
describe('SearchForm', () => { describe('SearchForm', () => {
it('uses a labelled grouping for ranking controls', () => { it('uses a labelled grouping for ranking controls', () => {
render( render(<SearchForm initialValues={searchValues} onSubmit={vi.fn()} />)
<SearchForm
initialQuery=""
initialProduct="Latest"
initialFollowing={false}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument() expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument()
}) })
it('submits the selected ranking and follows filter', () => { it('submits the selected ranking and follows filter', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render( render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
<SearchForm
initialQuery=""
initialProduct="Latest"
initialFollowing={false}
onSubmit={onSubmit}
/>,
)
fireEvent.change(screen.getByLabelText('検索語'), { fireEvent.change(screen.getByLabelText('検索語'), {
target: { value: 'AI lang:ja' }, target: { value: ' AI lang:ja ' },
}) })
fireEvent.click(screen.getByLabelText('人気順')) fireEvent.click(screen.getByLabelText('人気順'))
fireEvent.click(screen.getByLabelText('フォロー中のみ')) fireEvent.click(screen.getByLabelText('フォロー中のみ'))
expect(screen.getByText('AI lang:ja filter:follows')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '検索' })) fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({ expect(onSubmit).toHaveBeenCalledWith({
q: 'AI lang:ja', q: 'AI lang:ja',
from: '',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
product: 'Top', product: 'Top',
following: true, 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 { SearchProduct } from '@yuta/bird'
import { type FormEvent, useState } from 'react' import { type FormEvent, useState } from 'react'
import { buildSearchQuery } from '../inputs' import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs'
type SearchValues = { export type SearchValues = AdvancedSearchQuery & {
q: string
product: SearchProduct product: SearchProduct
following: boolean following: boolean
} }
type Props = { type Props = {
initialQuery: string initialValues: SearchValues
initialProduct: SearchProduct
initialFollowing: boolean
onSubmit: (values: SearchValues) => void onSubmit: (values: SearchValues) => void
} }
export function SearchForm({ type FilterName = 'author' | 'period' | 'language' | 'content' | 'exclude'
initialQuery,
initialProduct, const languageLabels = { all: 'すべて', ja: '日本語', en: '英語' } as const
initialFollowing, const contentLabels = {
onSubmit, all: 'すべて',
}: Props) { images: '画像',
const [q, setQuery] = useState(initialQuery) videos: '動画',
const [product, setProduct] = useState(initialProduct) links: 'リンク',
const [following, setFollowing] = useState(initialFollowing) } 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 [error, setError] = useState('')
const update = <Key extends keyof SearchValues>(
key: Key,
value: SearchValues[Key],
) => setValues((current) => ({ ...current, [key]: value }))
const submit = (event: FormEvent) => { const submit = (event: FormEvent) => {
event.preventDefault() event.preventDefault()
const normalized = {
...values,
q: values.q.trim(),
from: values.from.trim(),
}
try { try {
const query = buildSearchQuery(q, false) buildFilteredSearchQuery(normalized, normalized.following)
setError('') setError('')
onSubmit({ q: query, product, following }) onSubmit(normalized)
} catch (cause) { } catch (cause) {
setError( setError(
cause instanceof Error ? cause.message : '入力内容を確認してください。', 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 ( return (
<form className="intent-form" onSubmit={submit}> <form className="intent-form search-form" onSubmit={submit}>
<label className="field"> <div className="field search-query">
<span>検索語</span> <label htmlFor="search-query">検索語</label>
<input <input
aria-describedby="search-query-help"
autoComplete="off" autoComplete="off"
aria-describedby={error ? 'query-error' : undefined} id="search-query"
aria-invalid={Boolean(error)}
name="q" name="q"
value={q} value={values.q}
onChange={(event) => setQuery(event.currentTarget.value)} onChange={(event) => update('q', event.currentTarget.value)}
placeholder="検索語や高度な検索演算子" placeholder="キーワードや高度な検索式"
/> />
{error ? ( <span className="field-help" id="search-query-help">
<span className="field-error" id="query-error" role="alert"> 演算子を直接入力することもできます。
{error} </span>
</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} ) : null}
</label> {values.since || values.until ? (
<fieldset className="segmented"> <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> <legend>並び順</legend>
{(['Top', 'Latest'] as const).map((value) => ( {(['Top', 'Latest'] as const).map((value) => (
<label key={value}> <label key={value}>
<input <input
checked={product === value} checked={values.product === value}
name="product" name="product"
onChange={() => setProduct(value)} onChange={() => update('product', value)}
type="radio" type="radio"
value={value} value={value}
/> />
@@ -73,15 +397,17 @@ export function SearchForm({
</label> </label>
))} ))}
</fieldset> </fieldset>
<label className="check"> <label className="check search-following">
<input <input
checked={following} checked={values.following}
onChange={(event) => setFollowing(event.currentTarget.checked)} onChange={(event) => update('following', event.currentTarget.checked)}
type="checkbox" type="checkbox"
/> />
フォロー中のみ フォロー中のみ
</label> </label>
<button type="submit">検索</button> <button className="search-submit" type="submit">
検索
</button>
</form> </form>
) )
} }
+134
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
buildAdvancedSearchQuery,
buildFilteredSearchQuery,
buildSearchQuery, buildSearchQuery,
normalizeListTarget, normalizeListTarget,
normalizeTweetId, normalizeTweetId,
normalizeUserTarget, normalizeUserTarget,
postSearchRouteSchema,
threadPageInputSchema, threadPageInputSchema,
} from './inputs' } from './inputs'
@@ -108,3 +111,134 @@ describe('buildSearchQuery', () => {
expect(() => buildSearchQuery(' ', false)).toThrow() 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' import { z } from 'zod'
const HANDLE = /^[A-Za-z0-9_]{1,15}$/ 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 FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const TWEET_ID = /^\d{1,32}$/ const TWEET_ID = /^\d{1,32}$/
const LIST_ID = /^\d{1,32}$/ const LIST_ID = /^\d{1,32}$/
@@ -11,6 +12,23 @@ const tweetIdSchema = z
export class InputError extends Error {} 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([ export const threadPageInputSchema = z.union([
z.object({ z.object({
tweetId: tweetIdSchema, tweetId: tweetIdSchema,
@@ -45,10 +63,87 @@ export const searchPageInputSchema = z.object({
export const postSearchRouteSchema = z.object({ export const postSearchRouteSchema = z.object({
q: z.string().catch(''), 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'), product: z.enum(['Top', 'Latest']).catch('Latest'),
following: z.boolean().catch(false), 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 { export function normalizeTweetId(raw: string): string {
const value = raw.trim() const value = raw.trim()
if (!TWEET_ID.test(value)) { if (!TWEET_ID.test(value)) {
@@ -129,8 +224,10 @@ export function buildSearchQuery(raw: string, following: boolean): string {
if (!query) { if (!query) {
throw new InputError('検索語を入力してください。') 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 文字以内で入力してください。') 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(() => await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({ expect(usePostFeed).toHaveBeenCalledWith({
kind: 'search', kind: 'search',
query: 'AI lang:ja', query: 'AI lang:ja filter:follows',
product: 'Top', product: 'Top',
following: true, 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 () => { it('turns a decimal status path into a deliberate thread request', async () => {
await renderRoute('/yuta/status/123') await renderRoute('/yuta/status/123')
+44 -8
View File
@@ -1,34 +1,70 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell' import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed' import { PostFeed } from '#/features/posts/components/post-feed'
import { SearchForm } from '#/features/posts/components/search-form' import {
import { postSearchRouteSchema } from '#/features/posts/inputs' SearchForm,
type SearchValues,
} from '#/features/posts/components/search-form'
import {
buildFilteredSearchQuery,
postSearchRouteSchema,
} from '#/features/posts/inputs'
export const Route = createFileRoute('/search')({ export const Route = createFileRoute('/search')({
validateSearch: (search) => postSearchRouteSchema.parse(search), validateSearch: (search) => postSearchRouteSchema.parse(search),
component: SearchRoute, 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() { function SearchRoute() {
const values = Route.useSearch() const values = Route.useSearch()
const navigate = Route.useNavigate() 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 ( return (
<AppShell active="search"> <AppShell active="search">
<h1>何を確認しますか?</h1> <h1>何を確認しますか?</h1>
<p className="intro">入力した条件だけを検索します。</p> <p className="intro">入力した条件だけを検索します。</p>
<SearchForm <SearchForm
initialFollowing={values.following} initialValues={formValues}
initialProduct={values.product} key={formKey}
initialQuery={values.q}
key={`${values.q}:${values.product}:${values.following}`}
onSubmit={(search) => navigate({ search })} onSubmit={(search) => navigate({ search })}
/> />
<PostFeed <PostFeed
request={ request={
values.q query
? { ? {
kind: 'search', kind: 'search',
query: values.q, query,
product: values.product, product: values.product,
following: values.following, following: values.following,
} }
+190
View File
@@ -805,6 +805,7 @@ video.media {
letter-spacing: 0; letter-spacing: 0;
} }
.field > label:first-child,
.field > span:first-child { .field > span:first-child {
margin-bottom: var(--space-xs); margin-bottom: var(--space-xs);
} }
@@ -945,6 +946,175 @@ button[data-state="success"] {
white-space: nowrap; 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 { .list-picker {
margin-bottom: var(--space-xl); margin-bottom: var(--space-xl);
padding: var(--space-lg); padding: var(--space-lg);
@@ -1150,6 +1320,18 @@ button[data-state="success"] {
color: var(--color-accent-ink); 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 { .list-choices a:hover {
border-color: var(--border); border-color: var(--border);
background: var(--color-paper-3); background: var(--color-paper-3);
@@ -1170,6 +1352,14 @@ button[data-state="success"] {
grid-column: 1 / -1; 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 { .intent-form:not(:has(.segmented)) > button {
align-self: start; align-self: start;
margin-top: calc(1lh + var(--space-xs)); margin-top: calc(1lh + var(--space-xs));
+1
View File
@@ -22,6 +22,7 @@
"YuMincho", "Hiragino Mincho ProN", "Noto Serif JP", ui-serif, serif; "YuMincho", "Hiragino Mincho ProN", "Noto Serif JP", ui-serif, serif;
--font-body: --font-body:
"Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", ui-sans-serif, sans-serif; "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-3xs: 0.125rem;
--space-2xs: 0.25rem; --space-2xs: 0.25rem;