196 lines
5.6 KiB
TypeScript
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,
|
|
};
|
|
}
|