/** * React Fabric * @version 1.0.0 * @license MIT * @copyright 2024 Adarsh Pastakia * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { cn } from "@react-fabric/utilities"; import { Activity, useEffect, useMemo, useRef, useState } from "react"; import { StyleEffect } from "../../components/effect/StyleEffect"; import { Icon } from "../../components/icon/Icon"; import type { TitlebarProps } from "../../components/titlebar/Titlebar"; import { Titlebar } from "../../components/titlebar/Titlebar"; import { useGlobals } from "../../context/context"; import { useLayoutEffectDebugger } from "../../hooks/useEffectDebugger"; import { usePropState } from "../../hooks/usePropState"; import { usePropToggle } from "../../hooks/usePropToggle"; import { useResize } from "../../hooks/useResize"; import type { ChildrenProp, CollapseProps, CssPropWithMap, TestProps } from "../../types"; import { getIconProps } from "../../utils"; import { ErrorBoundary } from "../boundary/ErrorBoundary"; export interface AsideProps extends ChildrenProp, CollapseProps, CssPropWithMap<{ root?: string; titlebar?: string; body?: string; title?: string; icon?: string; actions?: string; }>, TestProps { /** * align inline-end */ align?: "start" | "end"; /** * margin (uses tailwind spacing calculation, ie. 2 = 0.5rem) */ margin?: number; /** * show loading indicator */ loading?: boolean; /** * allow resize */ resizeable?: boolean; /** * allow flyout with collapsed */ enableFlyout?: boolean; /** * event handler when shown */ onFlyout?: () => void; id?: string; hideCollapseHandle?: boolean; /** * fixed width */ width?: number | string; /** * max width */ maxWidth?: number | string; /** * min width */ minWidth?: number | string; /** * Resize event */ onResize?: (width: number) => void; } /** * A sidebar component that can be collapsed, resized, and has a flyout feature. * It supports a title, icon, actions, and loading state. * It can be aligned to the start or end of the page. * * @example * ```jsx * * ``` */ export function Aside({ children, title, icon, id, margin, hideCollapseHandle, className, classNames, resizeable, enableFlyout, onFlyout, onResize, collapsed: _collapsed, collapsable, onCollapse, actions, loading, align = "start", width: wd = "20rem", minWidth = "2rem", maxWidth = "30vw", "data-testid": testId, "data-test-value": testValue, }: AsideProps & TitlebarProps) { const elementRef = useRef(null); const [width, setWidth] = usePropState(wd); const [peek, setPeek] = useState(false); const { registerArea, unregisterArea } = useGlobals(); const [collapsed, onToggle] = usePropToggle(_collapsed, onCollapse); const handleResizeRef = useRef(({ x }: { x: number }) => { if (elementRef.current != null) { if (x !== 0) setWidth(elementRef.current.offsetWidth + x); } }); const { ref, isResizing, onMouseDown } = useResize(handleResizeRef.current, { isReverse: align === "end", onEnd() { const width = (ref.current?.parentElement as HTMLElement)?.offsetWidth; setWidth(width); onResize?.(width ?? 0); }, }); const tryPeek = (force?: boolean) => { if (collapsed) { if (enableFlyout) { setPeek(force ?? !peek); if (force ?? !peek) onFlyout?.(); } else { void onToggle(force); } } }; useLayoutEffectDebugger( () => { if (!isResizing && peek) { const handler = (e: MouseEvent) => { if ( (e.target as HTMLElement).closest(`[data-dismiss="true"]`) !== null || ((e.target as HTMLElement).closest(`[data-ref="aside-container"]`) === null && (e.target as HTMLElement).closest(`[data-ref="aside-placeholder"]`) === null) ) { setPeek(false); } }; document.addEventListener("mouseup", handler); return () => document.removeEventListener("mouseup", handler); } }, [peek, isResizing], "Aside peek", ); useEffect(() => { if (id) { registerArea(id, (open) => { tryPeek(open); }); return () => unregisterArea(id); } // eslint-disable-next-line @eslint-react/exhaustive-deps }, [id]); const widthStyles = useMemo( () => ({ width, minWidth, maxWidth, }), [width, minWidth, maxWidth], ); return ( ); }