mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 12:36:30 +00:00
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:
co-authored by
Claude Opus 4.7
parent
8dbee4fb64
commit
7dae4be61d
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+47
-259
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user