Skip to content

Commit

Permalink
E 1686 feature sidepanel layout (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexbeno authored Aug 12, 2024
1 parent ff191d3 commit 928dd45
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 3 deletions.
49 changes: 46 additions & 3 deletions app/test/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"use client";

import { TestSidePanel } from "@/app/test/_components/side-panel/test-side-panel";
import { useRef } from "react";

import { AnimatedColumnGroup } from "@/shared/components/animated-column-group/animated-column-group";
import { AnimatedColumn } from "@/shared/components/animated-column-group/animated-column/animated-column";
import { SecondaryNavigation } from "@/shared/features/navigation/secondary-navigation/secondary-navigation";
import { SidePanelGroup } from "@/shared/features/side-panel-group/side-panel-group";
import { SidePanelGroupRef } from "@/shared/features/side-panel-group/side-panel-group.types";
import { SidePanel } from "@/shared/features/side-panel-group/side-panel/side-panel";

export default function TestPage() {
const sidePanelRef = useRef<SidePanelGroupRef>(null);
return (
<div className={"flex h-full w-full flex-col gap-3 overflow-hidden"}>
<SecondaryNavigation
Expand All @@ -18,9 +22,48 @@ export default function TestPage() {
/>
<AnimatedColumnGroup>
<AnimatedColumn autoWidth={true} className="h-full flex-1 overflow-auto bg-container-2">
<div className={"h-[5000px]"}>CONTENT</div>
<div className={"h-[5000px]"}>
<div>
<button onClick={() => sidePanelRef.current?.onBack()}>Back</button>
<button onClick={() => sidePanelRef.current?.onNext()}>Next</button>
</div>

<div>
<button onClick={() => sidePanelRef.current?.openPanel()}>OPEN PANEL 1</button>
<button onClick={() => sidePanelRef.current?.closePanel()}>Close Panels</button>
</div>

<div>
<button onClick={() => sidePanelRef.current?.openPanel("panel2")}>Open PANEL 2</button>
<button onClick={() => sidePanelRef.current?.closePanel("panel2")}>Close PANEL 2</button>
</div>
</div>
</AnimatedColumn>
<TestSidePanel />
<SidePanelGroup
ref={sidePanelRef}
panels={["panel1", "panel2"]}
defaultPanelName={"panel1"}
config={{ closedWidth: 0, openedWidth: 370 }}
>
<SidePanel name={"panel1"}>
{({ onClose, onNext }) => (
<div className={"h-full bg-blue-500"}>
<div>PANEL 1</div>
<button onClick={() => onNext()}>Next</button>
<button onClick={() => onClose()}>Close</button>
</div>
)}
</SidePanel>
<SidePanel name={"panel2"}>
{({ onClose, onBack }) => (
<div className={"h-full bg-red-500"}>
<div>PANEL 2</div>
<button onClick={() => onClose()}>Close Panel 2</button>
<button onClick={() => onBack()}>Back</button>
</div>
)}
</SidePanel>
</SidePanelGroup>
</AnimatedColumnGroup>
</div>
);
Expand Down
101 changes: 101 additions & 0 deletions shared/features/side-panel-group/side-panel-group.context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { createContext, useContext, useMemo, useState } from "react";

import { AnimatedColumn } from "@/shared/components/animated-column-group/animated-column/animated-column";
import {
SidePanelGroupContextInterface,
SidePanelGroupContextProps,
} from "@/shared/features/side-panel-group/side-panel-group.types";

export const SidePanelGroupContext = createContext<SidePanelGroupContextInterface>({
isPanelOpen: () => false,
openPanel: () => {},
closePanel: () => {},
getOpenedPanelIndex: () => 0,
panelWidth: 0,
onBack: () => {},
onNext: () => {},
});

export function SidePanelGroupProvider({
children,
defaultPanelName,
defaultOpen,
config,
panels,
}: SidePanelGroupContextProps) {
const [openedPanels, setOpenedPanels] = useState<string[]>(defaultOpen ? [defaultPanelName] : []);

function isPanelOpen(name: string) {
return openedPanels.includes(name);
}

function openPanel(name?: string) {
if (name) {
setOpenedPanels(panels?.slice(0, panels.indexOf(name) + 1));
} else {
setOpenedPanels([defaultPanelName]);
}
}

function closePanel(name?: string) {
if (name) {
setOpenedPanels(panels?.slice(0, panels.indexOf(name)));
} else {
setOpenedPanels([]);
}
}

function getOpenedPanelIndex() {
return (openedPanels?.length || 0) - 1;
}

function onBack() {
if (openedPanels.length === 1) {
return;
}
setOpenedPanels(prev => prev.slice(0, prev.length - 1));
}

function onNext() {
if (openedPanels.length > panels.length - 1) {
return;
}
setOpenedPanels(prev => [...prev, panels[panels.indexOf(prev[prev.length - 1]) + 1]]);
}

const panelSize = useMemo(() => {
if (openedPanels.length === 0) {
return config.closedWidth;
}

return config.openedWidth;
}, [openedPanels, config]);

return (
<SidePanelGroupContext.Provider
value={{
isPanelOpen,
openPanel,
closePanel,
getOpenedPanelIndex,
onBack,
onNext,
panelWidth: config.openedWidth,
}}
>
<AnimatedColumn autoWidth={false} width={panelSize} initialWidth={config.closedWidth} className="h-full">
{children}
</AnimatedColumn>
</SidePanelGroupContext.Provider>
);
}

export function useSidePanelGroup() {
const context = useContext(SidePanelGroupContext);
if (!context) {
throw new Error("SidePanel must be used inside a SidePanelGroup");
}
return context;
}
51 changes: 51 additions & 0 deletions shared/features/side-panel-group/side-panel-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";

import { motion } from "framer-motion";
import { ForwardedRef, forwardRef, useImperativeHandle } from "react";

import { SidePanelGroupProvider, useSidePanelGroup } from "@/shared/features/side-panel-group/side-panel-group.context";
import { cn } from "@/shared/helpers/cn";

import { SidePanelGroupProps, SidePanelGroupRef } from "./side-panel-group.types";

export const SafeSidePanelGroup = forwardRef(function SafeSidePanelGroup(
{ children, className }: SidePanelGroupProps,
ref: ForwardedRef<SidePanelGroupRef>
) {
const { openPanel, closePanel, panelWidth, getOpenedPanelIndex, onBack, onNext } = useSidePanelGroup();

useImperativeHandle(ref, () => {
return {
openPanel,
closePanel,
onBack,
onNext,
};
}, [openPanel, closePanel, onNext, onBack]);

return (
<div className={cn("h-full w-full overflow-hidden", className?.wrapper)}>
<motion.div
className={cn("flex h-full justify-start", className?.mover)}
style={{ transform: "translateX(0)" }}
animate={{ transform: `translateX(-${panelWidth * getOpenedPanelIndex()}px)` }}
>
{children}
</motion.div>
</div>
);
});

export const SidePanelGroup = forwardRef(function SidePanelGroup(
props: SidePanelGroupProps,
ref: ForwardedRef<SidePanelGroupRef>
) {
const { children, ...contextProps } = props;
return (
<SidePanelGroupProvider {...contextProps}>
<SafeSidePanelGroup {...props} ref={ref}>
{children}
</SafeSidePanelGroup>
</SidePanelGroupProvider>
);
});
41 changes: 41 additions & 0 deletions shared/features/side-panel-group/side-panel-group.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { PropsWithChildren } from "react";

export interface SidePanelGroupContextInterface {
isPanelOpen: (name: string) => boolean;
openPanel: (name?: string) => void;
closePanel: (name?: string) => void;
panelWidth: number;
getOpenedPanelIndex: () => number;
onBack: () => void;
onNext: () => void;
}

export interface SidePanelGroupContextProps extends PropsWithChildren {
defaultPanelName: string;
defaultOpen?: boolean;
panels: string[];
config: {
closedWidth: number;
openedWidth: number;
};
}

interface classNames {
wrapper: string;
mover: string;
}

export interface SidePanelGroupProps extends PropsWithChildren {
defaultPanelName: SidePanelGroupContextProps["defaultPanelName"];
defaultOpen?: SidePanelGroupContextProps["defaultOpen"];
panels: SidePanelGroupContextProps["panels"];
config: SidePanelGroupContextProps["config"];
className?: Partial<classNames>;
}

export interface SidePanelGroupRef {
openPanel: SidePanelGroupContextInterface["openPanel"];
closePanel: SidePanelGroupContextInterface["closePanel"];
onBack: SidePanelGroupContextInterface["onBack"];
onNext: SidePanelGroupContextInterface["onNext"];
}
21 changes: 21 additions & 0 deletions shared/features/side-panel-group/side-panel/side-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { useSidePanelGroup } from "@/shared/features/side-panel-group/side-panel-group.context";
import { cn } from "@/shared/helpers/cn";

import { SidePanelProps } from "./side-panel.types";

export function SidePanel({ children, name, className }: SidePanelProps) {
const { panelWidth, openPanel, closePanel, onBack, onNext } = useSidePanelGroup();

const renderChildren =
typeof children === "function"
? children({ name, onClose: () => closePanel(name), onOpen: openPanel, onBack, onNext })
: children;

return (
<div className={cn("h-full", className)} style={{ minWidth: panelWidth, width: panelWidth }}>
{renderChildren}
</div>
);
}
16 changes: 16 additions & 0 deletions shared/features/side-panel-group/side-panel/side-panel.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ReactNode } from "react";

import { SidePanelGroupRef } from "@/shared/features/side-panel-group/side-panel-group.types";

interface RenderProps {
name: string;
onClose: () => void;
onOpen: SidePanelGroupRef["openPanel"];
onNext: SidePanelGroupRef["onNext"];
onBack: SidePanelGroupRef["onBack"];
}
export interface SidePanelProps {
name: string;
children: ((props: RenderProps) => ReactNode) | ReactNode;
className?: string;
}

0 comments on commit 928dd45

Please sign in to comment.