Add control to collapse pinned docs (#13233)

* Add control to collapse pinned docs

* feedback
This commit is contained in:
Tom Moor
2026-08-01 20:36:12 -04:00
committed by GitHub
parent fd5dfdb47e
commit 8bcf4a43a7
4 changed files with 221 additions and 43 deletions
+76
View File
@@ -0,0 +1,76 @@
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { s, hover, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Document from "~/models/Document";
type Props = {
/** The document to display */
document: Document;
};
/**
* A compact, single line representation of a document displaying its icon and
* title, linking to the document.
*/
export const DocumentChip = observer(function DocumentChip_({
document,
}: Props) {
const { icon, color } = document;
const title = document.titleWithDefault;
const isEmoji = determineIconType(icon) === IconType.Emoji;
return (
<Chip
dir={document.dir}
to={{
pathname: document.path,
state: {
title,
},
}}
>
{icon ? (
<Icon
value={icon}
color={color ?? undefined}
initial={document.initial}
/>
) : (
<DocumentIcon />
)}
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
</Chip>
);
});
const Title = styled.span`
${ellipsis()}
font-size: 14px;
font-weight: 500;
color: ${s("text")};
`;
const Chip = styled(Link)`
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
height: 24px;
padding: 0px 4px 0px 2px;
border-radius: 8px;
border: 1px solid ${s("inputBorder")};
color: ${s("textSecondary")};
cursor: var(--pointer);
user-select: none;
&:${hover},
&:active,
&:focus-visible {
background: ${s("listItemHoverBackground")};
}
`;
+142 -41
View File
@@ -17,11 +17,20 @@ import {
import fractionalIndex from "fractional-index";
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { CollapseIcon } from "outline-icons";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, hover, hideScrollbars } from "@shared/styles";
import type Pin from "~/models/Pin";
import DocumentCard from "~/components/DocumentCard";
import { DocumentChip } from "~/components/DocumentChip";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useMobile from "~/hooks/useMobile";
import usePersistedState from "~/hooks/usePersistedState";
import useStores from "~/hooks/useStores";
import { ResizingHeightContainer } from "./ResizingHeightContainer";
@@ -34,6 +43,11 @@ type Props = {
placeholderCount?: number;
/** Whether the user has permission to update pins */
canUpdate?: boolean;
/**
* When provided the section can be collapsed by the user, the preference is
* persisted locally under this key.
*/
collapseKey?: string;
};
function PinnedDocuments({
@@ -41,14 +55,29 @@ function PinnedDocuments({
pins,
placeholderCount,
canUpdate,
collapseKey,
...rest
}: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const isMobile = useMobile();
const [items, setItems] = useState(pins.map((pin) => pin.documentId));
const showPlaceholderRef = useRef(true);
// Only ever read or written when the section is collapsible
const [collapsed, setCollapsed] = usePersistedState<boolean>(
collapseKey ? `pins-collapsed-${collapseKey}` : "pins-collapsed",
false
);
const isCollapsible =
!isMobile && !!collapseKey && (!!items.length || !!placeholderCount);
const isCollapsed = isCollapsible && collapsed;
const showPlaceholder =
placeholderCount && !items.length && showPlaceholderRef.current;
const handleToggleCollapsed = useCallback(() => {
setCollapsed((state) => !state);
}, [setCollapsed]);
useEffect(() => {
setItems(pins.map((pin) => pin.documentId));
}, [pins]);
@@ -106,59 +135,131 @@ function PinnedDocuments({
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<ResizingHeightContainer
config={{
transition: {
duration: 0.2,
ease: "easeInOut",
},
}}
>
<SortableContext items={items} strategy={rectSortingStrategy}>
<List>
{showPlaceholder ? (
Array(placeholderCount)
.fill(undefined)
.map((_, index) => (
<div key={index} style={{ width: 170, height: 180 }} />
))
) : (
<AnimatePresence initial={false}>
{items.map((documentId) => {
const document = documents.get(documentId);
const pin = pins.find((p) => p.documentId === documentId);
<Wrapper $collapsible={isCollapsible} $collapsed={isCollapsed}>
{isCollapsible && (
<Tooltip content={isCollapsed ? t("Expand") : t("Collapse")}>
<ToggleButton
$visible={isCollapsed}
onClick={handleToggleCollapsed}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? t("Expand") : t("Collapse")}
>
<ToggleIcon />
</ToggleButton>
</Tooltip>
)}
{isCollapsed && (
<Chips>
{items.map((documentId) => {
const document = documents.get(documentId);
return document ? (
<DocumentChip key={documentId} document={document} />
) : null;
})}
</Chips>
)}
<ResizingHeightContainer
config={{
transition: {
duration: 0.2,
ease: "easeInOut",
},
}}
>
{isCollapsed ? null : (
<SortableContext items={items} strategy={rectSortingStrategy}>
<List $flush={isCollapsible}>
{showPlaceholder ? (
Array(placeholderCount)
.fill(undefined)
.map((_, index) => (
<div key={index} style={{ width: 170, height: 180 }} />
))
) : (
<AnimatePresence initial={false}>
{items.map((documentId) => {
const document = documents.get(documentId);
const pin = pins.find((p) => p.documentId === documentId);
// Once any document is loaded, never render the placeholder again
showPlaceholderRef.current = false;
// Once any document is loaded, never render the placeholder again
showPlaceholderRef.current = false;
return document ? (
<DocumentCard
key={documentId}
document={document}
canUpdatePin={canUpdate}
isDraggable={items.length > 1}
pin={pin}
{...rest}
/>
) : null;
})}
</AnimatePresence>
)}
</List>
</SortableContext>
</ResizingHeightContainer>
return document ? (
<DocumentCard
key={documentId}
document={document}
canUpdatePin={canUpdate}
isDraggable={items.length > 1}
pin={pin}
{...rest}
/>
) : null;
})}
</AnimatePresence>
)}
</List>
</SortableContext>
)}
</ResizingHeightContainer>
</Wrapper>
</DndContext>
);
}
const List = styled.div`
const ToggleIcon = styled(CollapseIcon)`
background: ${s("background")};
`;
const ToggleButton = styled(NudeButton)<{ $visible: boolean }>`
position: absolute;
top: 0;
right: 0;
z-index: 1;
color: ${s("textTertiary")};
opacity: ${(props) => (props.$visible ? 1 : 0)};
transition: opacity 100ms ease-in-out;
&:${hover},
&:active,
&:focus-visible {
color: ${s("text")};
opacity: 1;
}
`;
const Wrapper = styled.div<{ $collapsible: boolean; $collapsed: boolean }>`
position: relative;
// replaces the top margin of the list so the toggle takes no additional space
padding-top: ${(props) =>
props.$collapsible && !props.$collapsed ? 16 : 0}px;
${`&:${hover}`} ${ToggleButton} {
opacity: 1;
}
`;
const Chips = styled(Fade)`
${hideScrollbars()}
display: flex;
gap: 8px;
height: 24px;
overflow-x: auto;
margin-bottom: 16px;
// leave room for the collapse toggle, chips scrolling beneath it fade out
padding-right: 28px;
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
`;
const List = styled.div<{ $flush?: boolean }>`
display: grid;
column-gap: 8px;
row-gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0;
list-style: none;
margin: 16px 0 32px;
margin: ${(props) => (props.$flush ? "0" : "16px")} 0 32px;
&:empty {
display: none;
+1
View File
@@ -51,6 +51,7 @@ function Home() {
pins={pins}
canUpdate={can.update}
placeholderCount={count}
collapseKey="home"
/>
<Documents>
<Tabs>
+2 -2
View File
@@ -455,6 +455,8 @@
"Callback URLs": "Callback URLs",
"Published": "Published",
"Allow this app to be installed by other workspaces": "Allow this app to be installed by other workspaces",
"Expand": "Expand",
"Collapse": "Collapse",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
@@ -545,8 +547,6 @@
"New doc": "New doc",
"New nested document": "New nested document",
"No collections": "No collections",
"Collapse": "Collapse",
"Expand": "Expand",
"Import files": "Import files",
"Recent": "Recent",
"Go back": "Go back",