Sidebar design tweaks (#10684)

* Single line sidebar items

* fix: Letter icon in breadcrumb missing letter
Alignment of sidebar items

* fix: Editing state

* fix: Shared sidebar

* fix: Drag over unloaded document in sidebar does not allow drop

* perf

* Sidebar hover background

* Allow click toggle closed

* fix: Disclosure toggle

* perf: Avoid rendering collapsed folders
This commit is contained in:
Tom Moor
2025-11-23 11:38:40 +01:00
committed by GitHub
parent 22317e550a
commit 5bc2e7e62e
17 changed files with 141 additions and 147 deletions

View File

@@ -163,7 +163,7 @@ export const importDocument = createActionV2({
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.accept = documents.importFileTypesString;
input.onchange = async (ev) => {
const files = getEventFiles(ev);

View File

@@ -870,7 +870,7 @@ export const importDocument = createActionV2({
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.accept = documents.importFileTypesString;
input.onchange = async (ev) => {
const files = getEventFiles(ev);

View File

@@ -99,7 +99,12 @@ function DocumentBreadcrumb(
return createInternalLinkActionV2({
name: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, truncateMultiline } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
@@ -168,8 +168,8 @@ function EditableTitle(
);
}
const Text = styled.span`
${truncateMultiline(3)}
const Text = styled.div`
${ellipsis()}
`;
const Input = styled.input`

View File

@@ -30,15 +30,13 @@ export const ContextMenu = observer(
isMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return ((action?.children as ActionV2Variant[]) ?? []).map(
(childAction) => actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action?.children, actionContext]);
const menuItems = useComputed(
() =>
((action?.children as ActionV2Variant[]) ?? []).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
),
[action?.children, actionContext]
);
const handleOpenChange = React.useCallback(
(open: boolean) => {
@@ -48,7 +46,7 @@ export const ContextMenu = observer(
onClose?.();
}
},
[onOpen, onClose]
[open, onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {

View File

@@ -32,7 +32,7 @@ import CollectionLinkChildren from "./CollectionLinkChildren";
type Props = {
collection: Collection;
expanded?: boolean;
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
activeDocument: Document | undefined;
isDraggingAnyCollection?: boolean;
depth?: number;

View File

@@ -130,17 +130,13 @@ function InnerDocumentLink(
}
}, [setCollapsed, expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev) => {
ev?.preventDefault();
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
},
[setCollapsed, setExpanded, expanded]
);
const handleDisclosureClick = React.useCallback(() => {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
}, [setCollapsed, setExpanded, expanded]);
const handlePrefetch = React.useCallback(() => {
void prefetchDocument?.(node.id);
@@ -418,7 +414,7 @@ function InnerDocumentLink(
onKeyDown={handleKeyDown}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<DropToImport documentId={node.id}>
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}

View File

@@ -1,12 +1,13 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import invariant from "invariant";
import { observer } from "mobx-react";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import LoadingIndicator from "~/components/LoadingIndicator";
import useEventListener from "~/hooks/useEventListener";
import useImportDocument from "~/hooks/useImportDocument";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -22,6 +23,7 @@ type Props = {
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const [prerender, setPreRendered] = useState(false);
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
@@ -30,6 +32,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
collectionId || documentId,
"Must provide either collectionId or documentId"
);
useEventListener("dragenter", () => setPreRendered(true));
const canCollection = usePolicy(collectionId);
const canDocument = usePolicy(documentId);
@@ -42,6 +45,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
if (
disabled ||
!prerender ||
(collectionId && !canCollection.createDocument) ||
(documentId && !canDocument.createChildDocument)
) {
@@ -50,7 +54,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
accept={documents.importFileTypesString}
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
noClick

View File

@@ -1,30 +1,14 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
expanded: boolean;
children?: React.ReactNode;
};
const Folder: React.FC<Props> = ({ expanded, children }: Props) => {
const [openedOnce, setOpenedOnce] = React.useState(expanded);
// allows us to avoid rendering all children when the folder hasn't been opened
React.useEffect(() => {
if (expanded) {
setOpenedOnce(true);
}
}, [expanded]);
if (!openedOnce) {
if (!expanded) {
return null;
}
return <Wrapper $expanded={expanded}>{children}</Wrapper>;
return <>{children}</>;
};
const Wrapper = styled.div<{ $expanded?: boolean }>`
display: ${(props) => (props.$expanded ? "block" : "none")};
`;
export default Folder;

View File

@@ -40,7 +40,7 @@ function CollectionLink({ node, shareId, hideRootNode }: Props) {
<SharedDocumentLink
key={childNode.id}
index={index}
depth={hideRootNode ? 0 : 2}
depth={hideRootNode ? 1 : 2}
shareId={shareId}
node={childNode}
prefetchDocument={documents.prefetchDocument}

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { hover, s } from "@shared/styles";
import { ellipsis, hover, s } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
@@ -14,15 +14,14 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionV2WithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import useBoolean from "~/hooks/useBoolean";
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
innerRef?: (ref: HTMLElement | null | undefined) => void;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
/** Callback when we expect the user to click on the link. Used for prefetching data. */
onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
onClickIntent?: React.MouseEventHandler<HTMLElement>;
onDisclosureClick?: React.MouseEventHandler<HTMLElement>;
icon?: React.ReactNode;
label?: React.ReactNode;
menu?: React.ReactNode;
@@ -45,6 +44,7 @@ const activeDropStyle = {
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
function SidebarLink(
@@ -77,10 +77,10 @@ function SidebarLink(
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
paddingLeft: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`,
paddingRight: unreadBadge ? "32px" : undefined,
}),
[depth]
[depth, icon, unreadBadge]
);
const unreadStyle = React.useMemo(
@@ -99,81 +99,71 @@ function SidebarLink(
[theme.text, theme.sidebarActiveBackground, style]
);
const hoverStyle = React.useMemo(
() => ({
color: theme.text,
...style,
}),
[theme.text, style]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent<HTMLAnchorElement>) => {
onDisclosureClick?.(ev);
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
const DisclosureComponent = depth === 0 ? HiddenDisclosure : Disclosure;
const handleClickCapture = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.altKey && onDisclosureClick && expanded !== undefined) {
event.preventDefault();
event.stopPropagation();
onDisclosureClick(
event as unknown as React.MouseEvent<HTMLButtonElement>
);
if (onClick && !disabled && ev.isDefaultPrevented() === false) {
onClick(ev);
}
},
[onDisclosureClick, expanded]
[onClick, disabled, expanded]
);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
ev.preventDefault();
ev.stopPropagation();
onDisclosureClick?.(ev);
},
[onDisclosureClick]
);
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
return (
<>
<ContextMenu
action={contextAction}
ariaLabel={t("Link options")}
onOpen={setOpen}
onClose={setClosed}
<ContextMenu action={contextAction} ariaLabel={t("Link options")}>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onDragEnter={handleMouseEnter}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
onClickCapture={handleClickCapture}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
>
<Content>
{expanded !== undefined && (
<DisclosureComponent
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</Link>
</ContextMenu>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
<Content>
{expanded !== undefined && (
<DisclosureComponent
expanded={expanded}
onClick={preventDefault}
onPointerDown={handleDisclosureClick}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label $ellipsis={typeof label === "string"}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</Link>
</ContextMenu>
);
}
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
@@ -191,12 +181,13 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
display: inline-flex;
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
position: absolute;
top: 4px;
top: 3px;
right: 4px;
gap: 4px;
color: ${s("textTertiary")};
transition: opacity 50ms;
height: 24px;
background: var(--background);
svg {
color: ${s("textSecondary")};
@@ -226,16 +217,28 @@ const Link = styled(NavLink)<{
$isDraft?: boolean;
$disabled?: boolean;
}>`
&:hover,
&:active {
--background: ${s("sidebarHoverBackground")};
}
&[aria-current="page"] ${Actions} {
--background: ${s("sidebarActiveBackground")};
}
${(props) => props.$isActiveDrop && `--background: ${props.theme.slateDark};`}
display: flex;
position: relative;
text-overflow: ellipsis;
font-weight: 475;
padding: ${isMobile() ? 12 : 6}px 16px;
border-radius: 4px;
min-height: 32px;
min-height: 30px;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
white-space: nowrap;
margin-top: 1px;
background: var(--background);
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 16px;
@@ -282,30 +285,13 @@ const Link = styled(NavLink)<{
}
}
& + ${Actions} {
background: ${s("sidebarBackground")};
${NudeButton} {
background: transparent;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
}
&[aria-current="page"] + ${Actions} {
background: ${s("sidebarActiveBackground")};
}
${breakpoint("tablet")`
padding: 4px 8px 4px 16px;
padding: 3px 8px 3px 12px;
font-size: 14px;
`}
@media (hover: hover) {
&:hover + ${Actions}, &:active + ${Actions} {
&:hover ${Actions}, &:active ${Actions} {
visibility: visible;
svg {
@@ -318,12 +304,25 @@ const Link = styled(NavLink)<{
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
}
& ${Actions} {
${NudeButton} {
background: transparent;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
}
`;
const Label = styled.div`
const Label = styled.div<{ $ellipsis: boolean }>`
position: relative;
width: 100%;
line-height: 24px;
margin-left: 2px;
${(props) => props.$ellipsis && ellipsis()}
* {
unicode-bidi: plaintext;

View File

@@ -41,7 +41,7 @@ type StarredDocumentLinkProps = {
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
handleDisclosureClick: React.MouseEventHandler<HTMLElement>;
handlePrefetch: () => void;
icon: React.ReactNode;
label: React.ReactNode;
@@ -234,7 +234,7 @@ function StarredLink({ star }: Props) {
}, [documentId, documents]);
const handleDisclosureClick = React.useCallback(
(ev?: React.MouseEvent<HTMLButtonElement>) => {
(ev?: React.MouseEvent<HTMLElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);

View File

@@ -7,7 +7,7 @@ import useUnmount from "./useUnmount";
* and clears the timer on mouse leave or component unmount.
*/
export default function useClickIntent(
onClickIntent?: () => void,
onClickIntent?: React.MouseEventHandler<HTMLElement>,
delay = 100
) {
const timer = React.useRef<number>();

View File

@@ -197,7 +197,7 @@ const CollectionScene = observer(function _CollectionScene() {
}
>
<DropToImport
accept={documents.importFileTypes.join(", ")}
accept={documents.importFileTypesString}
disabled={!can.createDocument}
collectionId={collection.id}
>

View File

@@ -69,6 +69,11 @@ export default class DocumentsStore extends Store<Document> {
super(rootStore, Document);
}
@computed
get importFileTypesString(): string {
return this.importFileTypes.join(",");
}
@computed
get all(): Document[] {
return filter(

View File

@@ -145,6 +145,7 @@ declare module "styled-components" {
placeholder: string;
commentMarkBackground: string;
sidebarBackground: string;
sidebarHoverBackground: string;
sidebarActiveBackground: string;
sidebarControlHoverBackground: string;
sidebarDraftBorder: string;

View File

@@ -129,7 +129,8 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
textDiffDeletedBackground: "#ffebe9",
placeholder: "#a2b2c3",
sidebarBackground: colors.warmGrey,
sidebarActiveBackground: "#d7e0ea",
sidebarHoverBackground: "hsl(212 31% 90% / 1)",
sidebarActiveBackground: "hsl(212 31% 85% / 1)",
sidebarControlHoverBackground: "rgb(138 164 193 / 20%)",
sidebarDraftBorder: darken("0.25", colors.warmGrey),
sidebarText: "rgb(78, 92, 110)",
@@ -192,6 +193,7 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
placeholder: "#596673",
sidebarBackground: colors.veryDarkBlue,
sidebarHoverBackground: lighten(0.05, colors.veryDarkBlue),
sidebarActiveBackground: lighten(0.09, colors.veryDarkBlue),
sidebarControlHoverBackground: colors.white10,
sidebarDraftBorder: darken("0.35", colors.slate),