fix: Keyboard accessible context menus (#1768)

- Makes menus fully accessible and keyboard driven
- Currently adds 2.8% to initial bundle size due to the inclusion of Reakit and its dependency, popperjs.
- Converts all menus to functional components
- Remove old custom menu system
- Various layout and flow improvements around the menus

closes #1766
This commit is contained in:
Tom Moor
2021-01-13 22:00:25 -08:00
committed by GitHub
parent 47369dd968
commit e8b7782f5e
54 changed files with 1788 additions and 1881 deletions

View File

@ -3,8 +3,9 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useHistory } from "react-router-dom";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import Badge from "components/Badge";
import Button from "components/Button";
@ -18,7 +19,7 @@ import useCurrentUser from "hooks/useCurrentUser";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
type Props = {|
document: Document,
highlight?: ?string,
context?: ?string,
@ -27,7 +28,7 @@ type Props = {
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
};
|};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@ -40,7 +41,6 @@ function replaceResultMarks(tag: string) {
function DocumentListItem(props: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const history = useHistory();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
@ -53,23 +53,11 @@ function DocumentListItem(props: Props) {
context,
} = props;
const handleNewFromTemplate = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
history.push(
newDocumentUrl(document.collectionId, {
templateId: document.id,
})
);
},
[history, document]
);
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<DocumentLink
@ -80,83 +68,102 @@ function DocumentListItem(props: Props) {
state: { title: document.titleWithDefault },
}}
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
<Content>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
{!document.isDraft && !document.isArchived && !document.isTemplate && (
<Actions>
<StarButton document={document} />
</Actions>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
<SecondaryActions>
{document.isTemplate && !document.isArchived && !document.isDeleted && (
<Button onClick={handleNewFromTemplate} icon={<PlusIcon />} neutral>
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</Content>
<Actions>
{document.isTemplate && !document.isArchived && !document.isDeleted && (
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
)}
&nbsp;
<EventBoundary>
<DocumentMenu
document={document}
showPin={showPin}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</EventBoundary>
</SecondaryActions>
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</Actions>
</DocumentLink>
);
}
const SecondaryActions = styled(Flex)`
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const Actions = styled(EventBoundary)`
display: none;
align-items: center;
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
margin: 8px;
flex-shrink: 0;
flex-grow: 0;
${breakpoint("tablet")`
display: flex;
`};
`;
const DocumentLink = styled(Link)`
display: block;
display: flex;
align-items: center;
margin: 10px -8px;
padding: 6px 8px;
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
max-width: calc(100vw - 40px);
overflow: hidden;
position: relative;
${SecondaryActions} {
${Actions} {
opacity: 0;
}
@ -166,10 +173,11 @@ const DocumentLink = styled(Link)`
&:hover,
&:active,
&:focus {
&:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground};
${SecondaryActions} {
${Actions} {
opacity: 1;
}
@ -187,7 +195,7 @@ const DocumentLink = styled(Link)`
css`
background: ${(props) => props.theme.listItemHoverBackground};
${SecondaryActions} {
${Actions} {
opacity: 1;
}
@ -210,7 +218,7 @@ const Heading = styled.h3`
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
const Actions = styled(Flex)`
const StarPositioner = styled(Flex)`
margin-left: 4px;
align-items: center;
`;