feat: Sidebar Improvements (#1862)

* wip

* refactor behaviorg

* stash

* simplify
This commit is contained in:
Tom Moor 2021-02-07 21:51:56 -08:00 committed by GitHub
parent ac6c48817c
commit cc90c8de1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 207 additions and 221 deletions

View File

@ -76,7 +76,6 @@ class Layout extends React.Component<Props> {
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
if (this.props.ui.editMode) return;
this.keyboardShortcutsOpen = true;
}
@ -86,7 +85,6 @@ class Layout extends React.Component<Props> {
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
if (this.props.ui.editMode) return;
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
@ -94,7 +92,6 @@ class Layout extends React.Component<Props> {
@keydown("d")
goToDashboard() {
if (this.props.ui.editMode) return;
this.redirectTo = homeUrl();
}
@ -102,7 +99,7 @@ class Layout extends React.Component<Props> {
const { auth, t, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;

View File

@ -3,29 +3,37 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, {
Button as CollapseButton,
} from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder";
import ResizeHandle from "./components/ResizeHandle";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let firstRender = true;
let BOUNCE_ANIMATION_MS = 250;
let ANIMATION_MS = 250;
type Props = {
children: React.Node,
location: Location,
};
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
function Sidebar({ children }: Props) {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const collapsed = ui.isEditing || ui.sidebarCollapsed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
@ -38,24 +46,45 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
setWidth(width);
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
},
[offset, maxWidth, setWidth]
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
const handleStopDrag = React.useCallback(
(event: MouseEvent) => {
setResizing(false);
if (isSmallerThanMinimum) {
setWidth(minWidth);
setAnimating(true);
} else {
setWidth(width);
}
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
if (document.activeElement) {
document.activeElement.blur();
}
const handleStartDrag = React.useCallback(
(event) => {
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setAnimating(false);
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
setAnimating(true);
}
} else {
setWidth(width);
}
},
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
);
const handleMouseDown = React.useCallback(
(event: MouseEvent) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
@ -65,10 +94,19 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
setTimeout(() => setAnimating(false), ANIMATION_MS);
}
}, [isAnimating]);
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
@ -81,32 +119,6 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
};
}, [isResizing, handleDrag, handleStopDrag]);
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
};
function Sidebar({ location, children }: Props) {
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const collapsed = ui.editMode || ui.sidebarCollapsed;
const {
isAnimating,
isSmallerThanMinimum,
isResizing,
handleStartDrag,
} = useResize({
width,
minWidth,
maxWidth,
setWidth: ui.setSidebarWidth,
});
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
@ -124,49 +136,60 @@ function Sidebar({ location, children }: Props) {
const style = React.useMemo(
() => ({
width: `${width}px`,
left:
collapsed && !ui.mobileSidebarVisible
? `${-width + theme.sidebarCollapsedWidth}px`
: 0,
}),
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
[width]
);
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
);
const content = (
<Container
style={style}
$sidebarWidth={ui.sidebarWidth}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{!isResizing && (
<CollapseToggle
collapsed={ui.sidebarCollapsed}
<>
<Container
style={style}
$sidebarWidth={ui.sidebarWidth}
$isCollapsing={isCollapsing}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
$isResizing={isResizing}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</Container>
{!ui.isEditing && (
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarCollapsed ? "right" : "left"}
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
/>
)}
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
{!ui.sidebarCollapsed && (
<ResizeBorder
onMouseDown={handleStartDrag}
onDoubleClick={handleReset}
$isResizing={isResizing}
>
<ResizeHandle aria-label={t("Resize sidebar")} />
</ResizeBorder>
)}
</Container>
</>
);
// Fade in the sidebar on first render after page load
@ -195,29 +218,36 @@ const Container = styled(Flex)`
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
left 100ms ease-out,
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
${(props) => props.theme.backgroundTransition}
${(props) =>
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
z-index: ${(props) => props.theme.depths.sidebar};
max-width: 70%;
min-width: 280px;
${Positioner} {
display: none;
}
@media print {
display: none;
left: 0;
transform: none;
}
${breakpoint("tablet")`
margin: 0;
z-index: 3;
min-width: 0;
transform: translateX(${(props) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
&:hover,
&:focus-within {
left: 0 !important;
transform: none;
box-shadow: ${(props) =>
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
@ -225,11 +255,11 @@ const Container = styled(Flex)`
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
& ${CollapseButton} {
opacity: .75;
${Positioner} {
display: block;
}
& ${CollapseButton}:hover {
${ToggleButton} {
opacity: 1;
}
}
@ -241,4 +271,4 @@ const Container = styled(Flex)`
`};
`;
export default withRouter(observer(Sidebar));
export default observer(Sidebar);

View File

@ -1,59 +0,0 @@
// @flow
import { NextIcon, BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Tooltip from "components/Tooltip";
import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: (event: SyntheticEvent<>) => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
const { t } = useTranslation();
return (
<Tooltip
tooltip={collapsed ? t("Expand") : t("Collapse")}
shortcut={`${meta}+.`}
delay={500}
placement="bottom"
>
<Button {...rest} tabIndex="-1" aria-hidden>
{collapsed ? (
<NextIcon color="currentColor" />
) : (
<BackIcon color="currentColor" />
)}
</Button>
</Tooltip>
);
}
export const Button = styled.button`
display: block;
position: absolute;
top: 28px;
right: 8px;
border: 0;
width: 24px;
height: 24px;
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: transparent;
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;
cursor: pointer;
padding: 0;
&:hover {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
}
`;
export default CollapseToggle;

View File

@ -42,8 +42,6 @@ class Collections extends React.Component<Props> {
@keydown("n")
goToNewDocument() {
if (this.props.ui.editMode) return;
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;

View File

@ -1,6 +1,5 @@
// @flow
import styled from "styled-components";
import ResizeHandle from "./ResizeHandle";
const ResizeBorder = styled.div`
position: absolute;
@ -9,20 +8,6 @@ const ResizeBorder = styled.div`
right: -6px;
width: 12px;
cursor: ew-resize;
${(props) =>
props.$isResizing &&
`
${ResizeHandle} {
opacity: 1;
}
`}
&:hover {
${ResizeHandle} {
opacity: 1;
}
}
`;
export default ResizeBorder;

View File

@ -1,39 +0,0 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const ResizeHandle = styled.button`
opacity: 0;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%);
position: absolute;
top: 50%;
height: 40px;
right: -10px;
width: 8px;
padding: 0;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
border-radius: 8px;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: -24px;
bottom: -24px;
left: -12px;
right: -12px;
}
&:active {
background: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: ew-resize;
`}
`;
export default ResizeHandle;

View File

@ -0,0 +1,75 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
direction: "left" | "right",
style?: Object,
onClick?: () => any,
};
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
</ToggleButton>
</Positioner>
);
}
);
export const ToggleButton = styled.button`
opacity: 0;
background: none;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%)
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
position: absolute;
top: 50vh;
padding: 8px;
border: 0;
pointer-events: none;
color: ${(props) => props.theme.divider};
&:active {
color: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: pointer;
`}
`;
export const Positioner = styled.div`
z-index: 2;
position: absolute;
top: 0;
bottom: 0;
right: -30px;
width: 30px;
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1;
}
`;
export default Toggle;

View File

@ -21,7 +21,7 @@ class UiStore {
@observable activeDocumentId: ?string;
@observable activeCollectionId: ?string;
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
@observable isEditing: boolean = false;
@observable tocVisible: boolean = false;
@observable mobileSidebarVisible: boolean = false;
@observable sidebarWidth: number;
@ -151,12 +151,12 @@ class UiStore {
@action
enableEditMode = () => {
this.editMode = true;
this.isEditing = true;
};
@action
disableEditMode = () => {
this.editMode = false;
this.isEditing = false;
};
@action

View File

@ -153,7 +153,7 @@
"react-waypoint": "^9.0.2",
"react-window": "^1.8.6",
"reakit": "^1.3.4",
"rich-markdown-editor": "^11.1.6",
"rich-markdown-editor": "^11.2.0-0",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",

View File

@ -86,8 +86,6 @@
"Change Language": "Change Language",
"Dismiss": "Dismiss",
"Keyboard shortcuts": "Keyboard shortcuts",
"Expand": "Expand",
"Collapse": "Collapse",
"New collection": "New collection",
"Collections": "Collections",
"Untitled": "Untitled",
@ -110,7 +108,8 @@
"Export Data": "Export Data",
"Integrations": "Integrations",
"Installation": "Installation",
"Resize sidebar": "Resize sidebar",
"Expand": "Expand",
"Collapse": "Collapse",
"Unstar": "Unstar",
"Star": "Star",
"Appearance": "Appearance",