diff --git a/frontend/src/components/FilterBar.test.tsx b/frontend/src/components/FilterBar.test.tsx index df8f8e3..11fe302 100644 --- a/frontend/src/components/FilterBar.test.tsx +++ b/frontend/src/components/FilterBar.test.tsx @@ -34,6 +34,12 @@ function lastParams(): ListAssetsParams { return calls.length > 0 ? (calls[calls.length - 1][0] ?? {}) : {} } +/** Pick a tag through the autocomplete, the only way to add a tag filter. */ +async function addTag(user: ReturnType, name: string) { + await user.click(await screen.findByRole('combobox', { name: 'Filter by tag' })) + await user.click(await screen.findByRole('option', { name: new RegExp(name) })) +} + beforeEach(() => { list = vi .spyOn(assetsApi, 'list') @@ -64,19 +70,40 @@ describe('FilterBar', () => { const user = userEvent.setup() render() - await user.click(await screen.findByRole('button', { name: /NATO/ })) + await addTag(user, 'NATO') await waitFor(() => expect(lastParams().tag).toEqual(['NATO'])) - await user.click(screen.getByRole('button', { name: /archive/ })) + await addTag(user, 'archive') await waitFor(() => expect(lastParams().tag).toEqual(['NATO', 'archive'])) }) + it('narrows the tag menu as you type, and Enter picks the highlighted tag', async () => { + const user = userEvent.setup() + render() + + const input = await screen.findByRole('combobox', { name: 'Filter by tag' }) + await user.type(input, 'arch') + expect(screen.queryByRole('option', { name: /NATO/ })).not.toBeInTheDocument() + + await user.keyboard('{Enter}') + await waitFor(() => expect(lastParams().tag).toEqual(['archive'])) + }) + + it('never commits free text that is not an existing tag', async () => { + const user = userEvent.setup() + render() + + const input = await screen.findByRole('combobox', { name: 'Filter by tag' }) + await user.type(input, 'nonexistent{Enter}') + expect(lastParams().tag).toBeUndefined() + }) + it('clears one filter from its chip without touching the others', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Video' })) - await user.click(await screen.findByRole('button', { name: /NATO/ })) + await addTag(user, 'NATO') await waitFor(() => expect(lastParams().tag).toEqual(['NATO'])) await user.click(screen.getByRole('button', { name: 'Clear NATO' })) @@ -90,7 +117,7 @@ describe('FilterBar', () => { render() await user.click(screen.getByRole('button', { name: 'Video' })) - await user.click(await screen.findByRole('button', { name: /NATO/ })) + await addTag(user, 'NATO') await waitFor(() => expect(lastParams().tag).toEqual(['NATO'])) await user.click(screen.getByRole('button', { name: 'Clear all' })) diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx index 8630375..26165f9 100644 --- a/frontend/src/components/FilterBar.tsx +++ b/frontend/src/components/FilterBar.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react' import { Search, SlidersHorizontal, X } from 'lucide-react' import { buildCategoryTree, type CategoryNode } from '@/api/tags' -import TypeFilterChips, { Chip } from '@/components/TypeFilterChips' +import TagFilterInput from '@/components/TagFilterInput' +import TypeFilterChips from '@/components/TypeFilterChips' import { useLibraryStore } from '@/stores/library' import { useTagStore } from '@/stores/tags' import { formatDuration } from '@/utils/format' @@ -188,21 +189,7 @@ export default function FilterBar() { {tags.length > 0 && ( -
- Tags - {tags.slice(0, 12).map((tag) => ( - t.toLowerCase() === tag.name.toLowerCase())} - onClick={() => toggleTag(tag.name)} - > - {tag.name} - {tag.asset_count !== undefined && ( - {tag.asset_count} - )} - - ))} -
+ )} {showMore && ( diff --git a/frontend/src/components/TagFilterInput.tsx b/frontend/src/components/TagFilterInput.tsx new file mode 100644 index 0000000..29e0e22 --- /dev/null +++ b/frontend/src/components/TagFilterInput.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from 'react' +import type { Tag } from '@/api/tags' + +/** + * An autocomplete for the tag *filter*. + * + * This replaced a row of chips cut off at the first dozen tags, which made every tag + * past the cut unfilterable-by-click. It differs from `TagInput` on purpose: that one + * creates tags by name, while a filter on a name that does not exist can only ever show + * an empty grid — so here Enter picks from the menu and free text is never committed. + * Chosen tags are shown by FilterBar's active-chip row, so they are only hidden from + * the menu rather than rendered again here. + */ + +interface Props { + tags: Tag[] + /** Names currently filtered on; compared case-insensitively, as the server does. */ + selected: string[] + onPick: (name: string) => void +} + +// The menu scrolls, so this is a guard against rendering thousands of rows, not the +// limit the old chip row was. +const MAX_OPTIONS = 50 + +const LISTBOX_ID = 'tag-filter-suggestions' + +export default function TagFilterInput({ tags, selected, onPick }: Props) { + const [draft, setDraft] = useState('') + const [open, setOpen] = useState(false) + const [active, setActive] = useState(0) + + const matches = useMemo(() => { + const term = draft.trim().toLowerCase() + const chosen = new Set(selected.map((name) => name.toLowerCase())) + return tags + .filter((tag) => !chosen.has(tag.name.toLowerCase())) + .filter((tag) => !term || tag.name.toLowerCase().includes(term)) + .slice(0, MAX_OPTIONS) + }, [tags, selected, draft]) + + const pick = (name: string) => { + onPick(name) + setDraft('') + setActive(0) + } + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setOpen(true) + setActive((i) => Math.min(i + 1, matches.length - 1)) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setActive((i) => Math.max(i - 1, 0)) + } else if (event.key === 'Enter') { + event.preventDefault() + const match = matches[active] + if (open && match) pick(match.name) + } else if (event.key === 'Escape') { + setOpen(false) + } + } + + return ( +
+ 0} + aria-controls={LISTBOX_ID} + aria-autocomplete="list" + aria-label="Filter by tag" + className="input py-1 text-xs" + placeholder="Filter by tag…" + value={draft} + onChange={(e) => { + setDraft(e.target.value) + setOpen(true) + setActive(0) + }} + onFocus={() => setOpen(true)} + // A timeout, not a plain close: clicking an option blurs the input first, and + // unmounting the menu synchronously would eat the click. + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + onKeyDown={onKeyDown} + /> + + {open && matches.length > 0 && ( +
    + {matches.map((tag, index) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/views/LibraryView.test.tsx b/frontend/src/views/LibraryView.test.tsx index 31ade18..fa30352 100644 --- a/frontend/src/views/LibraryView.test.tsx +++ b/frontend/src/views/LibraryView.test.tsx @@ -633,3 +633,45 @@ describe('LibraryView generations (M8)', () => { expect(screen.getByRole('form', { name: 'Generate an image' })).toBeVisible() }) }) + +describe('LibraryView paging', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches the next page once the first has rendered', async () => { + /** + * The sentinel is not in the DOM while the first page loads, so an observer attached + * only on mount never saw it and the library stopped at page one. This observer + * reports intersecting as soon as it is given something to watch, which is what a + * real one does for an element already on screen. + */ + class VisibleObserver { + constructor(private readonly callback: IntersectionObserverCallback) {} + observe() { + this.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + this as unknown as IntersectionObserver + ) + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('IntersectionObserver', VisibleObserver) + + const list = vi.spyOn(assetsApi, 'list').mockImplementation(async (params) => { + const offset = params?.offset ?? 0 + return { + data: offset === 0 ? ASSETS.slice(0, 2) : ASSETS.slice(2), + total: ASSETS.length, + limit: 60, + offset, + } + }) + + render() + + expect(await screen.findByText('Third')).toBeInTheDocument() + expect(list).toHaveBeenCalledWith(expect.objectContaining({ offset: 2 })) + }) +}) diff --git a/frontend/src/views/LibraryView.tsx b/frontend/src/views/LibraryView.tsx index 9c8c3b8..9a1a487 100644 --- a/frontend/src/views/LibraryView.tsx +++ b/frontend/src/views/LibraryView.tsx @@ -68,6 +68,15 @@ export default function LibraryView() { // Infinite scroll. An observer rather than a scroll handler: it does not fire on // every pixel, and it keeps working if the grid is put in a different container. + // + // Keyed on `loading` and the loaded count, not just `loadMore`. The sentinel only + // exists once the first page is in, so an effect that ran once on mount found no + // element and never attached — the grid stopped at page one for good. Re-attaching + // after each page matters for the same reason: an observer reports on *changes* in + // visibility, so a sentinel that is still on screen after a page lands (a tall + // window, small cards) would never ask for the next one. A fresh observer always + // reports its initial state. + const loadedCount = assets.length useEffect(() => { const sentinel = sentinelRef.current if (!sentinel) return @@ -82,7 +91,7 @@ export default function LibraryView() { ) observer.observe(sentinel) return () => observer.disconnect() - }, [loadMore]) + }, [loadMore, loading, loadedCount]) // A URL import or a generation finishes in the background, so nothing else would put // what it made in the grid. Keyed on seeing a job go from running to finished rather