diff --git a/README.md b/README.md index 7986c9a..efefd95 100644 --- a/README.md +++ b/README.md @@ -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=&product=&following=` 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. diff --git a/docs/superpowers/specs/2026-07-13-twitter-lite-design.md b/docs/superpowers/specs/2026-07-13-twitter-lite-design.md index 8bae653..680fe45 100644 --- a/docs/superpowers/specs/2026-07-13-twitter-lite-design.md +++ b/docs/superpowers/specs/2026-07-13-twitter-lite-design.md @@ -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. diff --git a/e2e/a11y/searchPage.test.ts b/e2e/a11y/searchPage.test.ts index 8a440f4..890a999 100644 --- a/e2e/a11y/searchPage.test.ts +++ b/e2e/a11y/searchPage.test.ts @@ -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([]) +}) diff --git a/e2e/integrations/reader.test.ts b/e2e/integrations/reader.test.ts index fd013fa..440bf47 100644 --- a/e2e/integrations/reader.test.ts +++ b/e2e/integrations/reader.test.ts @@ -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) diff --git a/src/features/posts/components/forms.test.tsx b/src/features/posts/components/forms.test.tsx index 798dd07..a31405d 100644 --- a/src/features/posts/components/forms.test.tsx +++ b/src/features/posts/components/forms.test.tsx @@ -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( - , - ) + render() expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument() }) it('submits the selected ranking and follows filter', () => { const onSubmit = vi.fn() - render( - , - ) + render() 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() + + 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( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' })) + fireEvent.click(screen.getByRole('button', { name: '検索' })) + + expect(onSubmit).toHaveBeenCalledWith({ + ...searchValues, + q: 'AI', + lang: 'ja', + }) + }) }) diff --git a/src/features/posts/components/search-form.tsx b/src/features/posts/components/search-form.tsx index 0a58a50..a5257b7 100644 --- a/src/features/posts/components/search-form.tsx +++ b/src/features/posts/components/search-form.tsx @@ -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() const [error, setError] = useState('') + const update = ( + 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 ( -
-