33
common/resources/client/ui/layout/dashbboard-layout.css
vendored
Executable file
33
common/resources/client/ui/layout/dashbboard-layout.css
vendored
Executable file
@@ -0,0 +1,33 @@
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-areas: "navbar navbar navbar"
|
||||
"sidenav-left header header"
|
||||
"sidenav-left content sidenav-right"
|
||||
"footer footer footer";
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
.dashboard-grid-content {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.dashboard-grid-header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.dashboard-grid-navbar {
|
||||
grid-area: navbar;
|
||||
}
|
||||
|
||||
.dashboard-grid-sidenav-left {
|
||||
grid-area: sidenav-left;
|
||||
}
|
||||
|
||||
.dashboard-grid-sidenav-right{
|
||||
grid-area: sidenav-right;
|
||||
}
|
||||
|
||||
.dashboard-grid-footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
15
common/resources/client/ui/layout/dashboard-content-header.tsx
Executable file
15
common/resources/client/ui/layout/dashboard-content-header.tsx
Executable file
@@ -0,0 +1,15 @@
|
||||
import {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DashboardContentHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function DashboardContentHeader({
|
||||
children,
|
||||
className,
|
||||
}: DashboardContentHeaderProps) {
|
||||
return (
|
||||
<div className={clsx(className, 'dashboard-grid-header')}>{children}</div>
|
||||
);
|
||||
}
|
||||
19
common/resources/client/ui/layout/dashboard-content.tsx
Executable file
19
common/resources/client/ui/layout/dashboard-content.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {cloneElement, ReactElement} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DashboardContentProps {
|
||||
children: ReactElement<{className: string}>;
|
||||
isScrollable?: boolean;
|
||||
}
|
||||
export function DashboardContent({
|
||||
children,
|
||||
isScrollable = true,
|
||||
}: DashboardContentProps) {
|
||||
return cloneElement(children, {
|
||||
className: clsx(
|
||||
children.props.className,
|
||||
isScrollable && 'overflow-y-auto stable-scrollbar',
|
||||
'dashboard-grid-content'
|
||||
),
|
||||
});
|
||||
}
|
||||
17
common/resources/client/ui/layout/dashboard-layout-context.ts
Executable file
17
common/resources/client/ui/layout/dashboard-layout-context.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import {createContext} from 'react';
|
||||
|
||||
export type DashboardSidenavStatus = 'open' | 'closed' | 'compact';
|
||||
|
||||
export interface DashboardContextValue {
|
||||
leftSidenavStatus: DashboardSidenavStatus;
|
||||
setLeftSidenavStatus: (status: DashboardSidenavStatus) => void;
|
||||
rightSidenavStatus: DashboardSidenavStatus;
|
||||
setRightSidenavStatus: (status: DashboardSidenavStatus) => void;
|
||||
isMobileMode: boolean | null;
|
||||
leftSidenavCanBeCompact?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const DashboardLayoutContext = createContext<DashboardContextValue>(
|
||||
null!
|
||||
);
|
||||
121
common/resources/client/ui/layout/dashboard-layout.tsx
Executable file
121
common/resources/client/ui/layout/dashboard-layout.tsx
Executable file
@@ -0,0 +1,121 @@
|
||||
import {ComponentPropsWithoutRef, useCallback, useMemo} from 'react';
|
||||
import {
|
||||
DashboardLayoutContext,
|
||||
DashboardSidenavStatus,
|
||||
} from './dashboard-layout-context';
|
||||
import {Underlay} from '../overlays/underlay';
|
||||
import {AnimatePresence} from 'framer-motion';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {useMediaQuery} from '../../utils/hooks/use-media-query';
|
||||
import {
|
||||
getFromLocalStorage,
|
||||
setInLocalStorage,
|
||||
} from '../../utils/hooks/local-storage';
|
||||
import {useBlockBodyOverflow} from '../../utils/hooks/use-block-body-overflow';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DashboardLayoutProps extends ComponentPropsWithoutRef<'div'> {
|
||||
name: string;
|
||||
leftSidenavCanBeCompact?: boolean;
|
||||
leftSidenavStatus?: DashboardSidenavStatus;
|
||||
onLeftSidenavChange?: (status: DashboardSidenavStatus) => void;
|
||||
rightSidenavStatus?: DashboardSidenavStatus;
|
||||
initialRightSidenavStatus?: DashboardSidenavStatus;
|
||||
onRightSidenavChange?: (status: DashboardSidenavStatus) => void;
|
||||
height?: string;
|
||||
gridClassName?: string;
|
||||
blockBodyOverflow?: boolean;
|
||||
}
|
||||
export function DashboardLayout({
|
||||
children,
|
||||
leftSidenavStatus: leftSidenav,
|
||||
onLeftSidenavChange,
|
||||
rightSidenavStatus: rightSidenav,
|
||||
initialRightSidenavStatus,
|
||||
onRightSidenavChange,
|
||||
name,
|
||||
leftSidenavCanBeCompact,
|
||||
height = 'h-screen',
|
||||
className,
|
||||
gridClassName = 'dashboard-grid',
|
||||
blockBodyOverflow = true,
|
||||
...domProps
|
||||
}: DashboardLayoutProps) {
|
||||
useBlockBodyOverflow(!blockBodyOverflow);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
|
||||
const isCompactModeInitially = useMemo(() => {
|
||||
return !name ? false : getFromLocalStorage(`${name}.sidenav.compact`);
|
||||
}, [name]);
|
||||
const defaultLeftSidenavStatus = isCompactModeInitially ? 'compact' : 'open';
|
||||
const [leftSidenavStatus, setLeftSidenavStatus] = useControlledState(
|
||||
leftSidenav,
|
||||
isMobile ? 'closed' : defaultLeftSidenavStatus,
|
||||
onLeftSidenavChange,
|
||||
);
|
||||
|
||||
const rightSidenavStatusDefault = useMemo(() => {
|
||||
if (isMobile) {
|
||||
return 'closed';
|
||||
}
|
||||
if (initialRightSidenavStatus != null) {
|
||||
return initialRightSidenavStatus;
|
||||
}
|
||||
const userSelected = getFromLocalStorage(
|
||||
`${name}.sidenav.right.position`,
|
||||
'open',
|
||||
);
|
||||
if (userSelected != null) {
|
||||
return userSelected;
|
||||
}
|
||||
return initialRightSidenavStatus || 'closed';
|
||||
}, [isMobile, name, initialRightSidenavStatus]);
|
||||
const [rightSidenavStatus, _setRightSidenavStatus] = useControlledState(
|
||||
rightSidenav,
|
||||
rightSidenavStatusDefault,
|
||||
onRightSidenavChange,
|
||||
);
|
||||
const setRightSidenavStatus = useCallback(
|
||||
(status: DashboardSidenavStatus) => {
|
||||
_setRightSidenavStatus(status);
|
||||
setInLocalStorage(`${name}.sidenav.right.position`, status);
|
||||
},
|
||||
[_setRightSidenavStatus, name],
|
||||
);
|
||||
|
||||
const shouldShowUnderlay =
|
||||
isMobile && (leftSidenavStatus === 'open' || rightSidenavStatus === 'open');
|
||||
|
||||
return (
|
||||
<DashboardLayoutContext.Provider
|
||||
value={{
|
||||
leftSidenavStatus,
|
||||
setLeftSidenavStatus,
|
||||
rightSidenavStatus,
|
||||
setRightSidenavStatus,
|
||||
leftSidenavCanBeCompact,
|
||||
name,
|
||||
isMobileMode: isMobile,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...domProps}
|
||||
className={clsx('relative isolate', gridClassName, className, height)}
|
||||
>
|
||||
{children}
|
||||
<AnimatePresence>
|
||||
{shouldShowUnderlay && (
|
||||
<Underlay
|
||||
position="fixed"
|
||||
key="dashboard-underlay"
|
||||
onClick={() => {
|
||||
setLeftSidenavStatus('closed');
|
||||
setRightSidenavStatus('closed');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DashboardLayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
67
common/resources/client/ui/layout/dashboard-navbar.tsx
Executable file
67
common/resources/client/ui/layout/dashboard-navbar.tsx
Executable file
@@ -0,0 +1,67 @@
|
||||
import {Navbar, NavbarProps} from '../navigation/navbar/navbar';
|
||||
import {IconButton} from '../buttons/icon-button';
|
||||
import React, {useContext} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {DashboardLayoutContext} from './dashboard-layout-context';
|
||||
import {setInLocalStorage} from '../../utils/hooks/local-storage';
|
||||
import {MenuOpenIcon} from '@common/icons/material/MenuOpen';
|
||||
|
||||
export interface DashboardNavbarProps
|
||||
extends Omit<NavbarProps, 'toggleButton'> {
|
||||
hideToggleButton?: boolean;
|
||||
}
|
||||
export function DashboardNavbar({
|
||||
children,
|
||||
className,
|
||||
hideToggleButton,
|
||||
...props
|
||||
}: DashboardNavbarProps) {
|
||||
const {
|
||||
isMobileMode,
|
||||
leftSidenavStatus,
|
||||
setLeftSidenavStatus,
|
||||
name,
|
||||
leftSidenavCanBeCompact,
|
||||
} = useContext(DashboardLayoutContext);
|
||||
|
||||
const shouldToggleCompactMode = leftSidenavCanBeCompact && !isMobileMode;
|
||||
const shouldShowToggle =
|
||||
!hideToggleButton && (isMobileMode || leftSidenavCanBeCompact);
|
||||
|
||||
const handleToggle = () => {
|
||||
setLeftSidenavStatus(leftSidenavStatus === 'open' ? 'closed' : 'open');
|
||||
};
|
||||
|
||||
const handleCompactModeToggle = () => {
|
||||
const newStatus = leftSidenavStatus === 'compact' ? 'open' : 'compact';
|
||||
setInLocalStorage(`${name}.sidenav.compact`, newStatus === 'compact');
|
||||
setLeftSidenavStatus(newStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
className={clsx('dashboard-grid-navbar', className)}
|
||||
border="border-b"
|
||||
size="sm"
|
||||
toggleButton={
|
||||
shouldShowToggle ? (
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={() => {
|
||||
if (shouldToggleCompactMode) {
|
||||
handleCompactModeToggle();
|
||||
} else {
|
||||
handleToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuOpenIcon />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
112
common/resources/client/ui/layout/dashboard-sidenav.tsx
Executable file
112
common/resources/client/ui/layout/dashboard-sidenav.tsx
Executable file
@@ -0,0 +1,112 @@
|
||||
import clsx from 'clsx';
|
||||
import {m} from 'framer-motion';
|
||||
import {cloneElement, ReactElement, useContext} from 'react';
|
||||
import {DashboardLayoutContext} from './dashboard-layout-context';
|
||||
|
||||
export interface DashboardSidenavChildrenProps {
|
||||
className?: string;
|
||||
isCompactMode?: boolean;
|
||||
}
|
||||
|
||||
export interface SidenavProps {
|
||||
className?: string;
|
||||
children: ReactElement<DashboardSidenavChildrenProps>;
|
||||
position?: 'left' | 'right';
|
||||
size?: 'sm' | 'md' | 'lg' | string;
|
||||
mode?: 'overlay';
|
||||
// absolute will place sidenav between navbar/footer, fixed will overlay it over nav/footer.
|
||||
overlayPosition?: 'absolute' | 'fixed';
|
||||
display?: 'flex' | 'block';
|
||||
overflow?: string;
|
||||
forceClosed?: boolean;
|
||||
}
|
||||
export function DashboardSidenav({
|
||||
className,
|
||||
position,
|
||||
children,
|
||||
size = 'md',
|
||||
mode,
|
||||
overlayPosition = 'fixed',
|
||||
display = 'flex',
|
||||
overflow = 'overflow-hidden',
|
||||
forceClosed = false,
|
||||
}: SidenavProps) {
|
||||
const {
|
||||
isMobileMode,
|
||||
leftSidenavStatus,
|
||||
setLeftSidenavStatus,
|
||||
rightSidenavStatus,
|
||||
setRightSidenavStatus,
|
||||
} = useContext(DashboardLayoutContext);
|
||||
const status = position === 'left' ? leftSidenavStatus : rightSidenavStatus;
|
||||
const isOverlayMode = isMobileMode || mode === 'overlay';
|
||||
|
||||
const variants = {
|
||||
open: {display, width: null as any},
|
||||
compact: {
|
||||
display,
|
||||
width: null as any,
|
||||
},
|
||||
closed: {
|
||||
width: 0,
|
||||
transitionEnd: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClassName = getSize(status === 'compact' ? 'compact' : size);
|
||||
|
||||
return (
|
||||
<m.div
|
||||
variants={variants}
|
||||
initial={false}
|
||||
animate={forceClosed ? 'closed' : status}
|
||||
transition={{type: 'tween', duration: 0.15}}
|
||||
onClick={e => {
|
||||
// close sidenav when user clicks a link or button on mobile
|
||||
const target = e.target as HTMLElement;
|
||||
if (isMobileMode && (target.closest('button') || target.closest('a'))) {
|
||||
setLeftSidenavStatus('closed');
|
||||
setRightSidenavStatus('closed');
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
className,
|
||||
position === 'left'
|
||||
? 'dashboard-grid-sidenav-left'
|
||||
: 'dashboard-grid-sidenav-right',
|
||||
'will-change-[width]',
|
||||
overflow,
|
||||
sizeClassName,
|
||||
isOverlayMode && `${overlayPosition} bottom-0 top-0 z-20 shadow-2xl`,
|
||||
isOverlayMode && position === 'left' && 'left-0',
|
||||
isOverlayMode && position === 'right' && 'right-0',
|
||||
)}
|
||||
>
|
||||
{cloneElement<DashboardSidenavChildrenProps>(children, {
|
||||
className: clsx(
|
||||
children.props.className,
|
||||
'w-full h-full',
|
||||
status === 'compact' && 'compact-scrollbar',
|
||||
),
|
||||
isCompactMode: status === 'compact',
|
||||
})}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSize(size: SidenavProps['size'] | 'compact'): string {
|
||||
switch (size) {
|
||||
case 'compact':
|
||||
return 'w-80';
|
||||
case 'sm':
|
||||
return 'w-224';
|
||||
case 'md':
|
||||
return 'w-240';
|
||||
case 'lg':
|
||||
return 'w-288';
|
||||
default:
|
||||
return size || '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user