/** * `ExplorerView` — the shared current-directory explorer every entity screen * composes: a breadcrumb over a flat {@link Folder} tree, optional search + tag * chips, a list/card toggle, and a body in which FOLDERS are first-class entries * sorted above the items (folder rows inside the same table, folder cards inside * the same grid). Fully controlled and data-agnostic — the caller owns the item * shape via `getItemKey`/`getFolderId`/`renderRow`/`renderCard`, and keeps folder * dir / tags / search in the URL or in local state; only the view mode persists * internally (per `viewSurface`). * * The tag glue lives in `explorer-tags` as exported helpers ({@link buildTagVocabulary}, * {@link matchesSelectedTags}, {@link UNTAGGED_TOKEN}) so every consuming screen * shares one vocabulary/untagged-sentinel/OR-match rule rather than copying it. */ import { type ReactNode } from 'react'; import { type Folder } from './folder-nav'; export type { TagVocabularyEntry } from './explorer-tags'; export { buildTagVocabulary, matchesSelectedTags, UNTAGGED_TOKEN } from './explorer-tags'; /** One header cell of the table view. `renderRow` must emit exactly this many * ``s per item; folder rows span all of them. */ export interface ExplorerColumn { readonly key: string; readonly header: ReactNode; readonly numeric?: boolean; } /** Enables the tag vocabulary, filter chips, and OR filter. Fully controlled. */ export interface ExplorerTags { readonly getTags: (item: T) => readonly string[]; readonly selected: readonly string[]; readonly onChange: (selected: readonly string[]) => void; /** The untagged pseudo-tag's visible label (e.g. "Untagged"). */ readonly untaggedLabel: string; /** The chip group's accessible name (e.g. "Filter tools by tag"). */ readonly filterLabel: string; /** Collapse unselected chips beyond this count into a static "+N more"; unset * shows every chip. Selected chips always show. */ readonly maxVisibleTags?: number; } /** Controlled free-text search. `matches` owns the item shape and casing; it is * called only with a non-empty, already-trimmed query. */ export interface ExplorerSearch { readonly value: string; readonly onChange: (value: string) => void; readonly matches: (item: T, query: string) => boolean; /** The input's accessible name. */ readonly label: string; readonly placeholder?: string; } export interface ExplorerEmptyState { readonly title: string; readonly description?: string; readonly action?: ReactNode; } /** The folder-aware empty-state ladder — every label caller-supplied. */ export interface ExplorerEmptyStates { /** No items and no folders exist at all. */ readonly empty: ExplorerEmptyState; /** The current folder holds nothing — no items, no subfolders — yet the * entity set is not globally empty. */ readonly emptyFolder: ExplorerEmptyState; /** The current folder has items but the active tag/search filter excludes * every one, and it has no subfolders to show. */ readonly noMatch: ExplorerEmptyState; } export interface ExplorerViewProps { readonly items: readonly T[]; readonly getItemKey: (item: T) => string; /** The folder an item is filed in (`null` = the root directory). */ readonly getFolderId: (item: T) => string | null; readonly folders: readonly Folder[]; readonly currentFolderId: string | null; readonly onNavigate: (folderId: string | null) => void; readonly rootLabel: string; /** Persist + drive the list/card toggle under this surface key. */ readonly viewSurface: string; /** The card grid's accessible name; the toggle's is "