feat: Resizable sidebar (#1827)

* wip: First round on sidebar resizing

* feat: Saving setting, animation

* all requirements, refactoring needed

* lint

* refactor useResize

* some mobile improvements

* fix

* refactor
This commit is contained in:
Tom Moor 2021-01-20 23:00:14 -08:00 committed by GitHub
parent 774c3534d8
commit 111212b038
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 342 additions and 90 deletions

View File

@ -1,6 +1,7 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
@ -14,13 +15,15 @@ import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Analytics from "components/Analytics";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Modal from "components/Modal";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import {
@ -99,6 +102,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;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
@ -112,11 +116,19 @@ class Layout extends React.Component<Props> {
content="width=device-width, initial-scale=1.0"
/>
</Helmet>
<SkipNavLink />
<Analytics />
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
{this.props.notifications}
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
iconColor="currentColor"
neutral
/>
<Container auto>
{showSidebar && (
<Switch>
@ -125,10 +137,16 @@ class Layout extends React.Component<Props> {
</Switch>
)}
<SkipNavContent />
<Content
auto
justify="center"
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
$sidebarCollapsed={sidebarCollapsed}
style={
sidebarCollapsed
? undefined
: { marginLeft: `${ui.sidebarWidth}px` }
}
>
{this.props.children}
</Content>
@ -160,19 +178,34 @@ const Container = styled(Flex)`
min-height: 100%;
`;
const MobileMenuButton = styled(Button)`
position: fixed;
top: 12px;
left: 12px;
z-index: ${(props) => props.theme.depths.sidebar - 1};
${breakpoint("tablet")`
display: none;
`};
`;
const Content = styled(Flex)`
margin: 0;
transition: margin-left 100ms ease-out;
transition: ${(props) =>
props.$sidebarCollapsed ? `margin-left 100ms ease-out` : "none"};
@media print {
margin: 0;
}
${breakpoint("mobile", "tablet")`
margin-left: 0 !important;
`}
${breakpoint("tablet")`
margin-left: ${(props) =>
props.sidebarCollapsed
? props.theme.sidebarCollapsedWidth
: props.theme.sidebarWidth};
${(props) =>
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
`};
`;

View File

@ -1,55 +1,165 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon, MenuIcon } from "outline-icons";
import * as React from "react";
import { Portal } from "react-portal";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import styled from "styled-components";
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 } from "./components/CollapseToggle";
import CollapseToggle, {
Button as CollapseButton,
} from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder";
import ResizeHandle from "./components/ResizeHandle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let firstRender = true;
let BOUNCE_ANIMATION_MS = 250;
type Props = {
children: React.Node,
location: Location,
};
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
setWidth(width);
},
[offset, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
if (isSmallerThanMinimum) {
setWidth(minWidth);
setAnimating(true);
} else {
setWidth(width);
}
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
const handleStartDrag = React.useCallback(
(event) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
},
[width]
);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
}
}, [isAnimating]);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
};
function Sidebar({ location, children }: Props) {
const theme = useTheme();
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]);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
const style = React.useMemo(
() => ({
width: `${width}px`,
left:
collapsed && !ui.mobileSidebarVisible
? `${-width + theme.sidebarCollapsedWidth}px`
: 0,
}),
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
);
const content = (
<Container
mobileSidebarVisible={ui.mobileSidebarVisible}
collapsed={ui.editMode || ui.sidebarCollapsed}
style={style}
$sidebarWidth={ui.sidebarWidth}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
<Toggle
onClick={ui.toggleMobileSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{!isResizing && (
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
)}
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
{!ui.sidebarCollapsed && (
<ResizeBorder
onMouseDown={handleStartDrag}
onDoubleClick={handleReset}
$isResizing={isResizing}
>
<ResizeHandle />
</ResizeBorder>
)}
</Container>
);
@ -62,82 +172,67 @@ function Sidebar({ location, children }: Props) {
return content;
}
const Background = styled.a`
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
cursor: default;
z-index: ${(props) => props.theme.depths.sidebar - 1};
background: rgba(0, 0, 0, 0.5);
`;
const Container = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
left 100ms ease-out,
${(props) => props.theme.backgroundTransition}
${(props) =>
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
z-index: ${(props) => props.theme.depths.sidebar};
max-width: 70%;
min-width: 280px;
@media print {
display: none;
left: 0;
}
&:before,
&:after {
content: "";
background: ${(props) => props.theme.sidebarBackground};
position: absolute;
top: -50vh;
left: 0;
width: 100%;
height: 50vh;
}
&:after {
top: auto;
bottom: -50vh;
}
${breakpoint("tablet")`
left: ${(props) =>
props.collapsed
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
: 0};
width: ${(props) => props.theme.sidebarWidth};
margin: 0;
z-index: 3;
min-width: 0;
&:hover,
&:focus-within {
left: 0;
left: 0 !important;
box-shadow: ${(props) =>
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
& ${Button} {
& ${CollapseButton} {
opacity: .75;
}
& ${Button}:hover {
& ${CollapseButton}:hover {
opacity: 1;
}
}
&:not(:hover):not(:focus-within) > div {
opacity: ${(props) => (props.collapsed ? "0" : "1")};
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
`};
`;
const Toggle = styled.a`
display: flex;
align-items: center;
position: fixed;
top: 0;
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
z-index: 1;
margin: 12px;
${breakpoint("tablet")`
display: none;
`};
`;
export default withRouter(observer(Sidebar));

View File

@ -8,7 +8,7 @@ import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: () => void,
onClick?: (event: SyntheticEvent<>) => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
@ -43,7 +43,7 @@ export const Button = styled.button`
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: ${(props) => props.theme.sidebarItemBackground};
background: transparent;
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;

View File

@ -13,8 +13,8 @@ type Props = {
};
const HeaderBlock = React.forwardRef<Props, any>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => {
return (
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
<Wrapper>
<Header justify="flex-start" align="center" ref={ref} {...rest}>
<TeamLogo
alt={`${teamName} logo`}
@ -30,8 +30,8 @@ const HeaderBlock = React.forwardRef<Props, any>(
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
);
}
</Wrapper>
)
);
const StyledExpandedIcon = styled(ExpandedIcon)`
@ -45,6 +45,7 @@ const Subheading = styled.div`
font-size: 11px;
text-transform: uppercase;
font-weight: 500;
white-space: nowrap;
color: ${(props) => props.theme.sidebarText};
`;
@ -54,16 +55,20 @@ const TeamName = styled.div`
padding-right: 24px;
font-weight: 600;
color: ${(props) => props.theme.text};
white-space: nowrap;
text-decoration: none;
font-size: 16px;
`;
const Wrapper = styled.div`
flex-shrink: 0;
overflow: hidden;
`;
const Header = styled.button`
display: flex;
align-items: center;
flex-shrink: 0;
padding: 20px 24px;
position: relative;
background: none;
line-height: inherit;
border: 0;

View File

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

View File

@ -0,0 +1,39 @@
// @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

@ -6,6 +6,7 @@ const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 24px 8px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
`;
export default Section;

View File

@ -7,6 +7,7 @@ import {
type Match,
} from "react-router-dom";
import styled, { withTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "components/EventBoundary";
import { type Theme } from "types";
@ -96,6 +97,7 @@ const IconWrapper = styled.span`
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
`;
const Actions = styled(EventBoundary)`
@ -123,7 +125,7 @@ const StyledNavLink = styled(NavLink)`
display: flex;
position: relative;
text-overflow: ellipsis;
padding: 4px 16px;
padding: 6px 16px;
border-radius: 4px;
transition: background 50ms, color 50ms;
background: ${(props) =>
@ -159,6 +161,10 @@ const StyledNavLink = styled(NavLink)`
}
}
}
${breakpoint("tablet")`
padding: 4px 16px;
`}
`;
const Label = styled.div`

View File

@ -0,0 +1,8 @@
// @flow
import * as React from "react";
export const id = "skip-nav";
export default function SkipNavContent() {
return <div id={id} />;
}

View File

@ -0,0 +1,34 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { id } from "components/SkipNavContent";
export default function SkipNavLink() {
return <Anchor href={`#${id}`}>Skip navigation</Anchor>;
}
const Anchor = styled.a`
border: 0;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
position: absolute;
z-index: 1;
&:focus {
padding: 1rem;
position: fixed;
top: 12px;
left: 12px;
background: ${(props) => props.theme.background};
color: ${(props) => props.theme.text};
outline-color: ${(props) => props.theme.primary};
z-index: ${(props) => props.theme.depths.popover};
width: auto;
height: auto;
clip: auto;
}
`;

View File

@ -10,6 +10,7 @@ const TeamLogo = styled.img`
background: ${(props) => props.theme.background};
border: 1px solid ${(props) => props.theme.divider};
overflow: hidden;
flex-shrink: 0;
`;
export default TeamLogo;

View File

@ -171,7 +171,6 @@ class Header extends React.Component<Props> {
iconColor="currentColor"
borderOnHover
neutral
small
/>
</Tooltip>
</>
@ -223,7 +222,6 @@ class Header extends React.Component<Props> {
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
onClick={this.handleShareLink}
neutral
small
>
{t("Share")}
</Button>
@ -244,7 +242,6 @@ class Header extends React.Component<Props> {
disabled={savingIsDisabled}
isSaving={isSaving}
neutral={isDraft}
small
>
{isDraft ? t("Save Draft") : t("Done Editing")}
</Button>
@ -265,7 +262,6 @@ class Header extends React.Component<Props> {
icon={<EditIcon />}
to={editDocumentUrl(this.props.document)}
neutral
small
>
{t("Edit")}
</Button>
@ -300,7 +296,6 @@ class Header extends React.Component<Props> {
templateId: document.id,
})}
primary
small
>
{t("New from template")}
</Button>
@ -318,7 +313,6 @@ class Header extends React.Component<Props> {
onClick={this.handlePublish}
title={t("Publish document")}
disabled={publishingIsDisabled}
small
>
{isPublishing ? `${t("Publishing")}` : t("Publish")}
</Button>
@ -339,7 +333,6 @@ class Header extends React.Component<Props> {
{...props}
borderOnHover
neutral
small
/>
)}
showToggleEmbeds={canToggleEmbeds}

View File

@ -2,6 +2,7 @@
import { orderBy } from "lodash";
import { observable, action, autorun, computed } from "mobx";
import { v4 } from "uuid";
import { light as defaultTheme } from "shared/styles/theme";
import Collection from "models/Collection";
import Document from "models/Document";
import type { Toast } from "types";
@ -23,6 +24,7 @@ class UiStore {
@observable editMode: boolean = false;
@observable tocVisible: boolean = false;
@observable mobileSidebarVisible: boolean = false;
@observable sidebarWidth: number;
@observable sidebarCollapsed: boolean = false;
@observable toasts: Map<string, Toast> = new Map();
lastToastId: string;
@ -54,6 +56,7 @@ class UiStore {
// persisted keys
this.languagePromptDismissed = data.languagePromptDismissed;
this.sidebarCollapsed = data.sidebarCollapsed;
this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth;
this.tocVisible = data.tocVisible;
this.theme = data.theme || "system";
@ -110,6 +113,11 @@ class UiStore {
this.activeCollectionId = undefined;
};
@action
setSidebarWidth = (sidebarWidth: number): void => {
this.sidebarWidth = sidebarWidth;
};
@action
collapseSidebar = () => {
this.sidebarCollapsed = true;
@ -219,6 +227,7 @@ class UiStore {
return JSON.stringify({
tocVisible: this.tocVisible,
sidebarCollapsed: this.sidebarCollapsed,
sidebarWidth: this.sidebarWidth,
languagePromptDismissed: this.languagePromptDismissed,
theme: this.theme,
});

View File

@ -47,10 +47,10 @@ const spacing = {
padding: "1.5vw 1.875vw",
vpadding: "1.5vw",
hpadding: "1.875vw",
sidebarWidth: "280px",
sidebarCollapsedWidth: "16px",
sidebarMinWidth: "250px",
sidebarMaxWidth: "350px",
sidebarWidth: 280,
sidebarCollapsedWidth: 16,
sidebarMinWidth: 200,
sidebarMaxWidth: 400,
};
export const base = {