mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(component): init app sidebar (#2135)
This commit is contained in:
@@ -33,9 +33,11 @@
|
||||
"@mui/base": "5.0.0-alpha.127",
|
||||
"@mui/icons-material": "^5.11.16",
|
||||
"@mui/material": "^5.12.2",
|
||||
"@popperjs/core": "^2.11.7",
|
||||
"@radix-ui/react-avatar": "^1.0.2",
|
||||
"@toeverything/hooks": "workspace:*",
|
||||
"@toeverything/theme": "workspace:*",
|
||||
"@vanilla-extract/dynamic": "^2.0.3",
|
||||
"clsx": "^1.2.1",
|
||||
"jotai": "^2.0.4",
|
||||
"lit": "^2.7.2",
|
||||
|
||||
96
packages/component/src/components/app-sidebar/index.css.ts
Normal file
96
packages/component/src/components/app-sidebar/index.css.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
export const floatingMaxWidth = 768;
|
||||
export const navWidthVar = createVar('nav-width');
|
||||
|
||||
export const navStyle = style({
|
||||
position: 'relative',
|
||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
||||
width: navWidthVar,
|
||||
minWidth: navWidthVar,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'margin-left .3s',
|
||||
zIndex: parseInt(baseTheme.zIndexModal),
|
||||
borderRight: '1px solid var(--affine-border-color)',
|
||||
'@media': {
|
||||
[`(max-width: ${floatingMaxWidth}px)`]: {
|
||||
position: 'absolute',
|
||||
width: `calc(10vw + ${navWidthVar})`,
|
||||
selectors: {
|
||||
'&[data-open="false"]': {
|
||||
marginLeft: `calc((10vw + ${navWidthVar}) * -1)`,
|
||||
},
|
||||
'&[data-is-macos-electron="true"]': {
|
||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
'&[data-open="false"]': {
|
||||
marginLeft: `calc(${navWidthVar} * -1)`,
|
||||
},
|
||||
'&[data-is-macos-electron="true"]': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
vars: {
|
||||
[navWidthVar]: '256px',
|
||||
},
|
||||
});
|
||||
|
||||
export const navHeaderStyle = style({
|
||||
flex: '0 0 auto',
|
||||
height: '52px',
|
||||
padding: '0px 16px 0px 10px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&[data-is-macos-electron="true"]': {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const navBodyStyle = style({
|
||||
flex: '1 1 auto',
|
||||
});
|
||||
|
||||
export const navFooterStyle = style({
|
||||
flex: '0 0 auto',
|
||||
borderTop: '1px solid var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const sidebarButtonStyle = style({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
|
||||
export const sidebarFloatMaskStyle = style({
|
||||
transition: 'opacity .15s',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: '100%',
|
||||
bottom: 0,
|
||||
zIndex: parseInt(baseTheme.zIndexModal) - 1,
|
||||
background: 'var(--affine-background-modal-color)',
|
||||
'@media': {
|
||||
[`(max-width: ${floatingMaxWidth}px)`]: {
|
||||
selectors: {
|
||||
'&[data-open="true"]': {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
right: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
export const appSidebarOpenAtom = atomWithStorage('app-sidebar-open', true);
|
||||
export const appSidebarWidthAtom = atomWithStorage(
|
||||
'app-sidebar-width',
|
||||
256 /* px */
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { SidebarIcon } from '@blocksuite/icons';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { AppSidebar, appSidebarOpenAtom, ResizeIndicator } from '.';
|
||||
import { navHeaderStyle, sidebarButtonStyle } from './index.css';
|
||||
|
||||
export default {
|
||||
title: 'Components/AppSidebar',
|
||||
component: AppSidebar,
|
||||
} satisfies Meta;
|
||||
|
||||
const Footer = () => <div>Add Page</div>;
|
||||
|
||||
export const Default: StoryFn = props => {
|
||||
const [open, setOpen] = useAtom(appSidebarOpenAtom);
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
return (
|
||||
<>
|
||||
<main
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100vw',
|
||||
height: '600px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<AppSidebar footer={<Footer />} ref={ref}>
|
||||
Test
|
||||
</AppSidebar>
|
||||
<ResizeIndicator targetElement={ref} />
|
||||
<div>
|
||||
<div className={navHeaderStyle}>
|
||||
{!open && (
|
||||
<IconButton
|
||||
className={sidebarButtonStyle}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<SidebarIcon width={24} height={24} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
108
packages/component/src/components/app-sidebar/index.tsx
Normal file
108
packages/component/src/components/app-sidebar/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { getEnvironment } from '@affine/env';
|
||||
import {
|
||||
ArrowLeftSmallIcon,
|
||||
ArrowRightSmallIcon,
|
||||
SidebarIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import { IconButton } from '../../ui/button/IconButton';
|
||||
import {
|
||||
navBodyStyle,
|
||||
navFooterStyle,
|
||||
navHeaderStyle,
|
||||
navStyle,
|
||||
navWidthVar,
|
||||
sidebarButtonStyle,
|
||||
sidebarFloatMaskStyle,
|
||||
} from './index.css';
|
||||
import { appSidebarOpenAtom, appSidebarWidthAtom } from './index.jotai';
|
||||
|
||||
export { appSidebarOpenAtom };
|
||||
|
||||
export type AppSidebarProps = PropsWithChildren<{
|
||||
footer?: ReactNode | undefined;
|
||||
}>;
|
||||
|
||||
export const AppSidebar = forwardRef<HTMLElement, AppSidebarProps>(
|
||||
function AppSidebar(props, forwardedRef): ReactElement {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [open, setOpen] = useAtom(appSidebarOpenAtom);
|
||||
|
||||
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
|
||||
|
||||
const handleSidebarOpen = useCallback(() => {
|
||||
setOpen(open => !open);
|
||||
}, [setOpen]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ref.current as HTMLElement);
|
||||
|
||||
const environment = getEnvironment();
|
||||
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={navStyle}
|
||||
ref={ref}
|
||||
style={assignInlineVars({
|
||||
[navWidthVar]: `${appSidebarWidth}px`,
|
||||
})}
|
||||
data-testid="app-sidebar"
|
||||
data-open={open}
|
||||
data-is-macos-electron={isMacosDesktop}
|
||||
>
|
||||
<div
|
||||
className={navHeaderStyle}
|
||||
data-is-macos-electron={isMacosDesktop}
|
||||
>
|
||||
{isMacosDesktop && (
|
||||
<>
|
||||
<IconButton
|
||||
size="middle"
|
||||
onClick={() => {
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
<ArrowLeftSmallIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="middle"
|
||||
onClick={() => {
|
||||
window.history.forward();
|
||||
}}
|
||||
style={{ marginLeft: '32px' }}
|
||||
>
|
||||
<ArrowRightSmallIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
data-testid="app-sidebar-arrow-button-collapse"
|
||||
className={sidebarButtonStyle}
|
||||
onClick={handleSidebarOpen}
|
||||
>
|
||||
<SidebarIcon width={24} height={24} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={navBodyStyle}>{props.children}</div>
|
||||
<div className={navFooterStyle}>{props.footer}</div>
|
||||
</nav>
|
||||
<div
|
||||
data-testid="app-sidebar-float-mask"
|
||||
data-open={open}
|
||||
className={sidebarFloatMaskStyle}
|
||||
onClick={useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type { ResizeIndicatorProps } from './resize-indicator';
|
||||
export { ResizeIndicator } from './resize-indicator';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import { navWidthVar } from '../index.css';
|
||||
|
||||
export const spacerStyle = style({
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
left: navWidthVar,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
height: '100%',
|
||||
zIndex: 'calc(var(--affine-z-index-modal) - 1)',
|
||||
backgroundColor: 'var(--affine-border-color)',
|
||||
opacity: 0,
|
||||
cursor: 'col-resize',
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
// do not allow resizing on mobile
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
transition: 'opacity 0.15s ease 0.1s',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&[data-resizing="true"]': {
|
||||
transition: 'width .3s, min-width .3s, max-width .3s',
|
||||
},
|
||||
'&[data-open="false"]': {
|
||||
display: 'none',
|
||||
},
|
||||
'&[data-open="open"]': {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Instance } from '@popperjs/core';
|
||||
import { createPopper } from '@popperjs/core';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { ReactElement, RefObject } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { appSidebarOpenAtom, appSidebarWidthAtom } from '../index.jotai';
|
||||
import { spacerStyle } from './index.css';
|
||||
|
||||
export type ResizeIndicatorProps = {
|
||||
targetElement: RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const popperRef = useRef<Instance | null>(null);
|
||||
const setWidth = useSetAtom(appSidebarWidthAtom);
|
||||
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (props.targetElement.current) {
|
||||
popperRef.current = createPopper(
|
||||
props.targetElement.current,
|
||||
ref.current,
|
||||
{
|
||||
placement: 'right',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [props.targetElement]);
|
||||
|
||||
const sidebarWidth = useDeferredValue(useAtomValue(appSidebarWidthAtom));
|
||||
useEffect(() => {
|
||||
if (popperRef.current) {
|
||||
popperRef.current.update();
|
||||
}
|
||||
}, [sidebarWidth]);
|
||||
|
||||
const onResizeStart = useCallback(() => {
|
||||
let resized = false;
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const newWidth = Math.min(480, Math.max(e.clientX, 256));
|
||||
setWidth(newWidth);
|
||||
setIsResizing(true);
|
||||
resized = true;
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
// if not resized, toggle sidebar
|
||||
if (!resized) {
|
||||
setSidebarOpen(o => !o);
|
||||
}
|
||||
if (popperRef.current) {
|
||||
popperRef.current.update();
|
||||
}
|
||||
setIsResizing(false);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}, [setSidebarOpen, setWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={spacerStyle}
|
||||
data-testid="app-sidebar-resizer"
|
||||
data-resizing={isResizing}
|
||||
data-open={sidebarOpen}
|
||||
onMouseDown={onResizeStart}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -66,16 +66,18 @@ export const changeLogWrapperSlideOutStyle = style({
|
||||
animation: `${slideOut} .3s ease-in-out forwards`,
|
||||
});
|
||||
export const changeLogSlideInStyle = style({
|
||||
width: '110%',
|
||||
// fixme: if width is 100% and marginLeft is 0,
|
||||
// the UI will overflow on app sidebar
|
||||
width: '99%',
|
||||
marginLeft: '2px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-primary-color)',
|
||||
backgroundColor: 'var(--affine-tertiary-color)',
|
||||
border: '1px solid var(--affine-primary-color)',
|
||||
borderRight: 'none',
|
||||
marginLeft: '8px',
|
||||
paddingLeft: '8px',
|
||||
borderRadius: '16px 0 0 16px',
|
||||
cursor: 'pointer',
|
||||
@@ -89,7 +91,6 @@ export const changeLogSlideOutStyle = style({
|
||||
animation: `${slideOut2} .3s ease-in-out forwards`,
|
||||
});
|
||||
export const linkStyle = style({
|
||||
flexGrow: 1,
|
||||
textAlign: 'left',
|
||||
color: 'var(--affine-text-emphasis-color)',
|
||||
display: 'flex',
|
||||
@@ -103,6 +104,6 @@ export const iconStyle = style({
|
||||
});
|
||||
export const iconButtonStyle = style({
|
||||
fontSize: '20px',
|
||||
marginRight: '12%',
|
||||
marginRight: '0',
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ export const StyledIconButton = styled('button', {
|
||||
fontSize?: CSSProperties['fontSize'];
|
||||
}>(
|
||||
({
|
||||
theme,
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
|
||||
Reference in New Issue
Block a user