From 111212b03807cd2befc61926eebcac98cb8850ba Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 20 Jan 2021 23:00:14 -0800 Subject: [PATCH] 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 --- app/components/Layout.js | 47 +++- app/components/Sidebar/Sidebar.js | 221 +++++++++++++----- .../Sidebar/components/CollapseToggle.js | 4 +- .../Sidebar/components/HeaderBlock.js | 17 +- .../Sidebar/components/ResizeBorder.js | 28 +++ .../Sidebar/components/ResizeHandle.js | 39 ++++ app/components/Sidebar/components/Section.js | 1 + .../Sidebar/components/SidebarLink.js | 8 +- app/components/SkipNavContent.js | 8 + app/components/SkipNavLink.js | 34 +++ app/components/TeamLogo.js | 1 + app/scenes/Document/components/Header.js | 7 - app/stores/UiStore.js | 9 + shared/styles/theme.js | 8 +- 14 files changed, 342 insertions(+), 90 deletions(-) create mode 100644 app/components/Sidebar/components/ResizeBorder.js create mode 100644 app/components/Sidebar/components/ResizeHandle.js create mode 100644 app/components/SkipNavContent.js create mode 100644 app/components/SkipNavLink.js diff --git a/app/components/Layout.js b/app/components/Layout.js index 3a0210a7c..6a2c5957b 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -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 { 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 ; if (this.redirectTo) return ; @@ -112,11 +116,19 @@ class Layout extends React.Component { content="width=device-width, initial-scale=1.0" /> + {this.props.ui.progressBarVisible && } {this.props.notifications} + } + iconColor="currentColor" + neutral + /> + {showSidebar && ( @@ -125,10 +137,16 @@ class Layout extends React.Component { )} + {this.props.children} @@ -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;`} `}; `; diff --git a/app/components/Sidebar/Sidebar.js b/app/components/Sidebar/Sidebar.js index d4ceaccc1..afc6332b7 100644 --- a/app/components/Sidebar/Sidebar.js +++ b/app/components/Sidebar/Sidebar.js @@ -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 = ( - - - {ui.mobileSidebarVisible ? ( - - ) : ( - - )} - + {!isResizing && ( + + )} + {ui.mobileSidebarVisible && ( + + + + + + )} + {children} + {!ui.sidebarCollapsed && ( + + + + )} ); @@ -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)); diff --git a/app/components/Sidebar/components/CollapseToggle.js b/app/components/Sidebar/components/CollapseToggle.js index 8de701be6..60989a44a 100644 --- a/app/components/Sidebar/components/CollapseToggle.js +++ b/app/components/Sidebar/components/CollapseToggle.js @@ -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; diff --git a/app/components/Sidebar/components/HeaderBlock.js b/app/components/Sidebar/components/HeaderBlock.js index 1786c2fcf..cea45ac47 100644 --- a/app/components/Sidebar/components/HeaderBlock.js +++ b/app/components/Sidebar/components/HeaderBlock.js @@ -13,8 +13,8 @@ type Props = { }; const HeaderBlock = React.forwardRef( - ({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => { - return ( + ({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => ( +
( {subheading}
- ); - } +
+ ) ); 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; diff --git a/app/components/Sidebar/components/ResizeBorder.js b/app/components/Sidebar/components/ResizeBorder.js new file mode 100644 index 000000000..979907c57 --- /dev/null +++ b/app/components/Sidebar/components/ResizeBorder.js @@ -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; diff --git a/app/components/Sidebar/components/ResizeHandle.js b/app/components/Sidebar/components/ResizeHandle.js new file mode 100644 index 000000000..c85c9749d --- /dev/null +++ b/app/components/Sidebar/components/ResizeHandle.js @@ -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; diff --git a/app/components/Sidebar/components/Section.js b/app/components/Sidebar/components/Section.js index 31d0c807f..5214b694d 100644 --- a/app/components/Sidebar/components/Section.js +++ b/app/components/Sidebar/components/Section.js @@ -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; diff --git a/app/components/Sidebar/components/SidebarLink.js b/app/components/Sidebar/components/SidebarLink.js index 06e4efab4..9fb5d4b86 100644 --- a/app/components/Sidebar/components/SidebarLink.js +++ b/app/components/Sidebar/components/SidebarLink.js @@ -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` diff --git a/app/components/SkipNavContent.js b/app/components/SkipNavContent.js new file mode 100644 index 000000000..dfb86b0e7 --- /dev/null +++ b/app/components/SkipNavContent.js @@ -0,0 +1,8 @@ +// @flow +import * as React from "react"; + +export const id = "skip-nav"; + +export default function SkipNavContent() { + return
; +} diff --git a/app/components/SkipNavLink.js b/app/components/SkipNavLink.js new file mode 100644 index 000000000..6cdb1a234 --- /dev/null +++ b/app/components/SkipNavLink.js @@ -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 Skip navigation; +} + +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; + } +`; diff --git a/app/components/TeamLogo.js b/app/components/TeamLogo.js index ae703ac77..30a4c26ce 100644 --- a/app/components/TeamLogo.js +++ b/app/components/TeamLogo.js @@ -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; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index c775ea8a3..f33161112 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -171,7 +171,6 @@ class Header extends React.Component { iconColor="currentColor" borderOnHover neutral - small /> @@ -223,7 +222,6 @@ class Header extends React.Component { icon={isPubliclyShared ? : undefined} onClick={this.handleShareLink} neutral - small > {t("Share")} @@ -244,7 +242,6 @@ class Header extends React.Component { disabled={savingIsDisabled} isSaving={isSaving} neutral={isDraft} - small > {isDraft ? t("Save Draft") : t("Done Editing")} @@ -265,7 +262,6 @@ class Header extends React.Component { icon={} to={editDocumentUrl(this.props.document)} neutral - small > {t("Edit")} @@ -300,7 +296,6 @@ class Header extends React.Component { templateId: document.id, })} primary - small > {t("New from template")} @@ -318,7 +313,6 @@ class Header extends React.Component { onClick={this.handlePublish} title={t("Publish document")} disabled={publishingIsDisabled} - small > {isPublishing ? `${t("Publishing")}…` : t("Publish")} @@ -339,7 +333,6 @@ class Header extends React.Component { {...props} borderOnHover neutral - small /> )} showToggleEmbeds={canToggleEmbeds} diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index b5e65d0ef..d5c702e26 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -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 = 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, }); diff --git a/shared/styles/theme.js b/shared/styles/theme.js index 8d6c3a34b..16ff7252b 100644 --- a/shared/styles/theme.js +++ b/shared/styles/theme.js @@ -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 = {