39948-vm/frontend/src/hooks/usePageNavigation.ts

196 lines
5.6 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import type { NavigationContext } from '../lib/navigationHelpers';
/**
* Maximum history entries to prevent unbounded growth in long sessions.
* Matches typical browser behavior (50 entries).
*/
const MAX_HISTORY_LENGTH = 50;
/**
* Minimal page interface for navigation
*/
export interface NavigablePage {
id: string;
sort_order?: number;
slug?: string;
}
export interface UsePageNavigationOptions<TPage extends NavigablePage> {
pages: TPage[];
defaultPageId?: string;
trackHistory?: boolean;
onPageChange?: (pageId: string, isBack: boolean) => void;
}
export interface UsePageNavigationResult<TPage extends NavigablePage> {
currentPageId: string | null;
currentPage: TPage | null;
pageHistory: string[];
previousPageId: string | null;
defaultPage: TPage | null;
setCurrentPageId: (pageId: string) => void;
applyPageSelection: (targetPageId: string, isBack?: boolean) => void;
isBackNavigation: (targetPageId: string) => boolean;
goBack: () => boolean;
resetHistory: () => void;
/**
* Get navigation context for history-based back navigation.
* Provides currentPageSlug and previousPageId for resolveNavigationTarget().
*/
getNavigationContext: () => NavigationContext;
}
/**
* Hook for managing page navigation state with optional history tracking.
* Default page is the first page by sort_order.
*
* @example
* // Basic usage (runtime)
* const nav = usePageNavigation({
* pages,
* trackHistory: true,
* });
*
* // Editor usage (constructor) - no history
* const nav = usePageNavigation({
* pages,
* defaultPageId: initialPageId,
* trackHistory: false,
* });
*/
export function usePageNavigation<TPage extends NavigablePage>(
options: UsePageNavigationOptions<TPage>,
): UsePageNavigationResult<TPage> {
const { pages, defaultPageId, trackHistory = true, onPageChange } = options;
const [currentPageId, setCurrentPageIdState] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
// Compute default page (by ID or first by sort order)
const defaultPage = useMemo(() => {
if (!pages.length) return null;
// Try explicit default page ID
if (defaultPageId) {
const byId = pages.find((p) => p.id === defaultPageId);
if (byId) return byId;
}
// Fall back to first by sort order
return [...pages].sort(
(a, b) => (a.sort_order || 0) - (b.sort_order || 0),
)[0];
}, [pages, defaultPageId]);
// Get current page object
const currentPage = useMemo(() => {
if (!pages.length) return null;
if (currentPageId) {
return pages.find((p) => p.id === currentPageId) || null;
}
return defaultPage;
}, [pages, currentPageId, defaultPage]);
// Previous page ID from history
const previousPageId = useMemo(() => {
if (pageHistory.length < 2) return null;
return pageHistory[pageHistory.length - 2];
}, [pageHistory]);
// Check if navigating to a page would be a "back" navigation
const isBackNavigation = useCallback(
(targetPageId: string): boolean => {
return previousPageId === targetPageId;
},
[previousPageId],
);
// Apply page selection with history tracking
const applyPageSelection = useCallback(
(targetPageId: string, isBack = false) => {
setCurrentPageIdState(targetPageId);
if (trackHistory) {
setPageHistory((prev) => {
if (!prev.length) return [targetPageId];
const currentId = prev[prev.length - 1];
if (currentId === targetPageId) return prev;
// If going back and target matches previous, pop history (browser-like behavior)
if (
isBack &&
prev.length > 1 &&
prev[prev.length - 2] === targetPageId
) {
return prev.slice(0, -1);
}
// Add to history and trim to max length (keep most recent entries)
const newHistory = [...prev, targetPageId];
return newHistory.slice(-MAX_HISTORY_LENGTH);
});
}
onPageChange?.(targetPageId, isBack);
},
[trackHistory, onPageChange],
);
// Simple setter (no history tracking)
const setCurrentPageId = useCallback(
(pageId: string) => {
setCurrentPageIdState(pageId);
if (trackHistory && pageHistory.length === 0) {
setPageHistory([pageId]);
}
},
[trackHistory, pageHistory.length],
);
// Go back in history
const goBack = useCallback((): boolean => {
if (!trackHistory || pageHistory.length < 2) return false;
const targetPageId = pageHistory[pageHistory.length - 2];
applyPageSelection(targetPageId, true);
return true;
}, [trackHistory, pageHistory, applyPageSelection]);
// Reset history
const resetHistory = useCallback(() => {
setPageHistory(currentPageId ? [currentPageId] : []);
}, [currentPageId]);
// Get navigation context for history-based back navigation
const getNavigationContext = useCallback((): NavigationContext => {
return {
currentPageSlug: currentPage?.slug,
previousPageId,
};
}, [currentPage?.slug, previousPageId]);
// Initialize to default page
useEffect(() => {
if (defaultPage?.id && !currentPageId) {
setCurrentPageIdState(defaultPage.id);
if (trackHistory) {
setPageHistory((prev) => (prev.length ? prev : [defaultPage.id]));
}
}
}, [defaultPage, currentPageId, trackHistory]);
return {
currentPageId,
currentPage,
pageHistory,
previousPageId,
defaultPage,
setCurrentPageId,
applyPageSelection,
isBackNavigation,
goBack,
resetHistory,
getNavigationContext,
};
}