Extract title tags & per-row chevron expand

Add parsing and UX for bracketed title metadata and per-row title expansion. Introduces extractTitleTags (src/lib/utils/title-tags.ts) to pull bracketed tags from result titles (de-duplicated, slash-split) and useIsTruncated (src/lib/hooks/useIsTruncated.ts) to detect horizontal overflow. Refactors InteractiveTorrentSearchModal to a ResultRow component that renders title chips (slate chips) for parsed tags (filtered vs displayFormat), shows a chevron disclosure only when the title is truncated (or while expanded), toggles expansion per GUID, and resets expansion state when the modal closes. Tests added/updated for the component, hook, and parser; documentation updated to reflect behavior.
This commit is contained in:
kikootwo
2026-05-16 10:41:44 -04:00
parent 31d30bdfa0
commit 1065577a04
7 changed files with 619 additions and 120 deletions
@@ -18,6 +18,7 @@ const selectEbookMock = vi.hoisted(() => vi.fn());
const searchEbooksByAsinMock = vi.hoisted(() => vi.fn());
const selectEbookByAsinMock = vi.hoisted(() => vi.fn());
const replaceWithTorrentMock = vi.hoisted(() => vi.fn());
const useIsTruncatedMock = vi.hoisted(() => vi.fn(() => false));
vi.mock('@/lib/hooks/useReportedIssues', () => ({
useReplaceWithTorrent: () => ({
@@ -27,6 +28,10 @@ vi.mock('@/lib/hooks/useReportedIssues', () => ({
}),
}));
vi.mock('@/lib/hooks/useIsTruncated', () => ({
useIsTruncated: useIsTruncatedMock,
}));
vi.mock('@/lib/hooks/useRequests', () => ({
useInteractiveSearch: () => ({
searchTorrents: searchByRequestMock,
@@ -172,4 +177,190 @@ describe('InteractiveTorrentSearchModal', () => {
expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title');
});
});
describe('title chips and chevron expand', () => {
const renderWithResults = async (results: any[]) => {
searchByRequestMock.mockResolvedValueOnce(results);
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
const utils = render(
<InteractiveTorrentSearchModal
isOpen={true}
onClose={vi.fn()}
requestId="req-chip"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
await waitFor(() => {
expect(searchByRequestMock).toHaveBeenCalled();
});
return utils;
};
it('renders the title verbatim regardless of bracketed metadata', async () => {
await renderWithResults([
{ ...baseResult, guid: 'verbatim', title: 'Foundation [German] [Unabridged]' },
]);
const link = await screen.findByRole('link', { name: 'Foundation [German] [Unabridged]' });
expect(link.textContent).toBe('Foundation [German] [Unabridged]');
expect(link).toHaveAttribute('aria-label', 'Foundation [German] [Unabridged]');
expect(link).toHaveAttribute('title', 'Foundation [German] [Unabridged]');
});
it('renders no chips when the title has no brackets', async () => {
await renderWithResults([
{ ...baseResult, guid: 'no-brackets', title: 'Plain Title', format: undefined },
]);
await screen.findByRole('link', { name: 'Plain Title' });
// Slate-toned chip class is unique to title-tag chips
expect(document.querySelectorAll('span.bg-slate-100').length).toBe(0);
});
it('renders a slate chip for each bracketed tag', async () => {
await renderWithResults([
{ ...baseResult, guid: 'multi', title: 'Foundation [German] [Unabridged]', format: 'M4B' },
]);
await screen.findByRole('link', { name: 'Foundation [German] [Unabridged]' });
const german = screen.getByText('German');
const unabridged = screen.getByText('Unabridged');
expect(german.className).toMatch(/bg-slate-100/);
expect(unabridged.className).toMatch(/bg-slate-100/);
});
it('filters a tag that matches displayFormat case-insensitively', async () => {
await renderWithResults([
{ ...baseResult, guid: 'dedupe', title: 'Foundation [MP3]', format: 'mp3' },
]);
await screen.findByRole('link', { name: 'Foundation [MP3]' });
// The purple format pill renders the format (uppercased by CSS, raw text retained)
expect(screen.getByText('mp3')).toBeInTheDocument();
// No duplicate slate chip for MP3
expect(document.querySelectorAll('span.bg-slate-100').length).toBe(0);
});
it('does not render the chevron when the title fits', async () => {
useIsTruncatedMock.mockReturnValue(false);
await renderWithResults([
{ ...baseResult, guid: 'fits', title: 'Foundation [German]' },
]);
await screen.findByRole('link', { name: 'Foundation [German]' });
expect(screen.queryByRole('button', { name: /show full title|hide full title/i })).not.toBeInTheDocument();
});
it('renders the chevron when the title is truncated', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'truncated', title: 'A Very Long Title That Overflows [German]' },
]);
await screen.findByRole('link', { name: 'A Very Long Title That Overflows [German]' });
const chevron = screen.getByRole('button', { name: 'Show full title' });
expect(chevron).toHaveAttribute('aria-expanded', 'false');
});
it('toggles expansion when the chevron is clicked and keeps it visible while expanded', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'toggle', title: 'A Very Long Title That Overflows [German]' },
]);
const link = await screen.findByRole('link', { name: 'A Very Long Title That Overflows [German]' });
expect(link.className).toMatch(/truncate/);
expect(link.className).not.toMatch(/break-words/);
const chevron = screen.getByRole('button', { name: 'Show full title' });
fireEvent.click(chevron);
// After expand, the hook may report not-truncated; chevron must stay visible.
useIsTruncatedMock.mockReturnValue(false);
const collapse = screen.getByRole('button', { name: 'Hide full title' });
expect(collapse).toHaveAttribute('aria-expanded', 'true');
expect(link.className).toMatch(/break-words/);
expect(link.className).not.toMatch(/truncate/);
fireEvent.click(collapse);
expect(screen.queryByRole('button', { name: 'Hide full title' })).not.toBeInTheDocument();
});
it('expands rows independently', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'row-a', title: 'A Title That Overflows [German]' },
{ ...baseResult, guid: 'row-b', title: 'B Title That Overflows [Spanish]' },
]);
await screen.findByRole('link', { name: 'A Title That Overflows [German]' });
const chevrons = screen.getAllByRole('button', { name: 'Show full title' });
expect(chevrons.length).toBe(2);
fireEvent.click(chevrons[0]);
expect(screen.getAllByRole('button', { name: 'Hide full title' }).length).toBe(1);
expect(screen.getAllByRole('button', { name: 'Show full title' }).length).toBe(1);
});
it('clicking the title link does not toggle expansion', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'link-click', title: 'A Very Long Title [German]' },
]);
const link = await screen.findByRole('link', { name: 'A Very Long Title [German]' });
expect(link).toHaveAttribute('href', 'https://example.com/torrent');
fireEvent.click(link);
expect(screen.getByRole('button', { name: 'Show full title' })).toHaveAttribute('aria-expanded', 'false');
});
it('falls back gracefully on malformed brackets without crashing', async () => {
useIsTruncatedMock.mockReturnValue(false);
await renderWithResults([
{ ...baseResult, guid: 'malformed', title: 'Foundation [unclosed' },
]);
const link = await screen.findByRole('link', { name: 'Foundation [unclosed' });
expect(link.textContent).toBe('Foundation [unclosed');
});
it('resets expansion state when the modal closes and reopens', async () => {
useIsTruncatedMock.mockReturnValue(true);
searchByRequestMock.mockResolvedValueOnce([
{ ...baseResult, guid: 'reset', title: 'A Long Title That Overflows [German]' },
]);
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
const { rerender } = render(
<InteractiveTorrentSearchModal
isOpen={true}
onClose={vi.fn()}
requestId="req-reset"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
await screen.findByRole('link', { name: 'A Long Title That Overflows [German]' });
fireEvent.click(screen.getByRole('button', { name: 'Show full title' }));
expect(screen.getByRole('button', { name: 'Hide full title' })).toHaveAttribute('aria-expanded', 'true');
// Close
rerender(
<InteractiveTorrentSearchModal
isOpen={false}
onClose={vi.fn()}
requestId="req-reset"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
// Reopen — search runs again
searchByRequestMock.mockResolvedValueOnce([
{ ...baseResult, guid: 'reset', title: 'A Long Title That Overflows [German]' },
]);
rerender(
<InteractiveTorrentSearchModal
isOpen={true}
onClose={vi.fn()}
requestId="req-reset"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
await screen.findByRole('link', { name: 'A Long Title That Overflows [German]' });
expect(screen.getByRole('button', { name: 'Show full title' })).toHaveAttribute('aria-expanded', 'false');
});
});
});