mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user