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

@@ -0,0 +1,13 @@
// @flow
import styled from "styled-components";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;

View File

@@ -0,0 +1,98 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
type Props = {
onClick?: (SyntheticEvent<>) => void | Promise<void>,
children?: React.Node,
selected?: boolean,
disabled?: boolean,
as?: string | React.ComponentType<*>,
};
const MenuItem = ({
onClick,
children,
selected,
disabled,
as,
...rest
}: Props) => {
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
disabled={disabled}
{...rest}
>
{(props) => (
<MenuAnchor as={onClick ? "button" : as} {...props}>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
{children}
</MenuAnchor>
)}
</BaseMenuItem>
);
};
const Spacer = styled.div`
width: 24px;
height: 24px;
`;
export const MenuAnchor = styled.a`
display: flex;
margin: 0;
border: 0;
padding: 6px 12px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 15px;
cursor: default;
user-select: none;
svg:not(:last-child) {
margin-right: 8px;
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) =>
props.disabled
? "pointer-events: none;"
: `
&:hover,
&.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
box-shadow: none;
cursor: pointer;
svg {
fill: ${props.theme.white};
}
}
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
}
`};
`;
export default MenuItem;

View File

@@ -0,0 +1,21 @@
// @flow
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "components/NudeButton";
export default function OverflowMenuButton({
iconColor,
className,
...rest
}: any) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon color={iconColor} />
</NudeButton>
)}
</MenuButton>
);
}

View File

@@ -0,0 +1,16 @@
// @flow
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: {}) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 0.5em 12px;
`;

View File

@@ -0,0 +1,169 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: TMenuItem[],
|};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
justify-self: flex-end;
`;
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
{title} <Disclosure color="currentColor" />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
if (item.type === "separator" && index === filtered.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
return filtered.map((item, index) => {
if (item.to) {
return (
<MenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.href) {
return (
<MenuItem
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
target="_blank"
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.onClick) {
return (
<MenuItem
as="button"
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.items) {
return (
<BaseMenuItem
key={index}
as={Submenu}
templateItems={item.items}
title={item.title}
{...menu}
/>
);
}
if (item.type === "separator") {
return <Separator key={index} />;
}
return null;
});
}
export default React.memo<Props>(Template);

View File

@@ -0,0 +1,77 @@
// @flow
import { rgba } from "polished";
import * as React from "react";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
onClose?: () => void,
|};
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
}
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<Menu {...rest}>
{(props) => (
<Position {...props}>
<Background>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
);
}
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
`;
const Background = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
max-width: 276px;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
font-weight: normal;
@media print {
display: none;
}
`;