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:
@@ -1,45 +1,61 @@
|
||||
// @flow
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: string,
|
||||
note?: string,
|
||||
onSelect: () => void,
|
||||
active: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
const FilterOption = ({ label, note, onSelect, active }: Props) => {
|
||||
const FilterOption = ({ label, note, onSelect, active, ...rest }: Props) => {
|
||||
return (
|
||||
<ListItem active={active}>
|
||||
<Anchor onClick={active ? undefined : onSelect}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<span>
|
||||
{label}
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
</span>
|
||||
{active && <Checkmark />}
|
||||
</Flex>
|
||||
</Anchor>
|
||||
</ListItem>
|
||||
<MenuItem onClick={active ? undefined : onSelect} {...rest}>
|
||||
{(props) => (
|
||||
<ListItem>
|
||||
<Button active={active} {...props}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<span>
|
||||
{label}
|
||||
{note && <Description small>{note}</Description>}
|
||||
</span>
|
||||
{active && <Checkmark />}
|
||||
</Flex>
|
||||
</Button>
|
||||
</ListItem>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Description = styled(HelpText)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const Checkmark = styled(CheckmarkIcon)`
|
||||
flex-shrink: 0;
|
||||
padding-left: 4px;
|
||||
fill: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
const Anchor = styled("a")`
|
||||
const Button = styled.button`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 15px;
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
text-align: left;
|
||||
font-weight: ${(props) => (props.active ? "600" : "normal")};
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
|
||||
${HelpText} {
|
||||
@@ -54,7 +70,7 @@ const Anchor = styled("a")`
|
||||
|
||||
const ListItem = styled("li")`
|
||||
list-style: none;
|
||||
font-weight: ${(props) => (props.active ? "600" : "normal")};
|
||||
max-width: 250px;
|
||||
`;
|
||||
|
||||
export default FilterOption;
|
||||
|
||||
@@ -1,63 +1,74 @@
|
||||
// @flow
|
||||
import { find } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "components/Button";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import FilterOption from "./FilterOption";
|
||||
|
||||
type Props = {
|
||||
options: {
|
||||
key: string,
|
||||
label: string,
|
||||
note?: string,
|
||||
}[],
|
||||
type TFilterOption = {|
|
||||
key: string,
|
||||
label: string,
|
||||
note?: string,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
options: TFilterOption[],
|
||||
activeKey: ?string,
|
||||
defaultLabel?: string,
|
||||
selectedPrefix?: string,
|
||||
className?: string,
|
||||
onSelect: (key: ?string) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
activeKey = "",
|
||||
defaultLabel,
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
}: Props) => {
|
||||
const menu = useMenuState();
|
||||
const selected = find(options, { key: activeKey }) || options[0];
|
||||
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
|
||||
|
||||
return (
|
||||
<DropdownButton label={activeKey ? selectedLabel : defaultLabel}>
|
||||
{({ closeMenu }) => (
|
||||
<SearchFilter>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
neutral
|
||||
disclosure
|
||||
small
|
||||
>
|
||||
{activeKey ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} {...menu}>
|
||||
<List>
|
||||
{options.map((option) => (
|
||||
<FilterOption
|
||||
key={option.key}
|
||||
onSelect={() => {
|
||||
onSelect(option.key);
|
||||
closeMenu();
|
||||
menu.hide();
|
||||
}}
|
||||
active={option.key === activeKey}
|
||||
{...option}
|
||||
{...menu}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</DropdownButton>
|
||||
</ContextMenu>
|
||||
</SearchFilter>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled("div")`
|
||||
padding: 0 8px;
|
||||
width: 250px;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
@@ -73,32 +84,14 @@ const StyledButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchFilter = (props) => {
|
||||
return (
|
||||
<DropdownMenu
|
||||
className={props.className}
|
||||
label={
|
||||
<StyledButton neutral disclosure small>
|
||||
{props.label}
|
||||
</StyledButton>
|
||||
}
|
||||
position="right"
|
||||
>
|
||||
{({ closePortal }) => (
|
||||
<Content>{props.children({ closeMenu: closePortal })}</Content>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const DropdownButton = styled(SearchFilter)`
|
||||
const SearchFilter = styled.div`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const List = styled("ol")`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
export default FilterOptions;
|
||||
|
||||
Reference in New Issue
Block a user