From 4b603460cb147f8c76f4b1027319e6c339778c2f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 14 Feb 2021 13:18:33 -0800 Subject: [PATCH] chore: Standardized headers (#1883) * feat: Collection to standard header feat: Sticky tabs * chore: Document to standard header * chore: Dashboard -> Home chore: Scene component * chore: Trash, Templates, Drafts * fix: Mobile improvements * fix: Content showing at sides and occassionally ontop of sticky headers --- app/components/CenteredContent.js | 9 +- app/components/Collaborators.js | 11 +- app/components/Header.js | 104 ++++ app/components/Input.js | 5 + app/components/Scene.js | 50 ++ app/components/Sidebar/Main.js | 4 +- app/components/Sidebar/Settings.js | 4 +- .../{HeaderBlock.js => TeamButton.js} | 9 +- app/components/Subheading.js | 31 +- app/components/Tab.js | 6 +- app/components/Tabs.js | 27 +- app/routes/authenticated.js | 6 +- app/scenes/Archive.js | 2 +- app/scenes/Collection.js | 322 +++++----- app/scenes/Document/components/Header.js | 557 +++++++----------- app/scenes/Drafts.js | 42 +- app/scenes/{Dashboard.js => Home.js} | 48 +- app/scenes/Starred.js | 41 +- app/scenes/Templates.js | 26 +- app/scenes/Trash.js | 10 +- shared/i18n/locales/en_US/translation.json | 6 +- shared/styles/theme.js | 1 + 22 files changed, 711 insertions(+), 610 deletions(-) create mode 100644 app/components/Header.js create mode 100644 app/components/Scene.js rename app/components/Sidebar/components/{HeaderBlock.js => TeamButton.js} (88%) rename app/scenes/{Dashboard.js => Home.js} (73%) diff --git a/app/components/CenteredContent.js b/app/components/CenteredContent.js index 0a868cc5..a62b0f11 100644 --- a/app/components/CenteredContent.js +++ b/app/components/CenteredContent.js @@ -3,17 +3,18 @@ import * as React from "react"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; -type Props = { +type Props = {| children?: React.Node, -}; + withStickyHeader?: boolean, +|}; const Container = styled.div` width: 100%; max-width: 100vw; - padding: 60px 20px; + padding: ${(props) => (props.withStickyHeader ? "4px 20px" : "60px 20px")}; ${breakpoint("tablet")` - padding: 60px; + padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")}; `}; `; diff --git a/app/components/Collaborators.js b/app/components/Collaborators.js index 2292f45f..b5f65ba6 100644 --- a/app/components/Collaborators.js +++ b/app/components/Collaborators.js @@ -2,8 +2,9 @@ import { sortBy, keyBy } from "lodash"; import { observer, inject } from "mobx-react"; import * as React from "react"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; import { MAX_AVATAR_DISPLAY } from "shared/constants"; - import DocumentPresenceStore from "stores/DocumentPresenceStore"; import ViewsStore from "stores/ViewsStore"; import Document from "models/Document"; @@ -51,7 +52,7 @@ class Collaborators extends React.Component { const overflow = documentViews.length - mostRecentViewers.length; return ( - v.user)} overflow={overflow} renderAvatar={(user) => { @@ -75,4 +76,10 @@ class Collaborators extends React.Component { } } +const FacepileHiddenOnMobile = styled(Facepile)` + ${breakpoint("mobile", "tablet")` + display: none; + `}; +`; + export default inject("views", "presence")(Collaborators); diff --git a/app/components/Header.js b/app/components/Header.js new file mode 100644 index 00000000..9a0e6790 --- /dev/null +++ b/app/components/Header.js @@ -0,0 +1,104 @@ +// @flow +import { throttle } from "lodash"; +import { observer } from "mobx-react"; +import { transparentize } from "polished"; +import * as React from "react"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; +import Fade from "components/Fade"; +import Flex from "components/Flex"; + +type Props = {| + breadcrumb?: React.Node, + title: React.Node, + actions?: React.Node, +|}; + +function Header({ breadcrumb, title, actions }: Props) { + const [isScrolled, setScrolled] = React.useState(false); + + const handleScroll = React.useCallback( + throttle(() => setScrolled(window.scrollY > 75), 50), + [] + ); + + React.useEffect(() => { + window.addEventListener("scroll", handleScroll); + + return () => window.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + const handleClickTitle = React.useCallback(() => { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + }, []); + + return ( + + {breadcrumb} + {isScrolled ? ( + + <Fade> + <Flex align="center">{title}</Flex> + </Fade> + + ) : ( +
+ )} + {actions && {actions}} + + ); +} + +const Wrapper = styled(Flex)` + position: sticky; + top: 0; + right: 0; + left: 0; + z-index: 2; + background: ${(props) => transparentize(0.2, props.theme.background)}; + padding: 12px; + transition: all 100ms ease-out; + transform: translate3d(0, 0, 0); + backdrop-filter: blur(20px); + + @media print { + display: none; + } + + ${breakpoint("tablet")` + padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)}; + `}; +`; + +const Title = styled(Flex)` + font-size: 16px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + cursor: pointer; + width: 0; + + ${breakpoint("tablet")` + flex-grow: 1; + `}; +`; + +const Actions = styled(Flex)` + align-self: flex-end; + height: 32px; +`; + +export default observer(Header); diff --git a/app/components/Input.js b/app/components/Input.js index 855f59b4..8ca02b24 100644 --- a/app/components/Input.js +++ b/app/components/Input.js @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; import Flex from "components/Flex"; const RealTextarea = styled.textarea` @@ -33,6 +34,10 @@ const RealInput = styled.input` &::placeholder { color: ${(props) => props.theme.placeholder}; } + + ${breakpoint("mobile", "tablet")` + font-size: 16px; + `}; `; const Wrapper = styled.div` diff --git a/app/components/Scene.js b/app/components/Scene.js new file mode 100644 index 00000000..eaf31732 --- /dev/null +++ b/app/components/Scene.js @@ -0,0 +1,50 @@ +// @flow +import * as React from "react"; +import styled from "styled-components"; +import CenteredContent from "components/CenteredContent"; +import Header from "components/Header"; +import PageTitle from "components/PageTitle"; + +type Props = {| + icon?: React.Node, + title: React.Node, + textTitle?: string, + children: React.Node, + breadcrumb?: React.Node, + actions?: React.Node, +|}; + +function Scene({ + title, + icon, + textTitle, + actions, + breadcrumb, + children, +}: Props) { + return ( + + +
+ {icon} {title} + + ) : ( + title + ) + } + actions={actions} + breadcrumb={breadcrumb} + /> + {children} + + ); +} + +const FillWidth = styled.div` + width: 100%; +`; + +export default Scene; diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index d6696725..8c948cca 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -22,9 +22,9 @@ import Modal from "components/Modal"; import Scrollable from "components/Scrollable"; import Sidebar from "./Sidebar"; import Collections from "./components/Collections"; -import HeaderBlock from "./components/HeaderBlock"; import Section from "./components/Section"; import SidebarLink from "./components/SidebarLink"; +import TeamButton from "./components/TeamButton"; import useStores from "hooks/useStores"; import AccountMenu from "menus/AccountMenu"; @@ -72,7 +72,7 @@ function MainSidebar() { {(props) => ( - - {t("Return to App")} diff --git a/app/components/Sidebar/components/HeaderBlock.js b/app/components/Sidebar/components/TeamButton.js similarity index 88% rename from app/components/Sidebar/components/HeaderBlock.js rename to app/components/Sidebar/components/TeamButton.js index ee8e40e4..9a9206dd 100644 --- a/app/components/Sidebar/components/HeaderBlock.js +++ b/app/components/Sidebar/components/TeamButton.js @@ -13,7 +13,7 @@ type Props = {| logoUrl: string, |}; -const HeaderBlock = React.forwardRef( +const TeamButton = React.forwardRef( ({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
@@ -25,8 +25,7 @@ const HeaderBlock = React.forwardRef( /> - {teamName}{" "} - {showDisclosure && } + {teamName} {showDisclosure && } {subheading} @@ -35,7 +34,7 @@ const HeaderBlock = React.forwardRef( ) ); -const StyledExpandedIcon = styled(ExpandedIcon)` +const Disclosure = styled(ExpandedIcon)` position: absolute; right: 0; top: 0; @@ -84,4 +83,4 @@ const Header = styled.button` } `; -export default HeaderBlock; +export default TeamButton; diff --git a/app/components/Subheading.js b/app/components/Subheading.js index c0716deb..4835b3b7 100644 --- a/app/components/Subheading.js +++ b/app/components/Subheading.js @@ -2,19 +2,17 @@ import * as React from "react"; import styled from "styled-components"; -type Props = { +type Props = {| children: React.Node, -}; +|}; const H3 = styled.h3` border-bottom: 1px solid ${(props) => props.theme.divider}; - margin-top: 22px; - margin-bottom: 12px; + margin: 12px 0; line-height: 1; - position: relative; `; -const Underline = styled("span")` +const Underline = styled.div` margin-top: -1px; display: inline-block; font-weight: 500; @@ -22,14 +20,29 @@ const Underline = styled("span")` line-height: 1.5; color: ${(props) => props.theme.textSecondary}; border-bottom: 3px solid ${(props) => props.theme.textSecondary}; + padding-top: 7px; padding-bottom: 5px; `; +// When sticky we need extra background coverage around the sides otherwise +// items that scroll past can "stick out" the sides of the heading +const Sticky = styled.div` + position: sticky; + top: 54px; + margin: 0 -8px; + padding: 0 8px; + background: ${(props) => props.theme.background}; + transition: ${(props) => props.theme.backgroundTransition}; + z-index: ${(props) => props.theme.depths.stickyHeader}; +`; + const Subheading = ({ children, ...rest }: Props) => { return ( -

- {children} -

+ +

+ {children} +

+
); }; diff --git a/app/components/Tab.js b/app/components/Tab.js index c616e0a3..5d771744 100644 --- a/app/components/Tab.js +++ b/app/components/Tab.js @@ -8,7 +8,7 @@ type Props = { theme: Theme, }; -const StyledNavLink = styled(NavLink)` +const TabLink = styled(NavLink)` position: relative; display: inline-flex; align-items: center; @@ -16,7 +16,7 @@ const StyledNavLink = styled(NavLink)` font-size: 14px; color: ${(props) => props.theme.textTertiary}; margin-right: 24px; - padding-bottom: 8px; + padding: 6px 0; &:hover { color: ${(props) => props.theme.textSecondary}; @@ -32,7 +32,7 @@ function Tab({ theme, ...rest }: Props) { color: theme.textSecondary, }; - return ; + return ; } export default withTheme(Tab); diff --git a/app/components/Tabs.js b/app/components/Tabs.js index 5ce46c3d..d8b76bb7 100644 --- a/app/components/Tabs.js +++ b/app/components/Tabs.js @@ -1,16 +1,27 @@ // @flow +import * as React from "react"; import styled from "styled-components"; -const Tabs = styled.nav` - position: relative; +const Nav = styled.nav` border-bottom: 1px solid ${(props) => props.theme.divider}; - margin-top: 22px; - margin-bottom: 12px; + margin: 12px 0; overflow-y: auto; white-space: nowrap; transition: opacity 250ms ease-out; `; +// When sticky we need extra background coverage around the sides otherwise +// items that scroll past can "stick out" the sides of the heading +const Sticky = styled.div` + position: sticky; + top: 54px; + margin: 0 -8px; + padding: 0 8px; + background: ${(props) => props.theme.background}; + transition: ${(props) => props.theme.backgroundTransition}; + z-index: ${(props) => props.theme.depths.stickyHeader}; +`; + export const Separator = styled.span` border-left: 1px solid ${(props) => props.theme.divider}; position: relative; @@ -19,4 +30,12 @@ export const Separator = styled.span` margin-top: 6px; `; +const Tabs = (props: {}) => { + return ( + + + + ); +}; + export default Tabs; diff --git a/app/routes/authenticated.js b/app/routes/authenticated.js index 7a1ef175..2168bac5 100644 --- a/app/routes/authenticated.js +++ b/app/routes/authenticated.js @@ -3,11 +3,11 @@ import * as React from "react"; import { Switch, Redirect, type Match } from "react-router-dom"; import Archive from "scenes/Archive"; import Collection from "scenes/Collection"; -import Dashboard from "scenes/Dashboard"; import KeyedDocument from "scenes/Document/KeyedDocument"; import DocumentNew from "scenes/DocumentNew"; import Drafts from "scenes/Drafts"; import Error404 from "scenes/Error404"; +import Home from "scenes/Home"; import Search from "scenes/Search"; import Starred from "scenes/Starred"; import Templates from "scenes/Templates"; @@ -37,8 +37,8 @@ export default function AuthenticatedRoutes() { - - + + diff --git a/app/scenes/Archive.js b/app/scenes/Archive.js index 25394cec..3290ff71 100644 --- a/app/scenes/Archive.js +++ b/app/scenes/Archive.js @@ -20,7 +20,7 @@ function Archive(props: Props) { const { documents } = props; return ( - + {t("Archive")} { const can = policies.abilities(match.params.id || ""); return ( - + <> {can.update && ( <> @@ -174,7 +173,7 @@ class CollectionScene extends React.Component { )} /> - + ); } @@ -190,169 +189,170 @@ class CollectionScene extends React.Component { const collectionName = collection ? collection.name : ""; const hasPinnedDocuments = !!pinnedDocuments.length; - return ( - - {collection ? ( + return collection ? ( + - - {collection.isEmpty ? ( - - - }} - /> -
- Get started by creating a new one! -
- - - - -    - {collection.private && ( - - )} - - - - - - - -
- ) : ( - <> - - {" "} - {collection.name} - - - - {hasPinnedDocuments && ( - <> - - {t("Pinned")} - - - - )} - - - - {t("Documents")} - - - {t("Recently updated")} - - - {t("Recently published")} - - - {t("Least recently updated")} - - - {t("A–Z")} - - - - - - - - - - - - - - - - - - - - - - - - )} - - {this.renderActions()} + +   + {collection.name} + } + actions={this.renderActions()} + > + {collection.isEmpty ? ( + + + }} + /> +
+ Get started by creating a new one! +
+ + + + +    + {collection.private && ( + + )} + + + + + + + +
) : ( <> - + {" "} + {collection.name} - + + + {hasPinnedDocuments && ( + <> + + {t("Pinned")} + + + + )} + + + + {t("Documents")} + + + {t("Recently updated")} + + + {t("Recently published")} + + + {t("Least recently updated")} + + + {t("A–Z")} + + + + + + + + + + + + + + + + + + + + + + )} +
+ ) : ( + + + + + ); } @@ -371,7 +371,7 @@ const TinyPinIcon = styled(PinIcon)` opacity: 0.8; `; -const Wrapper = styled(Flex)` +const Empty = styled(Flex)` justify-content: center; margin: 10px 0; `; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index 7f840a91..d3dbac37 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -1,7 +1,5 @@ // @flow -import { throttle } from "lodash"; -import { observable } from "mobx"; -import { observer, inject } from "mobx-react"; +import { observer } from "mobx-react"; import { TableOfContentsIcon, EditIcon, @@ -9,18 +7,11 @@ import { PlusIcon, MoreIcon, } from "outline-icons"; -import { transparentize, darken } from "polished"; import * as React from "react"; -import { withTranslation, Trans, type TFunction } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import styled from "styled-components"; -import breakpoint from "styled-components-breakpoint"; -import AuthStore from "stores/AuthStore"; -import PoliciesStore from "stores/PoliciesStore"; -import SharesStore from "stores/SharesStore"; -import UiStore from "stores/UiStore"; import Document from "models/Document"; - import DocumentShare from "scenes/DocumentShare"; import { Action, Separator } from "components/Actions"; import Badge from "components/Badge"; @@ -28,20 +19,17 @@ import Breadcrumb, { Slash } from "components/Breadcrumb"; import Button from "components/Button"; import Collaborators from "components/Collaborators"; import Fade from "components/Fade"; -import Flex from "components/Flex"; +import Header from "components/Header"; import Modal from "components/Modal"; import Tooltip from "components/Tooltip"; +import useStores from "hooks/useStores"; import DocumentMenu from "menus/DocumentMenu"; import NewChildDocumentMenu from "menus/NewChildDocumentMenu"; import TemplatesMenu from "menus/TemplatesMenu"; import { metaDisplay } from "utils/keyboard"; import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers"; -type Props = { - auth: AuthStore, - ui: UiStore, - shares: SharesStore, - policies: PoliciesStore, +type Props = {| document: Document, isDraft: boolean, isEditing: boolean, @@ -56,356 +44,263 @@ type Props = { publish?: boolean, autosave?: boolean, }) => void, - t: TFunction, -}; +|}; -@observer -class Header extends React.Component { - @observable isScrolled = false; - @observable showShareModal = false; +function DocumentHeader({ + document, + isEditing, + isDraft, + isPublishing, + isRevision, + isSaving, + savingIsDisabled, + publishingIsDisabled, + onSave, +}: Props) { + const { t } = useTranslation(); + const { auth, ui, shares, policies } = useStores(); + const [showShareModal, setShowShareModal] = React.useState(false); - componentDidMount() { - window.addEventListener("scroll", this.handleScroll); - } + const handleSave = React.useCallback(() => { + onSave({ done: true }); + }, [onSave]); - componentWillUnmount() { - window.removeEventListener("scroll", this.handleScroll); - } + const handlePublish = React.useCallback(() => { + onSave({ done: true, publish: true }); + }, [onSave]); - updateIsScrolled = () => { - this.isScrolled = window.scrollY > 75; - }; + const handleShareLink = React.useCallback( + async (ev: SyntheticEvent<>) => { + await document.share(); - handleScroll = throttle(this.updateIsScrolled, 50); + setShowShareModal(true); + }, + [document] + ); - handleSave = () => { - this.props.onSave({ done: true }); - }; + const handleCloseShareModal = React.useCallback(() => { + setShowShareModal(false); + }, []); - handlePublish = () => { - this.props.onSave({ done: true, publish: true }); - }; + const share = shares.getByDocumentId(document.id); + const isPubliclyShared = share && share.published; + const isNew = document.isNew; + const isTemplate = document.isTemplate; + const can = policies.abilities(document.id); + const canShareDocument = auth.team && auth.team.sharing && can.share; + const canToggleEmbeds = auth.team && auth.team.documentEmbeds; + const canEdit = can.update && !isEditing; - handleShareLink = async (ev: SyntheticEvent<>) => { - const { document } = this.props; - await document.share(); - - this.showShareModal = true; - }; - - handleCloseShareModal = () => { - this.showShareModal = false; - }; - - handleClickTitle = () => { - window.scrollTo({ - top: 0, - behavior: "smooth", - }); - }; - - render() { - const { - shares, - document, - policies, - isEditing, - isDraft, - isPublishing, - isRevision, - isSaving, - savingIsDisabled, - publishingIsDisabled, - ui, - auth, - t, - } = this.props; - - const share = shares.getByDocumentId(document.id); - const isPubliclyShared = share && share.published; - const isNew = document.isNew; - const isTemplate = document.isTemplate; - const can = policies.abilities(document.id); - const canShareDocument = auth.team && auth.team.sharing && can.share; - const canToggleEmbeds = auth.team && auth.team.documentEmbeds; - const canEdit = can.update && !isEditing; - - return ( - + - - - - - {!isEditing && ( - <> - - - - - - )} - {isEditing && ( - <> + - - )} - {canEdit && ( - - - - - - )} - {canEdit && can.createChildDocument && ( - - ( + )} + {isEditing && ( + <> + - - )} - /> - - )} - {canEdit && isTemplate && !isDraft && !isRevision && ( - - - - )} - {can.update && isDraft && !isRevision && ( - - - - - - )} - {!isEditing && ( - <> - + + + )} + {canEdit && ( - + + + + )} + {canEdit && can.createChildDocument && ( + + ( - + )} - showToggleEmbeds={canToggleEmbeds} - showPrint /> - - )} - - - ); - } + )} + {canEdit && isTemplate && !isDraft && !isRevision && ( + + + + )} + {can.update && isDraft && !isRevision && ( + + + + + + )} + {!isEditing && ( + <> + + + ( +