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
+34
View File
@@ -0,0 +1,34 @@
/**
* Component: useIsTruncated Hook
* Documentation: documentation/frontend/components.md
*
* Returns whether the referenced element's content overflows horizontally
* (i.e. is being clipped by `truncate` / `overflow: hidden`). Used by the
* Interactive Search modal to render an expand-disclosure chevron only when
* the title is actually being cut off — keeping the row clean when there's
* nothing to disclose.
*/
import { useLayoutEffect, useState, type RefObject } from 'react';
export function useIsTruncated(ref: RefObject<HTMLElement | null>): boolean {
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
setIsTruncated(el.scrollWidth > el.clientWidth);
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [ref]);
return isTruncated;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Component: Title Tag Extraction Utility
* Documentation: documentation/frontend/components.md
*
* Pure parser used by the Interactive Search modal to split a result title
* into a "residual" string and a list of bracketed metadata tags. Brackets
* are ASCII `[`/`]` only (full-width `【】` is intentionally unsupported —
* Audible/indexer titles use ASCII in practice). Inner content is split on
* `/`, trimmed, empty segments dropped, then de-duplicated case-insensitively
* while preserving first-seen casing. The regex is non-nested by design:
* `Foundation [Edition [Deluxe]]` extracts `Deluxe` and leaves the outer
* `[Edition ]` in the residual. Accepted trade-off for v1 (rare).
*/
export interface TitleTags {
residual: string;
tags: string[];
}
// Why: character class excludes brackets so the regex never spans a nested
// pair — this is what makes the inner `[Deluxe]` win over the outer group.
const BRACKET_GROUP = /\[([^\[\]]*)\]/g;
export function extractTitleTags(title: string): TitleTags {
if (!title) return { residual: '', tags: [] };
const tags: string[] = [];
const seen = new Set<string>();
for (const match of title.matchAll(BRACKET_GROUP)) {
for (const segment of match[1].split('/')) {
const trimmed = segment.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
tags.push(trimmed);
}
}
const residual = title.replace(BRACKET_GROUP, ' ').replace(/\s+/g, ' ').trim();
return { residual, tags };
}