refactor(frontend): consolidate detail-navigation into a single module folder

Move the Prev/Position/Next toolbar and its supporting hooks from scattered
locations under components/shared/ and hooks/ into a single
components/shared/detail-navigation/ folder. Rename ListNavigation* →
DetailNavigation* (toolbar/buttons/sheet) so the names reflect what they
actually do — sibling navigation between detail pages, not navigation
within a list. The internal algorithmic hook drops its now-redundant
prefix (useFilteredListNavigation → useNavigation, computeListNavigation →
computeNavigation), and useDetailNavigation moves alongside the toolbar
since it's the public composition layer that produces toolbarProps.
This commit is contained in:
Sergey Kozyrenko
2026-05-15 09:33:12 +07:00
parent 21d61b37ee
commit ef0dbcf0e2
16 changed files with 147 additions and 142 deletions
+67 -66
View File
@@ -1,7 +1,7 @@
# Shared list/detail building blocks
This directory hosts the reusable surface for list-and-detail pages: a
filterable table, a Prev/Next/Sheet toolbar that walks the *same* filtered
filterable table, a Prev/Next/Sheet toolbar that walks the _same_ filtered
subset on detail pages, and the inline-rename + sortable-header primitives
that every list reuses.
@@ -18,10 +18,10 @@ that every list reuses.
usePagination │
│ │
▼ ▼
<DataTable> useFilteredListNavigation
<DataTable> useNavigation
(list page) │
│ ▼
▼ <ListNavigationToolbar>
▼ <DetailNavigationToolbar>
table_4_<path> (detail page)
in localStorage
(cold-start fallback)
@@ -33,90 +33,91 @@ that every list reuses.
into `localStorage` under `table_4_<path>`. The detail page never writes
storage and never replays storage into the URL — opening a shared link
shows exactly what the link says.
- **Prev/Next walks the same subset.** `ListNavigationToolbar` runs the
- **Prev/Next walks the same subset.** `DetailNavigationToolbar` runs the
same matcher (`createTextMatcher`) the list filter uses, so siblings stay
in lockstep with what the user sees in the table.
## Components
| File | Role |
| --------------------------------------------------------------- | -------------------------------------------------------------------------- |
| [`list-navigation-toolbar.tsx`](list-navigation-toolbar.tsx) | Prev / Position / Next cluster + sheet trigger on detail pages. |
| [`list-navigation-buttons.tsx`](list-navigation-buttons.tsx) | Presentation-only button trio; stateless. |
| [`list-navigation-sheet.tsx`](list-navigation-sheet.tsx) | WAI-ARIA listbox sheet — roving tabindex, focus reconciliation. |
| [`sortable-column-header.tsx`](sortable-column-header.tsx) | DRY header for TanStack columns — `none → asc → desc → none` cycle. |
| [`inline-rename-input.tsx`](inline-rename-input.tsx) | `<input>` + Save/Cancel addon with Enter/Escape keybindings. |
| File | Role |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| [`detail-navigation/`](detail-navigation/) | Prev / Position / Next toolbar + listbox sheet for detail pages, and the navigation hooks that feed it. |
| [`sortable-column-header.tsx`](sortable-column-header.tsx) | DRY header for TanStack columns — `none → asc → desc → none` cycle. |
| [`inline-rename-input.tsx`](inline-rename-input.tsx) | `<input>` + Save/Cancel addon with Enter/Escape keybindings. |
## Hooks (in `@/hooks/`)
## Hooks
| Hook | Source of truth | Writes? | Notes |
| ------------------------------- | --------------- | ------- | ------------------------------------------------------------------ |
| `useTableQueryFilter` | URL `?q=` | yes | List pages. Restores from `localStorage` on cold start. |
| `useTableQueryFilterReader` | URL `?q=` | no | Detail pages. Storage-blind — shared links never gain stale `?q=`. |
| `usePagination` | URL `?page=` | yes | Canonicalizes `?page=1` away so the URL has one form per view. |
| `useFilteredListNavigation` | props | no | Pure computation of Prev/Next around a `currentId`. |
| `useDetailNavigation` | URL + props | no | Bundles the three above into a single hook for detail pages. |
| `useInlineEditTitle` | local state | no | Edit-mode toggle + deferred focus (Radix dropdown race fix). |
| `usePageStorageKeys` | router | no | Resolves the three per-page storage keys reactively. |
| Hook | Where | Source of truth | Writes? | Notes |
| --------------------------- | ------------------------------- | --------------- | ------- | ------------------------------------------------------------------ |
| `useTableQueryFilter` | `@/hooks/` | URL `?q=` | yes | List pages. Restores from `localStorage` on cold start. |
| `useTableQueryFilterReader` | `@/hooks/` | URL `?q=` | no | Detail pages. Storage-blind — shared links never gain stale `?q=`. |
| `usePagination` | `@/hooks/` | URL `?page=` | yes | Canonicalizes `?page=1` away so the URL has one form per view. |
| `useNavigation` | `detail-navigation/` (internal) | props | no | Pure computation of Prev/Next around a `currentId`. |
| `useDetailNavigation` | `detail-navigation/` | URL + props | no | Bundles the three above into a single hook for detail pages. |
| `useInlineEditTitle` | `@/hooks/` | local state | no | Edit-mode toggle + deferred focus (Radix dropdown race fix). |
| `usePageStorageKeys` | `@/hooks/` | router | no | Resolves the three per-page storage keys reactively. |
## Library helpers (in `@/lib/`)
| Module | Purpose |
| ----------------------- | -------------------------------------------------------------------------------- |
| `table-filter.ts` | `createTextMatcher` — case + diacritic-insensitive substring matcher. |
| `table-state.ts` | Unified `table_4_<path>` JSON slot. Carries filter + sorting + columnVis + pageSize. |
| `table-sort.ts` | `cycleColumnSort` — pure none/asc/desc cycle for TanStack columns. |
| `view-options-storage.ts` | `viewOptions_4_<path>` for FileManager-style screens (folders-first, etc.). |
| `storage-keys.ts` | Single source of truth for storage-key conventions and `getTopLevelPath`. |
| `url-params.ts` | `URL_PARAMS` constants + `mergeHrefWithSearchParams` (preserves hash on merge). |
| Module | Purpose |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `table-filter.ts` | `createTextMatcher` — case + diacritic-insensitive substring matcher. |
| `table-state.ts` | Unified `table_4_<path>` JSON slot. Carries filter + sorting + columnVis + pageSize. |
| `table-sort.ts` | `cycleColumnSort` — pure none/asc/desc cycle for TanStack columns. |
| `view-options-storage.ts` | `viewOptions_4_<path>` for FileManager-style screens (folders-first, etc.). |
| `storage-keys.ts` | Single source of truth for storage-key conventions and `getTopLevelPath`. |
| `url-params.ts` | `URL_PARAMS` constants + `mergeHrefWithSearchParams` (preserves hash on merge). |
## How to add a new list + detail pair
1. **List page** (`/<entities>/`):
```tsx
const { filter, setFilter } = useTableQueryFilter();
const { pageIndex, setPage } = usePagination();
return (
<DataTable
columns={columns /* use <SortableColumnHeader column={column} label="..." /> */}
data={entities}
filterColumn="title"
filterValue={filter}
onFilterChange={setFilter}
onPageChange={setPage}
pageIndex={pageIndex}
/>
);
```
```tsx
const { filter, setFilter } = useTableQueryFilter();
const { pageIndex, setPage } = usePagination();
return (
<DataTable
columns={columns /* use <SortableColumnHeader column={column} label="..." /> */}
data={entities}
filterColumn="title"
filterValue={filter}
onFilterChange={setFilter}
onPageChange={setPage}
pageIndex={pageIndex}
/>
);
```
2. **Feature-scoped navigation hook** (`@/features/<entity>/use-<entity>-detail-navigation.ts`):
```ts
const getLabel = (item: Entity) => item.title;
const getHref = (item: Entity) => `/<entities>/${item.id}`;
export const useEntityDetailNavigation = (currentId: null | string | undefined) => {
const { entities } = useEntities();
```ts
const getLabel = (item: Entity) => item.title;
const getHref = (item: Entity) => `/<entities>/${item.id}`;
return useDetailNavigation<Entity>({ currentId, getHref, getLabel, items: entities });
};
```
export const useEntityDetailNavigation = (currentId: null | string | undefined) => {
const { entities } = useEntities();
return useDetailNavigation<Entity>({ currentId, getHref, getLabel, items: entities });
};
```
3. **Detail page** (`/<entities>/:id`):
```tsx
const { toolbarProps } = useEntityDetailNavigation(entityId);
return (
<header>
<ListNavigationToolbar<Entity>
{...toolbarProps}
sheetIcon={<Icon className="size-4" />}
sheetTitle="Entities"
renderItem={(item, isCurrent) => <span>{item.title}</span>}
/>
</header>
);
```
```tsx
const { toolbarProps } = useEntityDetailNavigation(entityId);
return (
<header>
<DetailNavigationToolbar<Entity>
{...toolbarProps}
sheetIcon={<Icon className="size-4" />}
sheetTitle="Entities"
renderItem={(item, isCurrent) => <span>{item.title}</span>}
/>
</header>
);
```
## Why URL > storage
@@ -148,7 +149,7 @@ and deletes them.
`view-options-storage`, `url-params`, `table-sort`), the hook behaviours
(`use-pagination`, `use-table-query-filter`, `use-inline-edit-title`,
`use-page-storage-keys`, `use-detail-navigation`), and the components
(`list-navigation-toolbar`, `data-table`).
(`detail-navigation/`, `data-table`).
- jsdom doesn't ship `Element.prototype.scrollIntoView` or `ResizeObserver`
— both are polyfilled in `vitest.setup.ts`.
- React Testing Library auto-cleans the DOM after every test (see the same
@@ -3,7 +3,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface ListNavigationButtonsProps {
interface DetailNavigationButtonsProps {
/** Disable the position-button when the filtered subset is empty. */
hasEntries: boolean;
/** No next sibling — disable the right chevron. */
@@ -21,14 +21,14 @@ interface ListNavigationButtonsProps {
/**
* Prev / Position / Next button cluster for a detail page. Stateless and
* presentation-only `ListNavigationToolbar` owns the navigation logic
* presentation-only `DetailNavigationToolbar` owns the navigation logic
* and feeds the resolved indices, labels, and click handlers down.
*
* Kept separate from `ListNavigationSheet` so the same buttons could in
* Kept separate from `DetailNavigationSheet` so the same buttons could in
* principle be reused without the sheet (e.g. a future variant that ships
* keyboard-only navigation without an overlay).
*/
export const ListNavigationButtons = ({
export const DetailNavigationButtons = ({
hasEntries,
nextDisabled,
onNext,
@@ -37,7 +37,7 @@ export const ListNavigationButtons = ({
positionLabel,
prevDisabled,
sheetTitle,
}: ListNavigationButtonsProps) => {
}: DetailNavigationButtonsProps) => {
const lowerTitle = sheetTitle.toLowerCase();
return (
@@ -4,7 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
interface ListNavigationSheetProps<T> {
interface DetailNavigationSheetProps<T> {
currentId: null | string | undefined;
/**
* Pre-computed index of `currentId` inside `items` (or `-1` when the
@@ -37,7 +37,7 @@ interface ListNavigationSheetProps<T> {
* Initial focus on open targets the current entry (if it's part of the
* filtered subset) so users land oriented inside their own context.
*/
export function ListNavigationSheet<T>({
export function DetailNavigationSheet<T>({
currentId,
currentIndex,
getId,
@@ -50,7 +50,7 @@ export function ListNavigationSheet<T>({
sheetIcon,
sheetTitle,
total,
}: ListNavigationSheetProps<T>) {
}: DetailNavigationSheetProps<T>) {
const listRef = useRef<HTMLUListElement>(null);
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
const [focusedId, setFocusedId] = useState<null | string>(null);
@@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ListNavigationToolbar } from './list-navigation-toolbar';
import { DetailNavigationToolbar } from './detail-navigation-toolbar';
interface Item {
id: string;
@@ -52,7 +52,7 @@ const renderToolbar = (overrides: { currentId?: null | string; filter?: string;
);
return render(
<ListNavigationToolbar<Item>
<DetailNavigationToolbar<Item>
currentId={overrides.currentId ?? 'c'}
filter={overrides.filter ?? ''}
getHref={getHref}
@@ -65,7 +65,7 @@ const renderToolbar = (overrides: { currentId?: null | string; filter?: string;
);
};
describe('ListNavigationToolbar', () => {
describe('DetailNavigationToolbar', () => {
it('renders nothing when items list is empty', () => {
renderToolbar({ items: [] });
// The toolbar should not emit any of its own controls — the wrapper
@@ -298,7 +298,7 @@ describe('ListNavigationToolbar', () => {
});
});
describe('ListNavigationToolbar — predicate stability', () => {
describe('DetailNavigationToolbar — predicate stability', () => {
it('updates the filtered subset when the filter prop changes', async () => {
const user = userEvent.setup();
@@ -316,7 +316,7 @@ describe('ListNavigationToolbar — predicate stability', () => {
);
const { rerender } = render(
<ListNavigationToolbar<Item>
<DetailNavigationToolbar<Item>
currentId="a"
filter=""
getHref={getHref}
@@ -331,7 +331,7 @@ describe('ListNavigationToolbar — predicate stability', () => {
expect(screen.getByRole('button', { name: /1\/4/ })).toBeInTheDocument();
rerender(
<ListNavigationToolbar<Item>
<DetailNavigationToolbar<Item>
currentId="a"
filter="Alpha"
getHref={getHref}
@@ -1,12 +1,13 @@
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ListNavigationButtons } from '@/components/shared/list-navigation-buttons';
import { ListNavigationSheet } from '@/components/shared/list-navigation-sheet';
import { useFilteredListNavigation } from '@/hooks/use-filtered-list-navigation';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
export interface ListNavigationToolbarProps<T> {
import { DetailNavigationButtons } from './detail-navigation-buttons';
import { DetailNavigationSheet } from './detail-navigation-sheet';
import { useNavigation } from './use-navigation';
export interface DetailNavigationToolbarProps<T> {
currentId: null | string | undefined;
/**
* Current free-text filter the list page applies. The toolbar narrows the
@@ -56,7 +57,7 @@ export interface ListNavigationToolbarProps<T> {
* supports a free-text filter.
*
* - "Prev" / "Next" walk the same filtered subset the list page renders.
* - The middle button opens `<ListNavigationSheet>`, a listbox of every
* - The middle button opens `<DetailNavigationSheet>`, a listbox of every
* entry in the filtered subset with the current one highlighted the
* user can jump anywhere in one click.
* - When the current item is missing from the filtered subset (e.g. the
@@ -70,7 +71,7 @@ export interface ListNavigationToolbarProps<T> {
* Returns `null` when there are no items at all there's nothing useful to
* render and a "/0" disabled toolbar would just be visual noise.
*/
export function ListNavigationToolbar<T>({
export function DetailNavigationToolbar<T>({
currentId,
filter,
getHref,
@@ -82,7 +83,7 @@ export function ListNavigationToolbar<T>({
sheetIcon,
sheetTitle,
sortFn,
}: ListNavigationToolbarProps<T>) {
}: DetailNavigationToolbarProps<T>) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -92,7 +93,7 @@ export function ListNavigationToolbar<T>({
// data shape (`getSearchableText` / `getLabel`), never the comparison.
const haystack = getSearchableText ?? getLabel;
const { currentIndex, filteredItems, nextId, prevId, total } = useFilteredListNavigation({
const { currentIndex, filteredItems, nextId, prevId, total } = useNavigation({
currentId,
getId,
getSearchableText: haystack,
@@ -153,7 +154,7 @@ export function ListNavigationToolbar<T>({
return (
<>
<ListNavigationButtons
<DetailNavigationButtons
hasEntries={total > 0}
nextDisabled={!nextId}
onNext={goToNext}
@@ -163,7 +164,7 @@ export function ListNavigationToolbar<T>({
prevDisabled={!prevId}
sheetTitle={sheetTitle}
/>
<ListNavigationSheet<T>
<DetailNavigationSheet<T>
currentId={currentId}
currentIndex={currentIndex}
getId={getId}
@@ -0,0 +1,3 @@
export { DetailNavigationToolbar } from './detail-navigation-toolbar';
export type { DetailNavigationToolbarProps } from './detail-navigation-toolbar';
export { useDetailNavigation } from './use-detail-navigation';
@@ -1,21 +1,21 @@
import { useMemo } from 'react';
import type { ListNavigationToolbarProps } from '@/components/shared/list-navigation-toolbar';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
import { useTableQueryFilterReader } from '@/hooks/use-table-query-filter';
import type { DetailNavigationToolbarProps } from './detail-navigation-toolbar';
/**
* Data-only slice of `ListNavigationToolbarProps<T>`. Derived from the
* single source of truth in `list-navigation-toolbar.tsx` so a new required
* prop (or a rename) can't silently desync the two surfaces.
* Data-only slice of `DetailNavigationToolbarProps<T>`. Derived from the
* single source of truth in `detail-navigation-toolbar.tsx` so a new
* required prop (or a rename) can't silently desync the two surfaces.
*
* Presentation-only fields (`sheetTitle`, `sheetIcon`, `renderItem`,
* `sortFn`) are intentionally excluded those are supplied per-page at the
* call site of the toolbar, not by the navigation hook.
*/
type ListNavigationToolbarDataProps<T> = Pick<
ListNavigationToolbarProps<T>,
type DetailNavigationToolbarDataProps<T> = Pick<
DetailNavigationToolbarProps<T>,
'currentId' | 'filter' | 'getHref' | 'getId' | 'getLabel' | 'getSearchableText' | 'items'
>;
@@ -56,7 +56,7 @@ interface UseDetailNavigationOptions<T extends { id: string }> {
interface UseDetailNavigationResult<T extends { id: string }> {
debouncedFilter: string;
toolbarProps: ListNavigationToolbarDataProps<T>;
toolbarProps: DetailNavigationToolbarDataProps<T>;
}
// Module-level so the reference is stable across renders. Typed against
@@ -75,7 +75,7 @@ const defaultGetId = (item: { id: string }): string => item.id;
* (read-only the detail page never mutates the filter from here),
* 3. supplying a default `getId` (most domain types use a plain `id`).
*
* The returned `toolbarProps` plug straight into `<ListNavigationToolbar>`
* The returned `toolbarProps` plug straight into `<DetailNavigationToolbar>`
* callers add presentation-only props (`sheetTitle`, `renderItem`, etc.).
*
* Pass each callback through `useCallback`: the toolbar's filter memo relies
@@ -104,7 +104,7 @@ export const useDetailNavigation = <T extends { id: string }>({
// `toolbarProps` memo below would invalidate on every render.
const resolvedGetId = useMemo<(item: T) => string>(() => getId ?? defaultGetId, [getId]);
const toolbarProps = useMemo<ListNavigationToolbarDataProps<T>>(
const toolbarProps = useMemo<DetailNavigationToolbarDataProps<T>>(
() => ({
currentId,
filter: debouncedFilter,
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { computeListNavigation } from './use-filtered-list-navigation';
import { computeNavigation } from './use-navigation';
interface Row {
id: string;
@@ -17,9 +17,9 @@ const ROWS: readonly Row[] = [
{ id: 'd', title: 'Delta' },
] as const;
describe('computeListNavigation', () => {
describe('computeNavigation', () => {
it('returns prev/next neighbours for a middle item', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
@@ -32,7 +32,7 @@ describe('computeListNavigation', () => {
});
it('returns null prev for the first item', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'a',
getId,
items: ROWS,
@@ -44,7 +44,7 @@ describe('computeListNavigation', () => {
});
it('returns null next for the last item', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'd',
getId,
items: ROWS,
@@ -56,7 +56,7 @@ describe('computeListNavigation', () => {
});
it('reports currentIndex=-1 when the current item is missing from the filtered subset', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'zzz',
getId,
items: ROWS,
@@ -70,7 +70,7 @@ describe('computeListNavigation', () => {
});
it('reports currentIndex=-1 when currentId is null or undefined', () => {
const nullResult = computeListNavigation({
const nullResult = computeNavigation({
currentId: null,
getId,
items: ROWS,
@@ -80,7 +80,7 @@ describe('computeListNavigation', () => {
expect(nullResult.prevId).toBeNull();
expect(nullResult.nextId).toBeNull();
const undefinedResult = computeListNavigation({
const undefinedResult = computeNavigation({
currentId: undefined,
getId,
items: ROWS,
@@ -90,7 +90,7 @@ describe('computeListNavigation', () => {
});
it('honours `query` when narrowing the subset', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
@@ -107,7 +107,7 @@ describe('computeListNavigation', () => {
});
it('drops the current item from the result when it does not match the query', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'a',
getId,
getSearchableText: getTitle,
@@ -122,7 +122,7 @@ describe('computeListNavigation', () => {
});
it('treats an empty/undefined query as "no filter" even when getSearchableText is provided', () => {
const empty = computeListNavigation({
const empty = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
@@ -132,7 +132,7 @@ describe('computeListNavigation', () => {
expect(empty.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
const missing = computeListNavigation({
const missing = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
@@ -146,7 +146,7 @@ describe('computeListNavigation', () => {
// Documents the boundary: without a haystack accessor we cannot evaluate
// the query against rows, so we degrade to "no filter" instead of
// throwing — keeps the hook usable while a caller forgets to wire one up.
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
@@ -158,7 +158,7 @@ describe('computeListNavigation', () => {
it('preserves input order when no sortFn is provided', () => {
const reversed = [...ROWS].reverse();
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'c',
getId,
items: reversed,
@@ -171,7 +171,7 @@ describe('computeListNavigation', () => {
});
it('applies sortFn to the filtered subset', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'b',
getId,
items: ROWS,
@@ -185,7 +185,7 @@ describe('computeListNavigation', () => {
});
it('handles an empty items array', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'anything',
getId,
items: [],
@@ -199,7 +199,7 @@ describe('computeListNavigation', () => {
});
it('returns the current item when present', () => {
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
@@ -210,7 +210,7 @@ describe('computeListNavigation', () => {
it('does not mutate the input array even when sorting', () => {
const before = ROWS.map(getId);
computeListNavigation({
computeNavigation({
currentId: 'a',
getId,
items: ROWS,
@@ -226,7 +226,7 @@ describe('computeListNavigation', () => {
// must surface this as currentIndex=-1 / no neighbours so the UI
// disables Prev/Next rather than jumping to an unrelated row.
const trimmed = ROWS.filter((row) => row.id !== 'b');
const result = computeListNavigation({
const result = computeNavigation({
currentId: 'b',
getId,
items: trimmed,
@@ -246,7 +246,7 @@ describe('computeListNavigation', () => {
{ id: '3', title: 'resume' },
];
const result = computeListNavigation({
const result = computeNavigation({
currentId: '1',
getId,
getSearchableText: getTitle,
@@ -267,7 +267,7 @@ describe('computeListNavigation', () => {
];
const getNumericId = (row: NumRow) => String(row.id);
const result = computeListNavigation({
const result = computeNavigation({
currentId: '20',
getId: getNumericId,
items: rows,
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { createTextMatcher } from '@/lib/table-filter';
interface ListNavigationInput<T> {
interface NavigationInput<T> {
currentId: null | string | undefined;
getId: (item: T) => string;
/**
@@ -21,7 +21,7 @@ interface ListNavigationInput<T> {
sortFn?: (a: T, b: T) => number;
}
interface ListNavigationResult<T> {
interface NavigationResult<T> {
currentIndex: number;
currentItem: null | T;
filteredItems: readonly T[];
@@ -31,8 +31,8 @@ interface ListNavigationResult<T> {
}
/**
* Pure core of {@link useFilteredListNavigation}: filter, sort, and resolve
* Prev/Next around `currentId`. Exposed without React so the algorithm can be
* Pure core of {@link useNavigation}: filter, sort, and resolve Prev/Next
* around `currentId`. Exposed without React so the algorithm can be
* unit-tested directly the hook is a thin `useMemo` wrapper around this.
*
* The Map of `id → index` is built once per invocation so the `currentId`
@@ -40,14 +40,14 @@ interface ListNavigationResult<T> {
* intentionally not exposed callers should walk `filteredItems` or use the
* returned `prevId` / `nextId`.
*/
export const computeListNavigation = <T>({
export const computeNavigation = <T>({
currentId,
getId,
getSearchableText,
items,
query,
sortFn,
}: ListNavigationInput<T>): ListNavigationResult<T> => {
}: NavigationInput<T>): NavigationResult<T> => {
const hasQuery = query !== undefined && query.length > 0;
const filtered =
hasQuery && getSearchableText
@@ -89,7 +89,7 @@ export const computeListNavigation = <T>({
};
};
interface UseFilteredListNavigationOptions<T> {
interface UseNavigationOptions<T> {
currentId: null | string | undefined;
getId: (item: T) => string;
/**
@@ -108,7 +108,7 @@ interface UseFilteredListNavigationOptions<T> {
sortFn?: (a: T, b: T) => number;
}
type UseFilteredListNavigationResult<T> = ListNavigationResult<T>;
type UseNavigationResult<T> = NavigationResult<T>;
/**
* Resolve Prev/Next siblings for a detail page that's tied to a filtered list.
@@ -130,16 +130,16 @@ type UseFilteredListNavigationResult<T> = ListNavigationResult<T>;
* keystroke, and `getSearchableText` is naturally module-scoped at the
* feature level (see `use-flow-detail-navigation` etc.).
*/
export const useFilteredListNavigation = <T>({
export const useNavigation = <T>({
currentId,
getId,
getSearchableText,
items,
query,
sortFn,
}: UseFilteredListNavigationOptions<T>): UseFilteredListNavigationResult<T> => {
}: UseNavigationOptions<T>): UseNavigationResult<T> => {
return useMemo(
() => computeListNavigation({ currentId, getId, getSearchableText, items, query, sortFn }),
() => computeNavigation({ currentId, getId, getSearchableText, items, query, sortFn }),
[currentId, getId, getSearchableText, items, query, sortFn],
);
};
@@ -1,4 +1,4 @@
import { useDetailNavigation } from '@/hooks/use-detail-navigation';
import { useDetailNavigation } from '@/components/shared/detail-navigation';
import { type Flow, useFlows } from '@/providers/flows-provider';
const getLabel = (item: Flow) => item.title || `Flow #${item.id}`;
@@ -9,7 +9,7 @@ const getHref = (item: Flow) => `/flows/${item.id}`;
/**
* Detail-page navigation wired up for flows. Encapsulates the getter
* callbacks and the call to `useDetailNavigation` so each detail page just
* spreads the returned `toolbarProps` onto `<ListNavigationToolbar<Flow>>`
* spreads the returned `toolbarProps` onto `<DetailNavigationToolbar<Flow>>`
* and adds the presentation-only props (`sheetTitle`, `renderItem`, etc.).
*
* Pass `null` instead of an id while the page is in a non-viewing state
@@ -8,8 +8,8 @@ import { toast } from 'sonner';
import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DetailNavigationToolbar } from '@/components/shared/detail-navigation';
import { InlineRenameInput } from '@/components/shared/inline-rename-input';
import { ListNavigationToolbar } from '@/components/shared/list-navigation-toolbar';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
@@ -163,7 +163,7 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
{canShowActions && (
<ListNavigationToolbar<Knowledge>
<DetailNavigationToolbar<Knowledge>
{...knowledgeToolbarProps}
renderItem={(item, isCurrent) => (
<>
@@ -1,4 +1,4 @@
import { useDetailNavigation } from '@/hooks/use-detail-navigation';
import { useDetailNavigation } from '@/components/shared/detail-navigation';
import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider';
const getLabel = (item: Knowledge) => item.question;
@@ -1,4 +1,4 @@
import { useDetailNavigation } from '@/hooks/use-detail-navigation';
import { useDetailNavigation } from '@/components/shared/detail-navigation';
import { type Template, useTemplates } from '@/providers/templates-provider';
const getLabel = (item: Template) => item.title;
+2 -2
View File
@@ -20,9 +20,9 @@ import { toast } from 'sonner';
import { FlowStatusIcon } from '@/components/icons/flow-status-icon';
import { ProviderIcon } from '@/components/icons/provider-icon';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DetailNavigationToolbar } from '@/components/shared/detail-navigation';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineRenameInput } from '@/components/shared/inline-rename-input';
import { ListNavigationToolbar } from '@/components/shared/list-navigation-toolbar';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
@@ -338,7 +338,7 @@ const Flow = () => {
</div>
<div className="flex items-center gap-2">
{flow && (
<ListNavigationToolbar<FlowItem>
<DetailNavigationToolbar<FlowItem>
{...flowToolbarProps}
renderItem={(item, isCurrent) => (
<>
+2 -2
View File
@@ -18,8 +18,8 @@ import { toast } from 'sonner';
import { z } from 'zod';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DetailNavigationToolbar } from '@/components/shared/detail-navigation';
import { InlineRenameInput } from '@/components/shared/inline-rename-input';
import { ListNavigationToolbar } from '@/components/shared/list-navigation-toolbar';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -433,7 +433,7 @@ const Template = () => {
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
{canShowActions && (
<ListNavigationToolbar<Template>
<DetailNavigationToolbar<Template>
{...templateToolbarProps}
sheetIcon={<FileText className="size-4" />}
sheetTitle="Templates"