refactor(frontend): headless controller for DetailNavigation

Detail pages duplicated DetailNavigationToolbar's internal navigation
state (prev/next, sheet open, position label) because mobile chrome lives
inside a DropdownMenuItem and could not reuse the toolbar component.
Promote useDetailNavigation to return a full DetailNavigationController,
have the leaf components (Buttons / Sheet / Toolbar) read from it, and
drop the ~40 LOC mobile mirror block on each of three pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-16 13:11:35 +07:00
co-authored by Claude Opus 4.7
parent 8dbee4fb64
commit 7dae4be61d
14 changed files with 1045 additions and 933 deletions
@@ -2,43 +2,40 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface DetailNavigationButtonsProps {
/** Disable the position-button when the filtered subset is empty. */
hasEntries: boolean;
/** No next sibling — disable the right chevron. */
nextDisabled: boolean;
onNext: () => void;
onOpen: () => void;
onPrev: () => void;
/** Pre-formatted `"3/10"` (or `"/0"` when no current). */
positionLabel: string;
/** No previous sibling — disable the left chevron. */
prevDisabled: boolean;
import type { DetailNavigationController } from './use-detail-navigation';
interface DetailNavigationButtonsProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
/** Lowercased plural used in the aria-label / tooltip ("flows", "templates"). */
sheetTitle: string;
/**
* Size variant. `'default'` is the desktop toolbar's `size-8` cluster;
* `'sm'` shrinks the cluster to `size-7` for embedding inside a
* `<DropdownMenuItem>` on mobile, where the host row is already padded.
*/
size?: 'default' | 'sm';
}
/**
* Prev / Position / Next button cluster for a detail page. Stateless and
* presentation-only — `DetailNavigationToolbar` owns the navigation logic
* and feeds the resolved indices, labels, and click handlers down.
* Prev / Position / Next button cluster bound to a `DetailNavigationController`.
* Stateless: the controller owns navigation, `isSheetOpen`, and the
* pre-formatted `positionLabel`.
*
* 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).
* Reused in both the desktop toolbar (`size="default"`) and the mobile
* dropdown row (`size="sm"`) — same a11y contract, same tooltips, same
* keyboard semantics in both places.
*/
export const DetailNavigationButtons = ({
hasEntries,
nextDisabled,
onNext,
onOpen,
onPrev,
positionLabel,
prevDisabled,
export const DetailNavigationButtons = <T extends { id: string }>({
controller,
sheetTitle,
}: DetailNavigationButtonsProps) => {
size = 'default',
}: DetailNavigationButtonsProps<T>) => {
const lowerTitle = sheetTitle.toLowerCase();
const isSm = size === 'sm';
const sideButtonSize = isSm ? 'size-7' : 'size-8';
const middleHeight = isSm ? 'h-7' : 'h-8';
return (
<div className="flex items-center">
@@ -46,9 +43,9 @@ export const DetailNavigationButtons = ({
<TooltipTrigger asChild>
<Button
aria-label="Previous"
className="size-8 rounded-r-none border-r-0 p-0"
disabled={prevDisabled}
onClick={onPrev}
className={cn(sideButtonSize, 'rounded-r-none border-r-0 p-0')}
disabled={!controller.prevId}
onClick={controller.goToPrev}
size="icon"
variant="outline"
>
@@ -60,13 +57,13 @@ export const DetailNavigationButtons = ({
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={`Open ${lowerTitle} list (${positionLabel})`}
className="h-8 min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums"
disabled={!hasEntries}
onClick={onOpen}
aria-label={`Open ${lowerTitle} list (${controller.positionLabel})`}
className={cn(middleHeight, 'min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums')}
disabled={!controller.hasEntries}
onClick={controller.openSheet}
variant="outline"
>
{positionLabel}
{controller.positionLabel}
</Button>
</TooltipTrigger>
<TooltipContent>Show all matching {lowerTitle}</TooltipContent>
@@ -75,9 +72,9 @@ export const DetailNavigationButtons = ({
<TooltipTrigger asChild>
<Button
aria-label="Next"
className="size-8 rounded-l-none border-l-0 p-0"
disabled={nextDisabled}
onClick={onNext}
className={cn(sideButtonSize, 'rounded-l-none border-l-0 p-0')}
disabled={!controller.nextId}
onClick={controller.goToNext}
size="icon"
variant="outline"
>
@@ -0,0 +1,241 @@
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DetailNavigationSheet } from './detail-navigation-sheet';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
title: string;
}
const ITEMS: readonly Item[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
{ id: 'd', title: 'Delta' },
] as const;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const getSearchableText = (item: Item) => item.title;
const LocationReadout = () => {
const { pathname, search } = useLocation();
return (
<span data-testid="location">
{pathname}
{search}
</span>
);
};
interface HarnessProps {
currentId?: null | string;
filter?: string;
items?: readonly Item[];
}
/**
* Render the sheet open by default (`defaultOpen: true`) so keyboard /
* focus / a11y interactions can be exercised without round-tripping through
* the toolbar's position button. Keeps each test focused on the leaf.
*/
const SheetHarness = ({ currentId = 'c', items = ITEMS }: HarnessProps) => {
const nav = useDetailNavigation<Item>({
currentId,
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items,
});
return (
<DetailNavigationSheet<Item>
controller={nav}
sheetTitle="Items"
/>
);
};
const renderSheet = (props: HarnessProps = {}) => {
const filter = props.filter ?? '';
const initialId = props.currentId ?? 'c';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[`/items/${initialId}?q=${filter}`]}>
<TooltipProvider>
<LocationReadout />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</TooltipProvider>
</MemoryRouter>
);
return render(<SheetHarness {...props} />, { wrapper: Wrapper });
};
describe('DetailNavigationSheet — a11y / aria contract', () => {
it('renders the listbox with the sheet title as accessible name (aria-describedby opt-out preserved)', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox', { name: 'Items' });
expect(listbox).toBeInTheDocument();
});
it('marks the current item with aria-selected', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
const current = within(listbox).getByRole('option', { selected: true });
expect(current).toHaveAttribute('data-item-id', 'c');
});
});
describe('DetailNavigationSheet — roving tabIndex', () => {
it('only the current option carries tabIndex={0}', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
const options = within(listbox).getAllByRole('option');
await waitFor(() => {
const focusable = options.filter((option) => option.getAttribute('tabindex') === '0');
expect(focusable).toHaveLength(1);
expect(focusable[0]).toHaveAttribute('data-item-id', 'c');
});
const nonFocusable = options.filter((option) => option.getAttribute('tabindex') === '-1');
expect(nonFocusable.length).toBe(options.length - 1);
});
it('falls back to the first filtered option when current is outside the subset', async () => {
renderSheet({ currentId: 'zzz' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
});
describe('DetailNavigationSheet — keyboard navigation', () => {
it('ArrowDown moves roving focus to the next option', async () => {
renderSheet({ currentId: 'b' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'b');
});
fireEvent.keyDown(listbox, { key: 'ArrowDown' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'c');
});
});
it('ArrowUp at the first option clamps (no wrap)', async () => {
renderSheet({ currentId: 'a' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
fireEvent.keyDown(listbox, { key: 'ArrowUp' });
// Focus stays on the first option — no wrap-around.
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
it('End jumps roving focus to the last option', async () => {
renderSheet({ currentId: 'a' });
const listbox = await screen.findByRole('listbox');
fireEvent.keyDown(listbox, { key: 'End' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
});
it('Home jumps roving focus to the first option', async () => {
renderSheet({ currentId: 'd' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
fireEvent.keyDown(listbox, { key: 'Home' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
});
describe('DetailNavigationSheet — selection', () => {
it('clicking an option navigates and closes the sheet', async () => {
const user = userEvent.setup();
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: 'Alpha' }));
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Items' })).not.toBeInTheDocument();
});
expect(screen.getByTestId('location').textContent).toContain('/items/a');
});
it('narrows the listbox to filtered items', async () => {
renderSheet({ currentId: 'a', filter: 'pha' });
const listbox = await screen.findByRole('listbox', { name: 'Items' });
await waitFor(() => {
const labels = within(listbox)
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Alpha']);
});
});
});
@@ -5,26 +5,13 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
interface DetailNavigationSheetProps<T> {
currentId: null | string | undefined;
/**
* Pre-computed index of `currentId` inside `items` (or `-1` when the
* current entry doesn't belong to the filtered subset). Supplied by the
* parent toolbar so the sheet doesn't re-scan `items` for membership.
*/
currentIndex: number;
getId: (item: T) => string;
getLabel: (item: T) => string;
/** Filtered + sorted items rendered as the sheet's listbox. */
items: readonly T[];
onItemSelect: (item: T) => void;
onOpenChange: (open: boolean) => void;
open: boolean;
import type { DetailNavigationController } from './use-detail-navigation';
interface DetailNavigationSheetProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
renderItem?: (item: T, isCurrent: boolean) => ReactNode;
sheetIcon?: ReactNode;
sheetTitle: string;
/** Total count shown in the header — same as `items.length`, named explicitly for clarity. */
total: number;
}
/**
@@ -38,20 +25,27 @@ interface DetailNavigationSheetProps<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 DetailNavigationSheet<T>({
currentId,
currentIndex,
getId,
getLabel,
items,
onItemSelect,
onOpenChange,
open,
export const DetailNavigationSheet = <T extends { id: string }>({
controller,
renderItem,
sheetIcon,
sheetTitle,
total,
}: DetailNavigationSheetProps<T>) {
}: DetailNavigationSheetProps<T>) => {
// Destructure at the top so existing `useMemo` / `useEffect` deps below
// read individual fields rather than the controller object — keeps the
// identity story the same as before the refactor.
const {
currentId,
currentIndex,
filteredItems: items,
getId,
getLabel,
handleItemSelect: onItemSelect,
isSheetOpen: open,
setSheetOpen: onOpenChange,
total,
} = controller;
const listRef = useRef<HTMLUListElement>(null);
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
const [focusedId, setFocusedId] = useState<null | string>(null);
@@ -107,7 +101,7 @@ export function DetailNavigationSheet<T>({
return null;
}
// `currentId != null` narrows to `string`; the parent toolbar
// `currentId != null` narrows to `string`; the controller has
// already verified `currentId` belongs to the filtered subset
// when it computed `currentIndex`, so no re-scan needed.
return currentId != null && currentIndex >= 0 ? String(currentId) : String(getId(firstItem));
@@ -313,4 +307,4 @@ export function DetailNavigationSheet<T>({
</SheetContent>
</Sheet>
);
}
};
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
@@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DetailNavigationToolbar } from './detail-navigation-toolbar';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
@@ -21,9 +22,8 @@ const ITEMS: readonly Item[] = [
{ id: 'd', title: 'Delta' },
] as const;
const getId = (item: Item) => item.id;
const getLabel = (item: Item) => item.title;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const LocationReadout = () => {
const { pathname, search } = useLocation();
@@ -36,9 +36,34 @@ const LocationReadout = () => {
);
};
const renderToolbar = (overrides: { currentId?: null | string; filter?: string; items?: readonly Item[] } = {}) => {
interface HarnessProps {
currentId?: null | string;
filter?: string;
items?: readonly Item[];
}
const ToolbarHarness = ({ currentId = 'c', items = ITEMS }: HarnessProps) => {
const nav = useDetailNavigation<Item>({
currentId,
getHref,
getLabel,
items,
});
return (
<DetailNavigationToolbar<Item>
controller={nav}
sheetTitle="Items"
/>
);
};
const renderToolbar = (props: HarnessProps = {}) => {
const filter = props.filter ?? '';
const initialId = props.currentId ?? 'c';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/c?q=' + (overrides.filter ?? '')]}>
<MemoryRouter initialEntries={[`/items/${initialId}?q=${filter}`]}>
<TooltipProvider>
<LocationReadout />
<Routes>
@@ -51,33 +76,28 @@ const renderToolbar = (overrides: { currentId?: null | string; filter?: string;
</MemoryRouter>
);
return render(
<DetailNavigationToolbar<Item>
currentId={overrides.currentId ?? 'c'}
filter={overrides.filter ?? ''}
getHref={getHref}
getId={getId}
getLabel={getLabel}
items={overrides.items ?? ITEMS}
sheetTitle="Items"
/>,
{ wrapper: Wrapper },
);
return render(<ToolbarHarness {...props} />, { wrapper: Wrapper });
};
describe('DetailNavigationToolbar', () => {
it('renders nothing when items list is empty', () => {
it('renders nothing when raw items is empty', () => {
renderToolbar({ items: [] });
// The toolbar should not emit any of its own controls — the wrapper
// tree still renders the location readout, so assert on absent
// toolbar elements instead of "container empty".
expect(screen.queryByRole('button', { name: /Previous/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Next/i })).not.toBeInTheDocument();
});
it('shows `${currentIndex + 1}/${total}` for a matched current item', () => {
it('composes Buttons + Sheet: position button opens the listbox', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'c' });
expect(screen.getByRole('button', { name: /3\/4/ })).toBeInTheDocument();
// Buttons present (smoke).
expect(screen.getByRole('button', { name: /Previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument();
// Position trigger opens the sheet.
await user.click(screen.getByRole('button', { name: /3\/4/ }));
const listbox = await screen.findByRole('listbox', { name: 'Items' });
expect(listbox).toBeInTheDocument();
});
it('shows `/total` when current is missing from the filtered subset', () => {
@@ -97,254 +117,22 @@ describe('DetailNavigationToolbar', () => {
it('Next navigates to the next sibling preserving `?q=`', async () => {
const user = userEvent.setup();
// Filter 'a' matches every item (substring), so prev/next stays
// navigable across the full set while still proving the `?q=` URL
// round-trip on click.
renderToolbar({ currentId: 'a', filter: 'a' });
await user.click(screen.getByRole('button', { name: /Next/i }));
await waitFor(() => {
const location = screen.getByTestId('location').textContent ?? '';
expect(location).toContain('/items/b');
expect(screen.getByTestId('location').textContent).toContain('/items/b');
});
expect(screen.getByTestId('location').textContent).toContain('q=a');
});
it('opens the sheet listbox when the position button is clicked', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'c' });
await user.click(screen.getByRole('button', { name: /3\/4/ }));
const listbox = await screen.findByRole('listbox', { name: 'Items' });
expect(listbox).toBeInTheDocument();
expect(within(listbox).getAllByRole('option')).toHaveLength(4);
});
it('narrows the listbox to filtered items', async () => {
const user = userEvent.setup();
// "pha" matches only "Alpha" — substring is case-insensitive and
// diacritic-insensitive (see ./text-filter tests).
renderToolbar({ currentId: 'a', filter: 'pha' });
await user.click(screen.getByRole('button', { name: /1\/1/ }));
const listbox = await screen.findByRole('listbox', { name: 'Items' });
const labels = within(listbox)
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Alpha']);
});
it('marks the current item with aria-selected and font-medium', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'c' });
await user.click(screen.getByRole('button', { name: /3\/4/ }));
const listbox = await screen.findByRole('listbox');
const current = within(listbox).getByRole('option', { selected: true });
expect(current).toHaveAttribute('data-item-id', 'c');
});
it('applies roving tabIndex — only the current option is focusable', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'c' });
await user.click(screen.getByRole('button', { name: /3\/4/ }));
const listbox = await screen.findByRole('listbox');
const options = within(listbox).getAllByRole('option');
// Wait for the open-time roving focus to land on the current entry.
await waitFor(() => {
const focusable = options.filter((option) => option.getAttribute('tabindex') === '0');
expect(focusable).toHaveLength(1);
expect(focusable[0]).toHaveAttribute('data-item-id', 'c');
});
const nonFocusable = options.filter((option) => option.getAttribute('tabindex') === '-1');
expect(nonFocusable.length).toBe(options.length - 1);
});
it('ArrowDown moves roving focus to the next option', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'b' });
await user.click(screen.getByRole('button', { name: /2\/4/ }));
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'b');
});
fireEvent.keyDown(listbox, { key: 'ArrowDown' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'c');
});
});
it('ArrowUp at the first option clamps (no wrap)', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'a' });
await user.click(screen.getByRole('button', { name: /1\/4/ }));
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
fireEvent.keyDown(listbox, { key: 'ArrowUp' });
// Focus stays on the first option — no wrap-around.
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
it('End jumps roving focus to the last option', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'a' });
await user.click(screen.getByRole('button', { name: /1\/4/ }));
const listbox = await screen.findByRole('listbox');
fireEvent.keyDown(listbox, { key: 'End' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
});
it('Home jumps roving focus to the first option', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'd' });
await user.click(screen.getByRole('button', { name: /4\/4/ }));
const listbox = await screen.findByRole('listbox');
// Wait for the open-time focus on the current ('d') before issuing Home.
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
fireEvent.keyDown(listbox, { key: 'Home' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
it('clicking an option in the sheet navigates and closes it', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'c' });
await user.click(screen.getByRole('button', { name: /3\/4/ }));
const listbox = await screen.findByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: 'Alpha' }));
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Items' })).not.toBeInTheDocument();
});
expect(screen.getByTestId('location').textContent).toContain('/items/a');
});
it('disables the position button when the filter excludes every item', () => {
it('disables the position button when the filter excludes every item', async () => {
renderToolbar({ currentId: 'c', filter: 'qqqqqqqqq' });
// With nothing to navigate to, the sheet trigger is disabled — the
// empty-state copy lives inside the sheet but the button gating
// prevents opening it in the first place.
const positionButton = screen.getByRole('button', { name: /\/0/ });
expect(positionButton).toBeDisabled();
});
it('falls back to the first filtered option when current is outside the subset', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'zzz' });
await user.click(screen.getByRole('button', { name: /\/4/ }));
const listbox = await screen.findByRole('listbox');
// After debounce settles, the empty subset disables the trigger.
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
});
describe('DetailNavigationToolbar — predicate stability', () => {
it('updates the filtered subset when the filter prop changes', async () => {
const user = userEvent.setup();
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a']}>
<TooltipProvider>
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</TooltipProvider>
</MemoryRouter>
);
const { rerender } = render(
<DetailNavigationToolbar<Item>
currentId="a"
filter=""
getHref={getHref}
getId={getId}
getLabel={getLabel}
items={ITEMS}
sheetTitle="Items"
/>,
{ wrapper: Wrapper },
);
expect(screen.getByRole('button', { name: /1\/4/ })).toBeInTheDocument();
rerender(
<DetailNavigationToolbar<Item>
currentId="a"
filter="Alpha"
getHref={getHref}
getId={getId}
getLabel={getLabel}
items={ITEMS}
sheetTitle="Items"
/>,
);
expect(screen.getByRole('button', { name: /1\/1/ })).toBeInTheDocument();
await act(async () => {
await user.tab();
expect(screen.getByRole('button', { name: /\/0/ })).toBeDisabled();
});
});
});
@@ -1,183 +1,49 @@
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import type { ReactNode } from 'react';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import type { DetailNavigationController } from './use-detail-navigation';
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
* navigable subset by running the same `matchesTextFilter` semantics the
* list uses — so Prev/Next never drifts out of sync with what the user
* sees in the table.
*/
filter: string;
/**
* Build a navigation target for the given item. The current value of
* `?<filterParamName>=` is forwarded so detail-page navigation preserves
* the filter the user picked on the list.
*/
getHref: (item: T) => string;
/**
* Stable accessor for the row's ID. The toolbar uses it for keying both
* the navigation lookup and the rendered Sheet list.
*/
getId: (item: T) => string;
/**
* Default label for items in the Sheet list. Used when `renderItem` is
* not provided. Also acts as the default haystack for the filter when
* `getSearchableText` is omitted (matches the common case where the list
* column filter targets the same field as the label).
*/
getLabel: (item: T) => string;
/**
* Optional override for the haystack the filter runs against. Pass when
* the list column filter is keyed on a different field than the visible
* label (e.g. label is `title || #id`, filter is plain `title`).
*/
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
/**
* Optional override for the row in the Sheet. Receives the item and a
* boolean indicating whether it's the current detail page.
*/
export interface DetailNavigationToolbarProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
renderItem?: (item: T, isCurrent: boolean) => ReactNode;
sheetIcon?: ReactNode;
sheetTitle: string;
/** Optional comparator. When omitted, items appear in input order. */
sortFn?: (a: T, b: T) => number;
}
/**
* Prev / Position / Next toolbar shown on detail pages whose list page
* supports a free-text filter.
* Convenience wrapper that composes `<DetailNavigationButtons>` and
* `<DetailNavigationSheet>` against a single `DetailNavigationController`.
* Most desktop call sites use this directly; pages with non-standard chrome
* (e.g. mobile prev/position/next inside a `<DropdownMenuItem>`) can compose
* the leaves themselves and read from the same controller.
*
* - "Prev" / "Next" walk the same filtered subset the list page renders.
* - 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
* server-loaded record doesn't match the active filter, or the record
* was just deleted) prev/next are disabled and the position label falls
* back to `"/total"` so the user can still open the Sheet to pick a
* neighbour.
* - Sibling navigation uses `replace: true` so paging through a list of 50
* items doesn't push 50 history entries the user has to back through.
*
* 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.
* Renders `null` when the controller reports `itemsEmpty` — saves the user
* from a momentary "/0" flash while the parent provider's data is in flight.
*/
export function DetailNavigationToolbar<T>({
currentId,
filter,
getHref,
getId,
getLabel,
getSearchableText,
items,
export const DetailNavigationToolbar = <T extends { id: string }>({
controller,
renderItem,
sheetIcon,
sheetTitle,
sortFn,
}: DetailNavigationToolbarProps<T>) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isSheetOpen, setIsSheetOpen] = useState(false);
// The toolbar owns the haystack-resolution rule so list and detail always
// agree on what "matches the filter" means — callers only describe the
// data shape (`getSearchableText` / `getLabel`), never the comparison.
const haystack = getSearchableText ?? getLabel;
const { currentIndex, filteredItems, nextId, prevId, total } = useNavigation({
currentId,
getId,
getSearchableText: haystack,
items,
query: filter,
sortFn,
});
// Forward the current filter (and any other URL state) when navigating
// to a sibling — without it the user would lose `?q=` the moment they
// hit Prev/Next, breaking the "stay inside the filtered subset" promise.
// `mergeHrefWithSearchParams` round-trips through `URL` so the hash
// fragment survives and getHref's own query params win on collision.
const buildHref = useCallback(
(item: T) => mergeHrefWithSearchParams(getHref(item), searchParams),
[getHref, searchParams],
);
const goTo = useCallback(
(id: null | string) => {
if (!id) {
return;
}
const target = filteredItems.find((item) => String(getId(item)) === id);
if (!target) {
return;
}
navigate(buildHref(target), { replace: true });
},
[buildHref, filteredItems, getId, navigate],
);
const handleItemSelect = useCallback(
(item: T) => {
setIsSheetOpen(false);
navigate(buildHref(item), { replace: true });
},
[buildHref, navigate],
);
const goToPrev = useCallback(() => goTo(prevId), [goTo, prevId]);
const goToNext = useCallback(() => goTo(nextId), [goTo, nextId]);
const openSheet = useCallback(() => setIsSheetOpen(true), []);
const positionLabel = useMemo(
() => (total === 0 || currentIndex === -1 ? `/${total}` : `${currentIndex + 1}/${total}`),
[currentIndex, total],
);
// Nothing meaningful to render before items show up — saves the user from
// a momentary "/0" flash while the parent provider's data is in flight.
if (items.length === 0) {
}: DetailNavigationToolbarProps<T>) => {
if (controller.itemsEmpty) {
return null;
}
return (
<>
<DetailNavigationButtons
hasEntries={total > 0}
nextDisabled={!nextId}
onNext={goToNext}
onOpen={openSheet}
onPrev={goToPrev}
positionLabel={positionLabel}
prevDisabled={!prevId}
controller={controller}
sheetTitle={sheetTitle}
/>
<DetailNavigationSheet<T>
currentId={currentId}
currentIndex={currentIndex}
getId={getId}
getLabel={getLabel}
items={filteredItems}
onItemSelect={handleItemSelect}
onOpenChange={setIsSheetOpen}
open={isSheetOpen}
<DetailNavigationSheet
controller={controller}
renderItem={renderItem}
sheetIcon={sheetIcon}
sheetTitle={sheetTitle}
total={total}
/>
</>
);
}
};
@@ -1,5 +1,4 @@
export { DetailNavigationButtons } from './detail-navigation-buttons';
export { DetailNavigationSheet } from './detail-navigation-sheet';
export { DetailNavigationToolbar } from './detail-navigation-toolbar';
export type { DetailNavigationToolbarProps } from './detail-navigation-toolbar';
export { useDetailNavigation } from './use-detail-navigation';
export { useNavigation } from './use-navigation';
export { type DetailNavigationController, useDetailNavigation } from './use-detail-navigation';
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useNavigate, useSearchParams } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDetailNavigation } from './use-detail-navigation';
@@ -58,10 +58,12 @@ describe('useDetailNavigation — default getId', () => {
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.toolbarProps.getId(ITEMS[1])).toBe('b');
expect(result.current.getId(ITEMS[1])).toBe('b');
});
});
it('keeps `toolbarProps` reference stable when the caller re-renders without changes', () => {
describe('useDetailNavigation — identity stability', () => {
it('keeps the controller reference stable across re-renders with unchanged inputs', () => {
const { rerender, result } = renderHook(
() =>
useDetailNavigation<Item>({
@@ -74,15 +76,42 @@ describe('useDetailNavigation — default getId', () => {
{ wrapper: renderInRoute(['/items/b']) },
);
const first = result.current.toolbarProps;
const first = result.current;
rerender();
// Downstream toolbar memos rely on this identity stability.
expect(result.current.toolbarProps).toBe(first);
// Downstream leaf components rely on this identity stability.
expect(result.current).toBe(first);
});
it('keeps `goToPrev` / `goToNext` / `handleItemSelect` identity-stable when nothing relevant changes', () => {
const { rerender, result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
const firstGoPrev = result.current.goToPrev;
const firstGoNext = result.current.goToNext;
const firstSelect = result.current.handleItemSelect;
const firstOpen = result.current.openSheet;
const firstSet = result.current.setSheetOpen;
rerender();
expect(result.current.goToPrev).toBe(firstGoPrev);
expect(result.current.goToNext).toBe(firstGoNext);
expect(result.current.handleItemSelect).toBe(firstSelect);
expect(result.current.openSheet).toBe(firstOpen);
expect(result.current.setSheetOpen).toBe(firstSet);
});
});
describe('useDetailNavigation — filter forwarding', () => {
it('exposes the URL `?q=` value through `toolbarProps.filter` (debounced)', async () => {
it('exposes the URL `?q=` value through `controller.debouncedFilter` (debounced)', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
@@ -95,58 +124,302 @@ describe('useDetailNavigation — filter forwarding', () => {
{ wrapper: renderInRoute(['/items/b?q=alpha']) },
);
// `toolbarProps.filter` is the debounced value — wait for the debounce
// to settle (default 200ms) before asserting equality.
// `debouncedFilter` settles after the default 200ms debounce.
await waitFor(() => {
expect(result.current.toolbarProps.filter).toBe('alpha');
expect(result.current.debouncedFilter).toBe('alpha');
});
expect(result.current.debouncedFilter).toBe('alpha');
});
it('does NOT replay `localStorage` into the URL on a fresh detail mount', async () => {
// Detail pages use `useTableQueryFilterReader` under the hood, which
// is explicitly storage-blind. A shared `/items/abc` link should not
// gain a stale `?q=` from a previous tab's filter.
localStorage.setItem('table_4_/items', JSON.stringify({ filter: 'stored' }));
it('narrows `filteredItems` to the matching subset once the filter settles', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/a?q=pha']) },
);
const ProbeChild = ({ onMount }: { onMount: (search: string) => void }) => {
const [searchParams] = useSearchParams();
onMount(searchParams.toString());
await waitFor(() => {
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['a']);
});
expect(result.current.total).toBe(1);
expect(result.current.currentIndex).toBe(0);
});
});
return null;
};
const seen: string[] = [];
renderHook(
() => {
const nav = useDetailNavigation<Item>({
describe('useDetailNavigation — derived state', () => {
it('reports prev/next neighbours for a middle item', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
});
return nav;
},
}),
{ wrapper: renderInRoute(['/items/b']) },
);
// Drain pending tasks — any storage→URL replay would land here.
await act(async () => {
await Promise.resolve();
expect(result.current.currentIndex).toBe(1);
expect(result.current.prevId).toBe('a');
expect(result.current.nextId).toBe('c');
expect(result.current.total).toBe(3);
expect(result.current.hasEntries).toBe(true);
expect(result.current.itemsEmpty).toBe(false);
expect(result.current.positionLabel).toBe('2/3');
});
it('reports `-1` index and `/total` label when currentId is not in subset', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'zzz',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/zzz']) },
);
expect(result.current.currentIndex).toBe(-1);
expect(result.current.prevId).toBeNull();
expect(result.current.nextId).toBeNull();
expect(result.current.positionLabel).toBe('/3');
});
it('reports `itemsEmpty=true` and `/0` when input list is empty', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: [],
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.itemsEmpty).toBe(true);
expect(result.current.total).toBe(0);
expect(result.current.hasEntries).toBe(false);
expect(result.current.positionLabel).toBe('/0');
});
});
describe('useDetailNavigation — navigation actions', () => {
it('goToNext() navigates to the next sibling preserving `?q=`', async () => {
const LocationProbe = ({ onChange }: { onChange: (loc: string) => void }) => {
const { pathname, search } = useLocation();
onChange(`${pathname}${search}`);
return null;
};
const seen: string[] = [];
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a?q=a']}>
<LocationProbe onChange={(loc) => seen.push(loc)} />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
await waitFor(() => {
expect(result.current.debouncedFilter).toBe('a');
});
renderHook(() => null, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={['/items/b']}>
<ProbeChild onMount={(search) => seen.push(search)} />
{children}
</MemoryRouter>
),
act(() => {
result.current.goToNext();
});
expect(seen.every((search) => !search.includes('q='))).toBe(true);
await waitFor(() => {
expect(seen.at(-1)).toContain('/items/b');
});
expect(seen.at(-1)).toContain('q=a');
});
it('goToPrev() is a no-op when prevId is null (no navigate)', async () => {
const LocationProbe = ({ onChange }: { onChange: (loc: string) => void }) => {
const { pathname } = useLocation();
onChange(pathname);
return null;
};
const seen: string[] = [];
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a']}>
<LocationProbe onChange={(loc) => seen.push(loc)} />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
expect(result.current.prevId).toBeNull();
const before = seen.length;
act(() => {
result.current.goToPrev();
});
// No path change emitted.
expect(seen.length).toBe(before);
});
it('handleItemSelect closes the sheet *before* navigating', async () => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a']}>
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
expect(result.current.isSheetOpen).toBe(true);
act(() => {
result.current.handleItemSelect(ITEMS[2]);
});
// Sheet closes synchronously inside the same call — the navigate
// that follows can't race with a still-mounted sheet.
expect(result.current.isSheetOpen).toBe(false);
});
});
describe('useDetailNavigation — controlled sheet mode', () => {
it('respects `open={true}` even when `setSheetOpen(false)` is called', () => {
const onOpenChange = vi.fn();
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onOpenChange,
open: true,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(true);
act(() => {
result.current.setSheetOpen(false);
});
// Parent owns the state — controller doesn't flip without a prop change.
expect(result.current.isSheetOpen).toBe(true);
// But `onOpenChange` fires so the parent can observe the request.
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
it('toggles internal state and fires onOpenChange in uncontrolled mode', () => {
const onOpenChange = vi.fn();
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onOpenChange,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(false);
act(() => {
result.current.openSheet();
});
expect(result.current.isSheetOpen).toBe(true);
expect(onOpenChange).toHaveBeenLastCalledWith(true);
act(() => {
result.current.closeSheet();
});
expect(result.current.isSheetOpen).toBe(false);
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
it('honours defaultOpen=true on first render in uncontrolled mode', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(true);
});
});
@@ -164,8 +437,9 @@ describe('useDetailNavigation — current item bookkeeping', () => {
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.toolbarProps.currentId).toBeUndefined();
expect(result.current.toolbarProps.items).toBe(ITEMS);
expect(result.current.currentId).toBeNull();
expect(result.current.currentIndex).toBe(-1);
expect(result.current.filteredItems).toEqual(ITEMS);
});
});
@@ -1,23 +1,80 @@
import { useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
import { useTableQueryFilterReader } from '@/hooks/use-table-query-filter';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import type { DetailNavigationToolbarProps } from './detail-navigation-toolbar';
import { useNavigation } from './use-navigation';
/**
* 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.
* Headless controller for a detail page that walks a filtered list.
*
* 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.
* Owns:
* - the filtered/sorted subset (pure `computeNavigation`),
* - the resolved Prev / Next sibling ids and the pre-formatted position
* label (so leaf components don't recompute),
* - the sheet open state (controllable via `open` / `onOpenChange`),
* - navigation actions that thread the current `?<filter>=` into every
* prev / next / item-select destination.
*
* All callbacks are identity-stable across renders that don't change the
* inputs the action depends on `<DetailNavigationButtons>`,
* `<DetailNavigationSheet>`, and any custom chrome rendered against the
* controller can rely on referential equality for downstream memos.
*/
type DetailNavigationToolbarDataProps<T> = Pick<
DetailNavigationToolbarProps<T>,
'currentId' | 'filter' | 'getHref' | 'getId' | 'getLabel' | 'getSearchableText' | 'items'
>;
export interface DetailNavigationController<T extends { id: string }> {
closeSheet: () => void;
/** Active `currentId` coerced to a string, or `null` when absent. */
currentId: null | string;
/** Index of `currentItem` inside `filteredItems`. `-1` when not in subset. */
currentIndex: number;
/** Item matching `currentId` inside the filtered subset, or `null`. */
currentItem: null | T;
/** Debounced URL filter the controller is filtering against. */
debouncedFilter: string;
/** Sorted+filtered subset that drives prev / next / sheet listing. */
filteredItems: readonly T[];
/** Stable id accessor (defaults to `item.id` when not supplied). */
getId: (item: T) => string;
getLabel: (item: T) => string;
/**
* Resolved haystack accessor the caller-supplied `getSearchableText`,
* or `getLabel` as a fallback. Exposed for advanced consumers; leaf
* components don't read it because filtering already happened on the
* way into `filteredItems`.
*/
getSearchableText: (item: T) => null | string | undefined;
goToNext: () => void;
goToPrev: () => void;
/** Navigate to the given item and close the sheet. */
handleItemSelect: (item: T) => void;
/** `true` iff `filteredItems.length > 0`. */
hasEntries: boolean;
isSheetOpen: boolean;
/**
* `true` iff the raw `items` array is empty (pre-filter). The convenience
* `<DetailNavigationToolbar>` uses this to render `null` on a fresh detail
* mount when the provider's list hasn't arrived yet.
*/
itemsEmpty: boolean;
/** ID of the next filtered sibling, or `null` at the end / off-subset. */
nextId: null | string;
openSheet: () => void;
/** Pre-formatted `"3/10"` or `"/0"` for the position trigger. */
positionLabel: string;
/** ID of the previous filtered sibling, or `null` at the start / off-subset. */
prevId: null | string;
setSheetOpen: (open: boolean) => void;
/** Same as `filteredItems.length`, named explicitly for clarity. */
total: number;
}
/**
* Restricts the override to strings that start with a `/`. Pure-string types
@@ -25,19 +82,30 @@ type DetailNavigationToolbarDataProps<T> = Pick<
* either collide on the shared `filter_4_` key or generate a different slot
* than the actual list page. Template-literal types catch this at compile
* time without runtime guards.
*
* `${string}` is unconstrained on purpose we only care that the value
* begins with a slash, not what follows.
*/
type ParentPath = `/${string}`;
interface UseDetailNavigationOptions<T extends { id: string }> {
currentId: null | string | undefined;
/** Initial value for the uncontrolled case. Defaults to `false`. */
defaultOpen?: boolean;
getHref: (item: T) => string;
getId?: (item: T) => string;
getLabel: (item: T) => string;
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
onOpenChange?: (open: boolean) => void;
/**
* Controlled-mode opt-in for the sheet. When `open` is `undefined` the
* controller owns the state internally; when a value is provided the
* caller owns it. `onOpenChange` always fires so a fully-controlled
* consumer can observe every set.
*
* Mirrors the `useControllable` pattern from
* `@/components/ui/autocomplete.tsx`.
*/
open?: boolean;
/**
* Optional override for the parent list path used to look up the shared
* filter storage slot. Defaults to the top-level segment of the current
@@ -45,18 +113,9 @@ interface UseDetailNavigationOptions<T extends { id: string }> {
* `/knowledges`). Nested routes (`/admin/flows/:id`) must pass an
* explicit value here because the default would key into `/admin`
* rather than `/admin/flows`.
*
* Typed as a slash-prefixed string so the compiler rejects empty or
* unprefixed overrides those used to silently fall back to the
* top-level path, which produced surprising key collisions. Pass an
* explicit slash path (e.g. `'/admin/flows'`) when overriding.
*/
parentPath?: ParentPath;
}
interface UseDetailNavigationResult<T extends { id: string }> {
debouncedFilter: string;
toolbarProps: DetailNavigationToolbarDataProps<T>;
sortFn?: (a: T, b: T) => number;
}
// Module-level so the reference is stable across renders. Typed against
@@ -66,30 +125,47 @@ interface UseDetailNavigationResult<T extends { id: string }> {
const defaultGetId = (item: { id: string }): string => item.id;
/**
* Convenience wrapper for detail pages that need a Prev/Next toolbar tied
* to the parent list's free-text filter.
* Build the headless `DetailNavigationController<T>` for a detail page.
*
* The hook bundles the three things every detail page repeats:
* Bundles the four moving parts every detail page repeats:
* 1. resolving the parent list's storage slot via `usePageStorageKeys`,
* 2. subscribing to the URL filter through `useTableQueryFilterReader`
* (read-only the detail page never mutates the filter from here),
* 3. supplying a default `getId` (most domain types use a plain `id`).
* 3. running the pure `useNavigation` core against the filtered subset,
* 4. wiring identity-stable `goToPrev` / `goToNext` / `handleItemSelect`
* with `?<filter>=` forwarded through `mergeHrefWithSearchParams`.
*
* The returned `toolbarProps` plug straight into `<DetailNavigationToolbar>`
* callers add presentation-only props (`sheetTitle`, `renderItem`, etc.).
* The returned controller drives `<DetailNavigationToolbar>`,
* `<DetailNavigationButtons>`, and `<DetailNavigationSheet>` (or any custom
* chrome a consumer wants to render). All function fields are wrapped in
* `useCallback` with `useLatestRef`-stabilized closures over `useSearchParams`
* and the caller-supplied accessors React Router v6 returns a fresh
* `URLSearchParams` each render, and threading it through callback deps would
* defeat the entire memoization goal.
*
* Pass each callback through `useCallback`: the toolbar's filter memo relies
* on identity stability, and an inline arrow defeats it.
* Pass per-feature callbacks through `useCallback` (or, as the existing
* `use-flow-detail-navigation` / `use-template-detail-navigation` /
* `use-knowledge-detail-navigation` do, hoist them to module scope). An
* inline arrow would still work `useLatestRef` reads the most recent
* version at fire time but it forfeits `useNavigation`'s internal
* memoization on `getSearchableText`.
*/
export const useDetailNavigation = <T extends { id: string }>({
currentId,
defaultOpen,
getHref,
getId,
getLabel,
getSearchableText,
items,
onOpenChange,
open,
parentPath,
}: UseDetailNavigationOptions<T>): UseDetailNavigationResult<T> => {
sortFn,
}: UseDetailNavigationOptions<T>): DetailNavigationController<T> => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// `parentPath` is typed as `/${string}` so the empty / unprefixed case
// is rejected at compile time — we only need to branch on presence here.
const hasExplicitParentPath = parentPath !== undefined;
@@ -101,21 +177,147 @@ export const useDetailNavigation = <T extends { id: string }>({
// Memoize so passing `getId={undefined}` keeps the reference stable when
// the caller re-renders for unrelated reasons. Without the memo the
// `toolbarProps` memo below would invalidate on every render.
// downstream `useNavigation` memo would invalidate on every render.
const resolvedGetId = useMemo<(item: T) => string>(() => getId ?? defaultGetId, [getId]);
const toolbarProps = useMemo<DetailNavigationToolbarDataProps<T>>(
() => ({
currentId,
filter: debouncedFilter,
getHref,
getId: resolvedGetId,
getLabel,
getSearchableText,
items,
}),
[currentId, debouncedFilter, getHref, getLabel, getSearchableText, items, resolvedGetId],
const resolvedGetSearchableText = useMemo<(item: T) => null | string | undefined>(
() => getSearchableText ?? getLabel,
[getSearchableText, getLabel],
);
return { debouncedFilter, toolbarProps };
const { currentIndex, currentItem, filteredItems, nextId, prevId, total } = useNavigation<T>({
currentId,
getId: resolvedGetId,
getSearchableText: resolvedGetSearchableText,
items,
query: debouncedFilter,
sortFn,
});
// Controlled-mode sheet state (mirrors `useControllable` from
// `components/ui/autocomplete.tsx:27-46`). When `open` is provided the
// caller owns the state; otherwise the controller owns it.
// `onOpenChange` fires on every set so fully-controlled consumers can
// observe transitions.
const onOpenChangeRef = useLatestRef(onOpenChange);
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
const isOpenControlled = open !== undefined;
const isSheetOpen = isOpenControlled ? open : internalOpen;
const setSheetOpen = useCallback(
(next: boolean) => {
if (!isOpenControlled) {
setInternalOpen(next);
}
onOpenChangeRef.current?.(next);
},
[isOpenControlled, onOpenChangeRef],
);
const openSheet = useCallback(() => setSheetOpen(true), [setSheetOpen]);
const closeSheet = useCallback(() => setSheetOpen(false), [setSheetOpen]);
// `useSearchParams` from React Router v6 returns a fresh `URLSearchParams`
// every render. Threading it (or any caller-supplied accessor that might
// not be stable) through `useCallback` deps would invalidate every
// navigation callback on every render. Stash through `useLatestRef` and
// read at fire-time instead — handlers fire from user clicks / keyboard
// events, so the one-commit lag documented on `useLatestRef` never bites.
const searchParamsRef = useLatestRef(searchParams);
const getHrefRef = useLatestRef(getHref);
const getIdRef = useLatestRef(resolvedGetId);
const filteredItemsRef = useLatestRef(filteredItems);
const buildHref = useCallback(
(item: T) => mergeHrefWithSearchParams(getHrefRef.current(item), searchParamsRef.current),
[getHrefRef, searchParamsRef],
);
const handleItemSelect = useCallback(
(item: T) => {
// Close the sheet *before* navigating — preserves the pre-refactor
// ordering so a route change can't unmount the sheet while its
// close callback is still in flight.
setSheetOpen(false);
navigate(buildHref(item), { replace: true });
},
[buildHref, navigate, setSheetOpen],
);
const goTo = useCallback(
(id: null | string) => {
if (!id) {
return;
}
const target = filteredItemsRef.current.find((item) => String(getIdRef.current(item)) === id);
if (!target) {
return;
}
navigate(buildHref(target), { replace: true });
},
[buildHref, filteredItemsRef, getIdRef, navigate],
);
const goToPrev = useCallback(() => goTo(prevId), [goTo, prevId]);
const goToNext = useCallback(() => goTo(nextId), [goTo, nextId]);
const positionLabel = useMemo(
() => (total === 0 || currentIndex === -1 ? `/${total}` : `${currentIndex + 1}/${total}`),
[currentIndex, total],
);
const normalizedCurrentId = currentId != null ? String(currentId) : null;
const hasEntries = filteredItems.length > 0;
const itemsEmpty = items.length === 0;
return useMemo<DetailNavigationController<T>>(
() => ({
closeSheet,
currentId: normalizedCurrentId,
currentIndex,
currentItem,
debouncedFilter,
filteredItems,
getId: resolvedGetId,
getLabel,
getSearchableText: resolvedGetSearchableText,
goToNext,
goToPrev,
handleItemSelect,
hasEntries,
isSheetOpen,
itemsEmpty,
nextId,
openSheet,
positionLabel,
prevId,
setSheetOpen,
total,
}),
[
closeSheet,
normalizedCurrentId,
currentIndex,
currentItem,
debouncedFilter,
filteredItems,
resolvedGetId,
getLabel,
resolvedGetSearchableText,
goToNext,
goToPrev,
handleItemSelect,
hasEntries,
isSheetOpen,
itemsEmpty,
nextId,
openSheet,
positionLabel,
prevId,
setSheetOpen,
total,
],
);
};
@@ -9,11 +9,11 @@ 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 `<DetailNavigationToolbar<Flow>>`
* and adds the presentation-only props (`sheetTitle`, `renderItem`, etc.).
* passes the returned controller to `<DetailNavigationToolbar controller={nav}>`
* (or to its leaf primitives for custom chrome).
*
* Pass `null` instead of an id while the page is in a non-viewing state
* (e.g. `/flows/new`) so the toolbar reports an unmatched current item.
* (e.g. `/flows/new`) so the controller reports an unmatched current item.
*/
export const useFlowDetailNavigation = (currentId: null | string | undefined) => {
const { flows } = useFlows();
@@ -1,14 +1,18 @@
import type { ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Ellipsis, LibraryBig, Loader2, Pencil, Trash } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Ellipsis, LibraryBig, Loader2, Pencil, Trash } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DetailNavigationSheet, DetailNavigationToolbar, useNavigation } from '@/components/shared/detail-navigation';
import {
DetailNavigationButtons,
DetailNavigationSheet,
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
@@ -24,7 +28,6 @@ import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider';
import { useKnowledgeDetailNavigation } from './use-knowledge-detail-navigation';
@@ -42,9 +45,20 @@ interface KnowledgeHeaderProps {
saveButton?: ReactNode;
}
const renderKnowledgeItem = (item: Knowledge, isCurrent: boolean): ReactNode => (
<>
<Badge
className="shrink-0 text-[10px] whitespace-nowrap"
variant="outline"
>
{item.docType}
</Badge>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.question}</span>
</>
);
export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveButton }: KnowledgeHeaderProps) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { isMobile } = useBreakpoint();
const { deleteKnowledge, updateKnowledge } = useKnowledges();
const [isRenaming, setIsRenaming] = useState(false);
@@ -53,57 +67,9 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
const knowledgeId = knowledge?.id ?? null;
const { toolbarProps: knowledgeToolbarProps } = useKnowledgeDetailNavigation(knowledgeId);
// Mirror what `<DetailNavigationToolbar>` computes internally so the
// mobile menu items share the same filtered subset as the desktop toolbar.
const mobileNav = useNavigation<Knowledge>({
currentId: knowledgeToolbarProps.currentId,
getId: knowledgeToolbarProps.getId,
getSearchableText: knowledgeToolbarProps.getSearchableText ?? knowledgeToolbarProps.getLabel,
items: knowledgeToolbarProps.items,
query: knowledgeToolbarProps.filter,
});
const [isMobileNavSheetOpen, setIsMobileNavSheetOpen] = useState(false);
const mobileNavGoTo = useCallback(
(id: null | string) => {
if (!id) {
return;
}
const target = mobileNav.filteredItems.find(
(item) => String(knowledgeToolbarProps.getId(item)) === id,
);
if (!target) {
return;
}
navigate(mergeHrefWithSearchParams(knowledgeToolbarProps.getHref(target), searchParams), {
replace: true,
});
},
[knowledgeToolbarProps, mobileNav.filteredItems, navigate, searchParams],
);
const mobileNavSelectItem = useCallback(
(item: Knowledge) => {
setIsMobileNavSheetOpen(false);
navigate(mergeHrefWithSearchParams(knowledgeToolbarProps.getHref(item), searchParams), {
replace: true,
});
},
[knowledgeToolbarProps, navigate, searchParams],
);
const mobilePositionLabel = useMemo(
() =>
mobileNav.total === 0 || mobileNav.currentIndex === -1
? `/${mobileNav.total}`
: `${mobileNav.currentIndex + 1}/${mobileNav.total}`,
[mobileNav.currentIndex, mobileNav.total],
);
// Single controller drives both the desktop toolbar and the mobile
// dropdown row + sheet — no separate state mirroring required.
const knowledgeNav = useKnowledgeDetailNavigation(knowledgeId);
// Title source-of-truth is the server-side `question`. We intentionally do
// not read it from the form draft below — the inline rename flow in this
@@ -219,20 +185,8 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
<div className="flex shrink-0 items-center gap-2">
{canShowActions && !isMobile && (
<DetailNavigationToolbar<Knowledge>
{...knowledgeToolbarProps}
renderItem={(item, isCurrent) => (
<>
<Badge
className="shrink-0 text-[10px] whitespace-nowrap"
variant="outline"
>
{item.docType}
</Badge>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>
{item.question}
</span>
</>
)}
controller={knowledgeNav}
renderItem={renderKnowledgeItem}
sheetIcon={<LibraryBig className="size-4" />}
sheetTitle="Knowledges"
/>
@@ -255,7 +209,7 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && mobileNav.total > 0 && (
{isMobile && knowledgeNav.total > 0 && (
<>
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
@@ -264,35 +218,11 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
<LibraryBig className="size-4" />
Knowledges
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<Button
aria-label="Previous"
className="size-7 rounded-r-none border-r-0 p-0"
disabled={!mobileNav.prevId}
onClick={() => mobileNavGoTo(mobileNav.prevId)}
size="icon"
variant="outline"
>
<ChevronLeft />
</Button>
<Button
aria-label="Open knowledges list"
className="h-7 min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums"
disabled={mobileNav.currentIndex === -1}
onClick={() => setIsMobileNavSheetOpen(true)}
variant="outline"
>
{mobilePositionLabel}
</Button>
<Button
aria-label="Next"
className="size-7 rounded-l-none border-l-0 p-0"
disabled={!mobileNav.nextId}
onClick={() => mobileNavGoTo(mobileNav.nextId)}
size="icon"
variant="outline"
>
<ChevronRight />
</Button>
<DetailNavigationButtons<Knowledge>
controller={knowledgeNav}
sheetTitle="Knowledges"
size="sm"
/>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -326,28 +256,10 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
</header>
{isMobile && canShowActions && (
<DetailNavigationSheet<Knowledge>
currentId={knowledgeToolbarProps.currentId}
currentIndex={mobileNav.currentIndex}
getId={knowledgeToolbarProps.getId}
getLabel={knowledgeToolbarProps.getLabel}
items={mobileNav.filteredItems}
onItemSelect={mobileNavSelectItem}
onOpenChange={setIsMobileNavSheetOpen}
open={isMobileNavSheetOpen}
renderItem={(item, isCurrent) => (
<>
<Badge
className="shrink-0 text-[10px] whitespace-nowrap"
variant="outline"
>
{item.docType}
</Badge>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.question}</span>
</>
)}
controller={knowledgeNav}
renderItem={renderKnowledgeItem}
sheetIcon={<LibraryBig className="size-4" />}
sheetTitle="Knowledges"
total={mobileNav.total}
/>
)}
<ConfirmationDialog
@@ -5,7 +5,9 @@ const getLabel = (item: Knowledge) => item.question;
const getHref = (item: Knowledge) => `/knowledges/${item.id}`;
/**
* Detail-page navigation wired up for knowledge documents. The list page
* Detail-page navigation wired up for knowledge documents. Returns a
* `DetailNavigationController<Knowledge>` for `<DetailNavigationToolbar>` /
* `<DetailNavigationButtons>` / `<DetailNavigationSheet>`. The list page
* filters on `question` and the header shows the same, so `getLabel`
* doubles as the default searchable text.
*/
@@ -6,9 +6,12 @@ const getId = (item: Template) => String(item.id);
const getHref = (item: Template) => `/templates/${item.id}`;
/**
* Detail-page navigation wired up for templates. The list page filters on
* `title` and the breadcrumb shows the same, so `getLabel` doubles as the
* default searchable text (no explicit `getSearchableText` needed).
* Detail-page navigation wired up for templates. Returns a
* `DetailNavigationController<Template>` for `<DetailNavigationToolbar>` /
* `<DetailNavigationButtons>` / `<DetailNavigationSheet>`. The list page
* filters on `title` and the breadcrumb shows the same, so `getLabel`
* doubles as the default searchable text (no explicit `getSearchableText`
* needed).
*/
export const useTemplateDetailNavigation = (currentId: null | string | undefined) => {
const { templates } = useTemplates();
+41 -135
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Copy,
Download,
Ellipsis,
@@ -15,14 +15,18 @@ import {
Star,
Trash,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
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 { DetailNavigationSheet, DetailNavigationToolbar, useNavigation } from '@/components/shared/detail-navigation';
import {
DetailNavigationButtons,
DetailNavigationSheet,
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
@@ -47,12 +51,27 @@ import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useFlowTabDetection } from '@/hooks/use-flow-tab-detection';
import { Log } from '@/lib/log';
import { copyToClipboard, downloadTextFile, generateFileName, generateReport } from '@/lib/report';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { formatName } from '@/lib/utils/format';
import { useFavorites } from '@/providers/favorites-provider';
import { useFlow } from '@/providers/flow-provider';
import { type Flow as FlowItem, useFlows } from '@/providers/flows-provider';
const renderFlowItem = (item: FlowItem, isCurrent: boolean): ReactNode => (
<>
<FlowStatusIcon
className="size-3 shrink-0"
status={item.status}
/>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.title || `Flow #${item.id}`}</span>
<Badge
className="ml-auto shrink-0 font-mono text-[10px]"
variant="outline"
>
#{item.id}
</Badge>
</>
);
const FlowReportDropdown = () => {
const { flowData, flowId } = useFlow();
const flow = flowData?.flow;
@@ -171,7 +190,6 @@ const FlowReportDropdown = () => {
const Flow = () => {
const { isDesktop, isMobile } = useBreakpoint();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { flowData, flowError, flowId, isLoading: isFlowLoading } = useFlow();
const { deleteFlow, finishFlow } = useFlows();
@@ -181,56 +199,10 @@ const Flow = () => {
const flowTitle = flow?.title ?? '';
const isFlowRunning = flow ? ![StatusType.Failed, StatusType.Finished].includes(flow.status) : false;
// Walk the same `?q=` filtered subset the list page renders. The hook
// also restores the filter from `localStorage` when the page is opened
// via a bookmark, so the toolbar stays in lockstep with the user's last
// persisted intent.
const { toolbarProps: flowToolbarProps } = useFlowDetailNavigation(flowId);
// Mirror what `<DetailNavigationToolbar>` computes internally so the
// mobile menu items (Previous / Open list / Next) and the sheet trigger
// share the same filtered subset and ordering as the desktop toolbar.
const mobileNav = useNavigation<FlowItem>({
currentId: flowToolbarProps.currentId,
getId: flowToolbarProps.getId,
getSearchableText: flowToolbarProps.getSearchableText ?? flowToolbarProps.getLabel,
items: flowToolbarProps.items,
query: flowToolbarProps.filter,
});
const [isMobileNavSheetOpen, setIsMobileNavSheetOpen] = useState(false);
const mobileNavGoTo = useCallback(
(id: null | string) => {
if (!id) {
return;
}
const target = mobileNav.filteredItems.find((item) => String(flowToolbarProps.getId(item)) === id);
if (!target) {
return;
}
navigate(mergeHrefWithSearchParams(flowToolbarProps.getHref(target), searchParams), { replace: true });
},
[flowToolbarProps, mobileNav.filteredItems, navigate, searchParams],
);
const mobileNavSelectItem = useCallback(
(item: FlowItem) => {
setIsMobileNavSheetOpen(false);
navigate(mergeHrefWithSearchParams(flowToolbarProps.getHref(item), searchParams), { replace: true });
},
[flowToolbarProps, navigate, searchParams],
);
const mobilePositionLabel = useMemo(
() =>
mobileNav.total === 0 || mobileNav.currentIndex === -1
? `/${mobileNav.total}`
: `${mobileNav.currentIndex + 1}/${mobileNav.total}`,
[mobileNav.currentIndex, mobileNav.total],
);
// Single controller drives the desktop toolbar AND the mobile dropdown
// row + sheet — Prev/Next, sheet open state, and the position label all
// live on one source of truth.
const flowNav = useFlowDetailNavigation(flowId);
const {
handleDropdownCloseAutoFocus,
@@ -389,24 +361,8 @@ const Flow = () => {
<div className="flex shrink-0 items-center gap-2">
{flow && !isMobile && (
<DetailNavigationToolbar<FlowItem>
{...flowToolbarProps}
renderItem={(item, isCurrent) => (
<>
<FlowStatusIcon
className="size-3 shrink-0"
status={item.status}
/>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>
{item.title || `Flow #${item.id}`}
</span>
<Badge
className="ml-auto shrink-0 font-mono text-[10px]"
variant="outline"
>
#{item.id}
</Badge>
</>
)}
controller={flowNav}
renderItem={renderFlowItem}
sheetIcon={<GitFork className="size-4" />}
sheetTitle="Flows"
/>
@@ -438,15 +394,13 @@ const Flow = () => {
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && mobileNav.total > 0 && (
{isMobile && flowNav.total > 0 && (
<>
{/* Single row that mirrors the desktop toolbar: label on
the left, prev / position / next button group on the
right. `onSelect={preventDefault}` stops the menu from
closing on label clicks; the inner buttons own their
own click handlers and don't bubble into menu-item
selection. Position button doubles as the sheet
trigger, matching the toolbar's middle-button role. */}
closing on label clicks; `<DetailNavigationButtons>`
owns its own click handlers and tooltips. */}
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
@@ -454,35 +408,11 @@ const Flow = () => {
<GitFork className="size-4" />
Flows
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<Button
aria-label="Previous"
className="size-7 rounded-r-none border-r-0 p-0"
disabled={!mobileNav.prevId}
onClick={() => mobileNavGoTo(mobileNav.prevId)}
size="icon"
variant="outline"
>
<ChevronLeft />
</Button>
<Button
aria-label="Open flows list"
className="h-7 min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums"
disabled={mobileNav.currentIndex === -1}
onClick={() => setIsMobileNavSheetOpen(true)}
variant="outline"
>
{mobilePositionLabel}
</Button>
<Button
aria-label="Next"
className="size-7 rounded-l-none border-l-0 p-0"
disabled={!mobileNav.nextId}
onClick={() => mobileNavGoTo(mobileNav.nextId)}
size="icon"
variant="outline"
>
<ChevronRight />
</Button>
<DetailNavigationButtons<FlowItem>
controller={flowNav}
sheetTitle="Flows"
size="sm"
/>
</div>
</DropdownMenuItem>
{flowId && (
@@ -549,34 +479,10 @@ const Flow = () => {
</header>
{isMobile && flow && (
<DetailNavigationSheet<FlowItem>
currentId={flowToolbarProps.currentId}
currentIndex={mobileNav.currentIndex}
getId={flowToolbarProps.getId}
getLabel={flowToolbarProps.getLabel}
items={mobileNav.filteredItems}
onItemSelect={mobileNavSelectItem}
onOpenChange={setIsMobileNavSheetOpen}
open={isMobileNavSheetOpen}
renderItem={(item, isCurrent) => (
<>
<FlowStatusIcon
className="size-3 shrink-0"
status={item.status}
/>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>
{item.title || `Flow #${item.id}`}
</span>
<Badge
className="ml-auto shrink-0 font-mono text-[10px]"
variant="outline"
>
#{item.id}
</Badge>
</>
)}
controller={flowNav}
renderItem={renderFlowItem}
sheetIcon={<GitFork className="size-4" />}
sheetTitle="Flows"
total={mobileNav.total}
/>
)}
<div className="relative flex h-[calc(100dvh-3rem)] w-full max-w-full flex-1">
+23 -95
View File
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Ellipsis,
FileSymlink,
FileText,
@@ -15,12 +15,16 @@ import {
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { z } from 'zod';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DetailNavigationSheet, DetailNavigationToolbar, useNavigation } from '@/components/shared/detail-navigation';
import {
DetailNavigationButtons,
DetailNavigationSheet,
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
@@ -45,7 +49,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useTemplateDetailNavigation } from '@/features/templates/use-template-detail-navigation';
import { useFlowTemplateQuery } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { cn } from '@/lib/utils';
import { type Template, useTemplates } from '@/providers/templates-provider';
@@ -233,9 +236,12 @@ Action plan:
},
];
const renderTemplateItem = (item: Template, isCurrent: boolean): ReactNode => (
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.title}</span>
);
const Template = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { templateId } = useParams<{ templateId?: string }>();
const { createTemplate, deleteTemplate, updateTemplate } = useTemplates();
@@ -245,55 +251,7 @@ const Template = () => {
// Pass `null` while creating a new template — there is no "current item"
// to highlight, and the toolbar shouldn't render at all anyway (gated
// below by `canShowActions`).
const { toolbarProps: templateToolbarProps } = useTemplateDetailNavigation(isNew ? null : templateId);
// Mirror what `<DetailNavigationToolbar>` computes internally so the
// mobile menu items share the same filtered subset as the desktop toolbar.
const mobileNav = useNavigation<Template>({
currentId: templateToolbarProps.currentId,
getId: templateToolbarProps.getId,
getSearchableText: templateToolbarProps.getSearchableText ?? templateToolbarProps.getLabel,
items: templateToolbarProps.items,
query: templateToolbarProps.filter,
});
const [isMobileNavSheetOpen, setIsMobileNavSheetOpen] = useState(false);
const mobileNavGoTo = useCallback(
(id: null | string) => {
if (!id) {
return;
}
const target = mobileNav.filteredItems.find((item) => String(templateToolbarProps.getId(item)) === id);
if (!target) {
return;
}
navigate(mergeHrefWithSearchParams(templateToolbarProps.getHref(target), searchParams), {
replace: true,
});
},
[mobileNav.filteredItems, navigate, searchParams, templateToolbarProps],
);
const mobileNavSelectItem = useCallback(
(item: Template) => {
setIsMobileNavSheetOpen(false);
navigate(mergeHrefWithSearchParams(templateToolbarProps.getHref(item), searchParams), {
replace: true,
});
},
[navigate, searchParams, templateToolbarProps],
);
const mobilePositionLabel = useMemo(
() =>
mobileNav.total === 0 || mobileNav.currentIndex === -1
? `/${mobileNav.total}`
: `${mobileNav.currentIndex + 1}/${mobileNav.total}`,
[mobileNav.currentIndex, mobileNav.total],
);
const templateNav = useTemplateDetailNavigation(isNew ? null : templateId);
const [isAsideOpen, setIsAsideOpen] = useState(false);
const [expandedPresetIndex, setExpandedPresetIndex] = useState<null | number>(null);
@@ -491,7 +449,8 @@ const Template = () => {
<div className="flex shrink-0 items-center gap-2">
{canShowActions && !isMobile && (
<DetailNavigationToolbar<Template>
{...templateToolbarProps}
controller={templateNav}
renderItem={renderTemplateItem}
sheetIcon={<FileText className="size-4" />}
sheetTitle="Templates"
/>
@@ -519,7 +478,7 @@ const Template = () => {
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && mobileNav.total > 0 && (
{isMobile && templateNav.total > 0 && (
<>
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
@@ -528,35 +487,11 @@ const Template = () => {
<FileText className="size-4" />
Templates
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<Button
aria-label="Previous"
className="size-7 rounded-r-none border-r-0 p-0"
disabled={!mobileNav.prevId}
onClick={() => mobileNavGoTo(mobileNav.prevId)}
size="icon"
variant="outline"
>
<ChevronLeft />
</Button>
<Button
aria-label="Open templates list"
className="h-7 min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums"
disabled={mobileNav.currentIndex === -1}
onClick={() => setIsMobileNavSheetOpen(true)}
variant="outline"
>
{mobilePositionLabel}
</Button>
<Button
aria-label="Next"
className="size-7 rounded-l-none border-l-0 p-0"
disabled={!mobileNav.nextId}
onClick={() => mobileNavGoTo(mobileNav.nextId)}
size="icon"
variant="outline"
>
<ChevronRight />
</Button>
<DetailNavigationButtons<Template>
controller={templateNav}
sheetTitle="Templates"
size="sm"
/>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -590,17 +525,10 @@ const Template = () => {
</header>
{isMobile && canShowActions && (
<DetailNavigationSheet<Template>
currentId={templateToolbarProps.currentId}
currentIndex={mobileNav.currentIndex}
getId={templateToolbarProps.getId}
getLabel={templateToolbarProps.getLabel}
items={mobileNav.filteredItems}
onItemSelect={mobileNavSelectItem}
onOpenChange={setIsMobileNavSheetOpen}
open={isMobileNavSheetOpen}
controller={templateNav}
renderItem={renderTemplateItem}
sheetIcon={<FileText className="size-4" />}
sheetTitle="Templates"
total={mobileNav.total}
/>
)}
</>