feat: Add "new doc" button on collections in sidebar (#3174)
* feat: Add new icon button on collections in sidebar, move sort into menu * Remove unused menu, add warning when dragging in a-z collection * fix: Add hover background to sidebar actions, add tooltip to new doc button * Retain 'active' state on buttons when related context menu is open * fix: Two more spots that deserve active background
This commit is contained in:
@@ -41,7 +41,8 @@ const RealButton = styled.button<{
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
@@ -76,7 +77,8 @@ const RealButton = styled.button<{
|
||||
}
|
||||
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${
|
||||
props.borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
@@ -103,7 +105,8 @@ const RealButton = styled.button<{
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import DocumentMeta from "~/components/DocumentMeta";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
@@ -171,6 +172,13 @@ const Actions = styled(EventBoundary)`
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useHistory } from "react-router-dom";
|
||||
import { useLocation, useHistory, Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentReparent from "~/scenes/DocumentReparent";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Fade from "~/components/Fade";
|
||||
import Modal from "~/components/Modal";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import CollectionSortMenu from "~/menus/CollectionSortMenu";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
@@ -254,20 +258,25 @@ function CollectionLink({
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<>
|
||||
{can.update && displayDocumentLinks && (
|
||||
<CollectionSortMenu
|
||||
collection={collection}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
<Fade>
|
||||
{can.update && (
|
||||
<Tooltip tooltip={t("New doc")} delay={500}>
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New document")}
|
||||
as={Link}
|
||||
to={newDocumentPath(collection.id)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -11,8 +11,10 @@ import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -47,6 +49,7 @@ function DocumentLink(
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { showToast } = useToasts();
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
@@ -225,6 +228,19 @@ function DocumentLink(
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort) {
|
||||
showToast(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
),
|
||||
{
|
||||
type: "info",
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -327,16 +343,18 @@ function DocumentLink(
|
||||
!isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
})}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<Tooltip tooltip={t("New doc")} delay={500}>
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
})}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
@@ -350,8 +368,12 @@ function DocumentLink(
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{manualSort && isDraggingAnyDocument && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
{isDraggingAnyDocument && (
|
||||
<DropCursor
|
||||
disabled={!manualSort}
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
/>
|
||||
)}
|
||||
</Relative>
|
||||
{openedOnce && (
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
function DropCursor({
|
||||
isActiveDrop,
|
||||
innerRef,
|
||||
position,
|
||||
}: {
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
isActiveDrop: boolean;
|
||||
innerRef: React.Ref<HTMLDivElement>;
|
||||
position?: "top";
|
||||
}) {
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} position={position} />;
|
||||
};
|
||||
|
||||
function DropCursor({ isActiveDrop, innerRef, position, disabled }: Props) {
|
||||
return (
|
||||
<Cursor
|
||||
isOver={isActiveDrop}
|
||||
disabled={disabled}
|
||||
ref={innerRef}
|
||||
position={position}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// transparent hover zone with a thin visible band vertically centered
|
||||
const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
|
||||
const Cursor = styled.div<{
|
||||
isOver?: boolean;
|
||||
disabled?: boolean;
|
||||
position?: "top";
|
||||
}>`
|
||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||
transition: opacity 150ms;
|
||||
position: absolute;
|
||||
@@ -26,7 +36,10 @@ const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
|
||||
${(props) => (props.position === "top" ? "top: -7px;" : "bottom: -7px;")}
|
||||
|
||||
::after {
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
background: ${(props) =>
|
||||
props.disabled
|
||||
? props.theme.sidebarActiveBackground
|
||||
: props.theme.slateDark};
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
content: "";
|
||||
|
||||
@@ -71,7 +71,8 @@ const Wrapper = styled(Flex)<{ minHeight: number }>`
|
||||
cursor: pointer;
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${(props) => props.theme.sidebarActiveBackground};
|
||||
|
||||
@@ -190,13 +190,12 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
|
||||
|
||||
& + ${Actions} {
|
||||
${NudeButton} {
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
}
|
||||
}
|
||||
background: transparent;
|
||||
|
||||
&[aria-current="page"] + ${Actions} {
|
||||
${NudeButton} {
|
||||
background: ${(props) => props.theme.sidebarActiveBackground};
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ImportIcon,
|
||||
ExportIcon,
|
||||
PadlockIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ManualSortIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -124,6 +126,20 @@ function CollectionMenu({
|
||||
[history, showToast, collection.id, documents]
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(field: string) => {
|
||||
menu.hide();
|
||||
return collection.save({
|
||||
sort: {
|
||||
field,
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
[collection, menu]
|
||||
);
|
||||
|
||||
const alphabeticalSort = collection.sort.field === "title";
|
||||
const can = usePolicy(collection.id);
|
||||
const canUserInTeam = usePolicy(team.id);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
@@ -145,6 +161,30 @@ function CollectionMenu({
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Sort in sidebar"),
|
||||
visible: can.update,
|
||||
icon: alphabeticalSort ? (
|
||||
<AlphabeticalSortIcon color="currentColor" />
|
||||
) : (
|
||||
<ManualSortIcon color="currentColor" />
|
||||
),
|
||||
items: [
|
||||
{
|
||||
type: "button",
|
||||
title: t("Alphabetical sort"),
|
||||
onClick: () => handleChangeSort("title"),
|
||||
selected: alphabeticalSort,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Manual sort"),
|
||||
onClick: () => handleChangeSort("index"),
|
||||
selected: !alphabeticalSort,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
@@ -182,6 +222,8 @@ function CollectionMenu({
|
||||
t,
|
||||
can.update,
|
||||
can.delete,
|
||||
alphabeticalSort,
|
||||
handleChangeSort,
|
||||
handleNewDocument,
|
||||
handleImportDocument,
|
||||
collection,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import Collection from "~/models/Collection";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
function CollectionSortMenu({ collection, onOpen, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const handleChangeSort = React.useCallback(
|
||||
(field: string) => {
|
||||
menu.hide();
|
||||
return collection.save({
|
||||
sort: {
|
||||
field,
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
[collection, menu]
|
||||
);
|
||||
const alphabeticalSort = collection.sort.field === "title";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<NudeButton aria-label={t("Show sort menu")} {...props}>
|
||||
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Sort in sidebar")}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Alphabetical sort"),
|
||||
onClick: () => handleChangeSort("title"),
|
||||
selected: alphabeticalSort,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Manual sort"),
|
||||
onClick: () => handleChangeSort("index"),
|
||||
selected: !alphabeticalSort,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionSortMenu);
|
||||
@@ -139,6 +139,7 @@
|
||||
"Document archived": "Document archived",
|
||||
"Move document": "Move document",
|
||||
"Collections": "Collections",
|
||||
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
||||
"Untitled": "Untitled",
|
||||
"New nested document": "New nested document",
|
||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word",
|
||||
@@ -231,16 +232,15 @@
|
||||
"Path to document": "Path to document",
|
||||
"Group member options": "Group member options",
|
||||
"Remove": "Remove",
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"Alphabetical sort": "Alphabetical sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Edit": "Edit",
|
||||
"Permissions": "Permissions",
|
||||
"Delete": "Delete",
|
||||
"Collection permissions": "Collection permissions",
|
||||
"Delete collection": "Delete collection",
|
||||
"Export collection": "Export collection",
|
||||
"Show sort menu": "Show sort menu",
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"Alphabetical sort": "Alphabetical sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Document restored": "Document restored",
|
||||
"Document unpublished": "Document unpublished",
|
||||
"Document options": "Document options",
|
||||
|
||||
@@ -135,7 +135,7 @@ export const light = {
|
||||
placeholder: "#a2b2c3",
|
||||
sidebarBackground: colors.warmGrey,
|
||||
sidebarActiveBackground: "#d7e0ea",
|
||||
sidebarControlHoverBackground: "rgba(0,0,0,0.1)",
|
||||
sidebarControlHoverBackground: "rgb(138 164 193 / 20%)",
|
||||
sidebarDraftBorder: darken("0.25", colors.warmGrey),
|
||||
sidebarText: "rgb(78, 92, 110)",
|
||||
backdrop: "rgba(0, 0, 0, 0.2)",
|
||||
|
||||
Reference in New Issue
Block a user