Some checks failed
continuous-integration/drone/push Build encountered an error
265 lines
7.1 KiB
TypeScript
265 lines
7.1 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine';
|
|
|
|
const SIDEBAR_WIDTH_KEY = 'authSidebarWidth';
|
|
const SIDEBAR_COLLAPSED_KEY = 'authSidebarCollapsed';
|
|
const SIDEBAR_DEFAULT_WIDTH = 280;
|
|
const SIDEBAR_MIN_WIDTH = 220;
|
|
const SIDEBAR_MAX_WIDTH = 420;
|
|
const SIDEBAR_COLLAPSED_WIDTH = 56;
|
|
|
|
export type LeftMenuSizing = {
|
|
defaultWidth?: number;
|
|
minWidth?: number;
|
|
maxWidth?: number;
|
|
collapsedWidth?: number;
|
|
};
|
|
|
|
export type LeftMenuRenderState = {
|
|
collapsed: boolean;
|
|
mobileOpen: boolean;
|
|
isDesktop: boolean;
|
|
closeMenu: () => void;
|
|
};
|
|
|
|
export type LeftMenuContent = {
|
|
ariaLabel?: string;
|
|
render: (state: LeftMenuRenderState) => ReactNode;
|
|
};
|
|
|
|
export type LeftMenuStyle = CSSProperties & {
|
|
'--auth-sidebar-width': string;
|
|
};
|
|
|
|
type LeftMenuContextValue = {
|
|
collapsed: boolean;
|
|
mobileOpen: boolean;
|
|
content: LeftMenuContent;
|
|
desktopMenuStyle: LeftMenuStyle;
|
|
openMenu: (content?: LeftMenuContent) => void;
|
|
closeMenu: () => void;
|
|
toggleMenu: (content?: LeftMenuContent) => void;
|
|
expandMenu: () => void;
|
|
collapseMenu: () => void;
|
|
toggleCollapsed: () => void;
|
|
setMenuContent: (content: LeftMenuContent | null) => void;
|
|
startResize: ReturnType<typeof useSidePanelMachine>['startResize'];
|
|
};
|
|
|
|
type LeftMenuProviderProps = {
|
|
children: ReactNode;
|
|
defaultContent: LeftMenuContent;
|
|
closeOnPathname?: string;
|
|
sizing?: LeftMenuSizing;
|
|
};
|
|
|
|
const LeftMenuContext = createContext<LeftMenuContextValue | undefined>(undefined);
|
|
|
|
function readSizingValue(value: number | undefined, fallback: number): number {
|
|
if (!Number.isFinite(value) || value == null || value <= 0) {
|
|
return fallback;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function resolveLeftMenuSizing(sizing: LeftMenuSizing | undefined) {
|
|
const requestedMinWidth = readSizingValue(sizing?.minWidth, SIDEBAR_MIN_WIDTH);
|
|
const requestedMaxWidth = readSizingValue(sizing?.maxWidth, SIDEBAR_MAX_WIDTH);
|
|
const minWidth = Math.min(requestedMinWidth, requestedMaxWidth);
|
|
const maxWidth = Math.max(requestedMinWidth, requestedMaxWidth);
|
|
const requestedDefaultWidth = readSizingValue(sizing?.defaultWidth, SIDEBAR_DEFAULT_WIDTH);
|
|
const defaultWidth = Math.min(maxWidth, Math.max(minWidth, requestedDefaultWidth));
|
|
const requestedCollapsedWidth = readSizingValue(
|
|
sizing?.collapsedWidth,
|
|
SIDEBAR_COLLAPSED_WIDTH,
|
|
);
|
|
const collapsedWidth = Math.min(minWidth, requestedCollapsedWidth);
|
|
|
|
return {
|
|
defaultWidth,
|
|
minWidth,
|
|
maxWidth,
|
|
collapsedWidth,
|
|
};
|
|
}
|
|
|
|
function readStoredCollapsed(): boolean {
|
|
if (!globalThis.window) {
|
|
return false;
|
|
}
|
|
|
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
|
|
}
|
|
|
|
export function LeftMenuProvider({
|
|
children,
|
|
defaultContent,
|
|
closeOnPathname,
|
|
sizing,
|
|
}: Readonly<LeftMenuProviderProps>) {
|
|
const { defaultWidth, minWidth, maxWidth, collapsedWidth } = resolveLeftMenuSizing(sizing);
|
|
const [collapsed, setCollapsed] = useState<boolean>(() => readStoredCollapsed());
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [content, setContent] = useState<LeftMenuContent>(defaultContent);
|
|
const defaultContentRef = useRef(defaultContent);
|
|
|
|
useEffect(() => {
|
|
const previousDefaultContent = defaultContentRef.current;
|
|
defaultContentRef.current = defaultContent;
|
|
setContent((currentContent) => {
|
|
if (currentContent === previousDefaultContent) {
|
|
return defaultContent;
|
|
}
|
|
return currentContent;
|
|
});
|
|
}, [defaultContent]);
|
|
|
|
useEffect(() => {
|
|
if (!globalThis.window) {
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? '1' : '0');
|
|
}, [collapsed]);
|
|
|
|
const expandMenu = useCallback(() => {
|
|
setCollapsed(false);
|
|
}, []);
|
|
|
|
const collapseMenu = useCallback(() => {
|
|
setCollapsed(true);
|
|
}, []);
|
|
|
|
const toggleCollapsed = useCallback(() => {
|
|
setCollapsed((previous) => !previous);
|
|
}, []);
|
|
|
|
const closeMobile = useCallback(() => {
|
|
setMobileOpen(false);
|
|
}, []);
|
|
|
|
const setMenuContent = useCallback((nextContent: LeftMenuContent | null) => {
|
|
setContent(nextContent ?? defaultContentRef.current);
|
|
}, []);
|
|
|
|
const closeMenu = useCallback(() => {
|
|
if (isDesktopViewport()) {
|
|
collapseMenu();
|
|
return;
|
|
}
|
|
|
|
closeMobile();
|
|
}, [collapseMenu, closeMobile]);
|
|
|
|
const openMenu = useCallback(
|
|
(nextContent?: LeftMenuContent) => {
|
|
if (nextContent) {
|
|
setContent(nextContent);
|
|
}
|
|
|
|
if (isDesktopViewport()) {
|
|
expandMenu();
|
|
return;
|
|
}
|
|
|
|
setMobileOpen(true);
|
|
},
|
|
[expandMenu],
|
|
);
|
|
|
|
const toggleMenu = useCallback(
|
|
(nextContent?: LeftMenuContent) => {
|
|
if (nextContent) {
|
|
setContent(nextContent);
|
|
}
|
|
|
|
if (isDesktopViewport()) {
|
|
toggleCollapsed();
|
|
return;
|
|
}
|
|
|
|
setMobileOpen((previous) => !previous);
|
|
},
|
|
[toggleCollapsed],
|
|
);
|
|
|
|
const handleCloseOnPathname = useCallback(() => {
|
|
setMobileOpen(false);
|
|
setContent(defaultContentRef.current);
|
|
}, []);
|
|
|
|
const { width, startResize } = useSidePanelMachine({
|
|
storageKey: SIDEBAR_WIDTH_KEY,
|
|
defaultWidth,
|
|
minWidth,
|
|
maxWidth,
|
|
resizeAxis: 'from-left',
|
|
resizingBodyClass: 'auth-sidebar-resizing',
|
|
isOpen: mobileOpen,
|
|
canResize: !collapsed,
|
|
shouldPersistWidth: !collapsed,
|
|
closeOnPathname,
|
|
onCloseOnPathname: handleCloseOnPathname,
|
|
onEscape: closeMobile,
|
|
});
|
|
|
|
const desktopMenuStyle = useMemo<LeftMenuStyle>(
|
|
() => ({
|
|
'--auth-sidebar-width': `${collapsed ? collapsedWidth : width}px`,
|
|
}),
|
|
[collapsed, collapsedWidth, width],
|
|
);
|
|
|
|
const value = useMemo<LeftMenuContextValue>(
|
|
() => ({
|
|
collapsed,
|
|
mobileOpen,
|
|
content,
|
|
desktopMenuStyle,
|
|
openMenu,
|
|
closeMenu,
|
|
toggleMenu,
|
|
expandMenu,
|
|
collapseMenu,
|
|
toggleCollapsed,
|
|
setMenuContent,
|
|
startResize,
|
|
}),
|
|
[
|
|
collapsed,
|
|
mobileOpen,
|
|
content,
|
|
desktopMenuStyle,
|
|
openMenu,
|
|
closeMenu,
|
|
toggleMenu,
|
|
expandMenu,
|
|
collapseMenu,
|
|
toggleCollapsed,
|
|
setMenuContent,
|
|
startResize,
|
|
],
|
|
);
|
|
|
|
return <LeftMenuContext.Provider value={value}>{children}</LeftMenuContext.Provider>;
|
|
}
|
|
|
|
export function useLeftMenu() {
|
|
const ctx = useContext(LeftMenuContext);
|
|
if (!ctx) {
|
|
throw new Error('useLeftMenu must be used within LeftMenuProvider');
|
|
}
|
|
return ctx;
|
|
}
|