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

@@ -11,11 +11,6 @@ export const Action = styled(Flex)`
font-size: 15px;
flex-shrink: 0;
a {
color: ${(props) => props.theme.text};
height: 24px;
}
&:empty {
display: none;
}

View File

@@ -4,7 +4,6 @@ import {
ArchiveIcon,
EditIcon,
GoToIcon,
MoreIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
@@ -14,18 +13,15 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import BreadcrumbMenu from "./BreadcrumbMenu";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
document: Document,
collections: CollectionsStore,
onlyText: boolean,
};
@@ -133,7 +129,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
@@ -148,6 +144,11 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
);
};
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)`
display: none;
@@ -168,22 +169,6 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.25;
`;
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Overflow = styled(MoreIcon)`
flex-shrink: 0;
transition: opacity 100ms ease-in-out;
fill: ${(props) => props.theme.divider};
&:active,
&:hover {
fill: ${(props) => props.theme.text};
}
`;
const Crumb = styled(Link)`
color: ${(props) => props.theme.text};
font-size: 15px;
@@ -199,12 +184,17 @@ const Crumb = styled(Link)`
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 0;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
export default observer(Breadcrumb);

View File

@@ -1,22 +0,0 @@
// @flow
import * as React from "react";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
type Props = {
label: React.Node,
path: Array<any>,
};
export default function BreadcrumbMenu({ label, path }: Props) {
return (
<DropdownMenu label={label} position="center">
<DropdownMenuItems
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</DropdownMenu>
);
}

View File

@@ -22,9 +22,13 @@ const RealButton = styled.button`
cursor: pointer;
user-select: none;
svg {
fill: ${(props) => props.iconColor || props.theme.buttonText};
}
${(props) =>
!props.borderOnHover &&
`
svg {
fill: ${props.iconColor || props.theme.buttonText};
}
`}
&::-moz-focus-inner {
padding: 0;
@@ -42,7 +46,7 @@ const RealButton = styled.button`
}
${(props) =>
props.neutral &&
props.$neutral &&
`
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText};
@@ -52,9 +56,14 @@ const RealButton = styled.button`
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
};
svg {
${
props.borderOnHover
? ""
: `svg {
fill: ${props.iconColor || props.theme.buttonNeutralText};
}`
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
@@ -72,9 +81,9 @@ const RealButton = styled.button`
background: ${props.theme.danger};
color: ${props.theme.white};
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
`};
`;
@@ -108,6 +117,7 @@ export type Props = {
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
fullwidth?: boolean,
borderOnHover?: boolean,
};
@@ -119,13 +129,14 @@ function Button({
value,
disclosure,
innerRef,
neutral,
...rest
}: Props) {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton type={type} ref={innerRef} {...rest}>
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}

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

@@ -1,6 +1,7 @@
// @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 = {
@@ -8,31 +9,35 @@ type Props = {
children?: React.Node,
selected?: boolean,
disabled?: boolean,
as?: string | React.ComponentType<*>,
};
const DropdownMenuItem = ({
const MenuItem = ({
onClick,
children,
selected,
disabled,
as,
...rest
}: Props) => {
return (
<MenuItem
<BaseMenuItem
onClick={disabled ? undefined : onClick}
disabled={disabled}
role="menuitem"
tabIndex="-1"
{...rest}
>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
{(props) => (
<MenuAnchor as={onClick ? "button" : as} {...props}>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
{children}
</MenuAnchor>
)}
{children}
</MenuItem>
</BaseMenuItem>
);
};
@@ -41,13 +46,14 @@ const Spacer = styled.div`
height: 24px;
`;
const MenuItem = styled.a`
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;
@@ -61,6 +67,7 @@ const MenuItem = styled.a`
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
@@ -69,7 +76,8 @@ const MenuItem = styled.a`
? "pointer-events: none;"
: `
&:hover {
&:hover,
&.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
box-shadow: none;
@@ -87,4 +95,4 @@ const MenuItem = styled.a`
`};
`;
export default DropdownMenuItem;
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

@@ -1,13 +1,19 @@
// @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 Flex from "components/Flex";
import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
type MenuItem =
type TMenuItem =
| {|
title: React.Node,
to: string,
@@ -35,7 +41,7 @@ type MenuItem =
disabled?: boolean,
style?: Object,
hover?: boolean,
items: MenuItem[],
items: TMenuItem[],
|}
| {|
type: "separator",
@@ -48,14 +54,35 @@ type MenuItem =
|};
type Props = {|
items: MenuItem[],
items: TMenuItem[],
|};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
justify-self: flex-end;
`;
export default function DropdownMenuItems({ items }: Props): React.Node {
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
@@ -76,69 +103,67 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
return filtered.map((item, index) => {
if (item.to) {
return (
<DropdownMenuItem
<MenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
{...menu}
>
{item.title}
</DropdownMenuItem>
</MenuItem>
);
}
if (item.href) {
return (
<DropdownMenuItem
<MenuItem
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
target="_blank"
{...menu}
>
{item.title}
</DropdownMenuItem>
</MenuItem>
);
}
if (item.onClick) {
return (
<DropdownMenuItem
<MenuItem
as="button"
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
{...menu}
>
{item.title}
</DropdownMenuItem>
</MenuItem>
);
}
if (item.items) {
return (
<DropdownMenu
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
<Flex justify="space-between" align="center" auto>
{item.title}
<Disclosure color="currentColor" />
</Flex>
</DropdownMenuItem>
}
hover={item.hover}
<BaseMenuItem
key={index}
>
<DropdownMenuItems items={item.items} />
</DropdownMenu>
as={Submenu}
templateItems={item.items}
title={item.title}
{...menu}
/>
);
}
if (item.type === "separator") {
return <hr key={index} />;
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;
}
`;

View File

@@ -1,6 +1,5 @@
// @flow
import format from "date-fns/format";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -45,9 +44,7 @@ class RevisionListItem extends React.Component<Props> {
<StyledRevisionMenu
document={document}
revision={revision}
label={
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>

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;
`;

View File

@@ -15,6 +15,7 @@ const Container = styled(Flex)`
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Modified = styled.span`

View File

@@ -1,289 +0,0 @@
// @flow
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { MoreIcon } from "outline-icons";
import { rgba } from "polished";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { PortalWithState } from "react-portal";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
let previousClosePortal;
let counter = 0;
type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {|
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
children?: Children,
className?: string,
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
t: TFunction,
|};
@observer
class DropdownMenu extends React.Component<Props> {
id: string = `menu${counter++}`;
closeTimeout: TimeoutID;
@observable top: ?number;
@observable bottom: ?number;
@observable right: ?number;
@observable left: ?number;
@observable position: "left" | "right" | "center";
@observable fixed: ?boolean;
@observable bodyRect: ClientRect;
@observable labelRect: ClientRect;
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
@observable menuRef: { current: null | HTMLElement } = React.createRef();
handleOpen = (
openPortal: (SyntheticEvent<>) => void,
closePortal: () => void
) => {
return (ev: SyntheticMouseEvent<HTMLElement>) => {
ev.preventDefault();
const currentTarget = ev.currentTarget;
invariant(document.body, "why you not here");
if (currentTarget instanceof HTMLDivElement) {
this.bodyRect = document.body.getBoundingClientRect();
this.labelRect = currentTarget.getBoundingClientRect();
this.top = this.labelRect.bottom - this.bodyRect.top;
this.bottom = undefined;
this.position = this.props.position || "left";
if (currentTarget.parentElement) {
const triggerParentStyle = getComputedStyle(
currentTarget.parentElement
);
if (triggerParentStyle.position === "static") {
this.fixed = true;
this.top = this.labelRect.bottom;
}
}
this.initPosition();
// attempt to keep only one flyout menu open at once
if (previousClosePortal && !this.props.hover) {
previousClosePortal();
}
previousClosePortal = closePortal;
openPortal(ev);
}
};
};
initPosition() {
if (this.position === "left") {
this.right =
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
} else if (this.position === "center") {
this.left = this.labelRect.left + this.labelRect.width / 2;
} else {
this.left = this.labelRect.left;
}
}
onOpen = () => {
if (typeof this.props.onOpen === "function") {
this.props.onOpen();
}
this.fitOnTheScreen();
};
fitOnTheScreen() {
if (!this.dropdownRef || !this.dropdownRef.current) return;
const el = this.dropdownRef.current;
const sticksOutPastBottomEdge =
el.clientHeight + this.top > window.innerHeight;
if (sticksOutPastBottomEdge) {
this.top = undefined;
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
} else {
this.bottom = undefined;
}
if (this.position === "left" || this.position === "right") {
const totalWidth =
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
el.scrollWidth;
const isVisible = totalWidth < window.innerWidth;
if (!isVisible) {
if (this.position === "right") {
this.position = "left";
this.left = undefined;
} else if (this.position === "left") {
this.position = "right";
this.right = undefined;
}
}
}
this.initPosition();
this.forceUpdate();
}
closeAfterTimeout = (closePortal: () => void) => () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
this.closeTimeout = setTimeout(closePortal, 500);
};
clearCloseTimeout = () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
};
render() {
const { className, hover, label, children, t } = this.props;
return (
<div className={className}>
<PortalWithState
onOpen={this.onOpen}
onClose={this.props.onClose}
closeOnOutsideClick
closeOnEsc
>
{({ closePortal, openPortal, isOpen, portal }) => (
<>
<Label
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onMouseEnter={
hover ? this.handleOpen(openPortal, closePortal) : undefined
}
onClick={
hover ? undefined : this.handleOpen(openPortal, closePortal)
}
>
{label || (
<NudeButton
id={`${this.id}button`}
aria-label={t("More options")}
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}
>
<MoreIcon />
</NudeButton>
)}
</Label>
{portal(
<Position
ref={this.dropdownRef}
position={this.position}
fixed={this.fixed}
top={this.top}
bottom={this.bottom}
left={this.left}
right={this.right}
>
<Menu
ref={this.menuRef}
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onClick={
typeof children === "function"
? undefined
: (ev) => {
ev.stopPropagation();
closePortal();
}
}
style={this.props.style}
id={this.id}
aria-labelledby={`${this.id}button`}
role="menu"
>
{typeof children === "function"
? children({ closePortal })
: children}
</Menu>
</Position>
)}
</>
)}
</PortalWithState>
</div>
);
}
}
const Label = styled(Flex).attrs({
justify: "center",
align: "center",
})`
z-index: ${(props) => props.theme.depths.menu};
cursor: pointer;
`;
const Position = styled.div`
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
display: flex;
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
max-height: 75%;
z-index: ${(props) => props.theme.depths.menu};
transform: ${(props) =>
props.position === "center" ? "translateX(-50%)" : "initial"};
pointer-events: none;
`;
const Menu = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
backdrop-filter: blur(10px);
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
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;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
hr {
margin: 0.5em 12px;
}
@media print {
display: none;
}
`;
export 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 withTranslation()<DropdownMenu>(DropdownMenu);

View File

@@ -1,3 +0,0 @@
// @flow
export { default as DropdownMenu, Header } from "./DropdownMenu";
export { default as DropdownMenuItem } from "./DropdownMenuItem";

View File

@@ -4,13 +4,18 @@ import * as React from "react";
type Props = {
children: React.Node,
className?: string,
};
export default function EventBoundary({ children }: Props) {
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
return <span onClick={handleClick}>{children}</span>;
return (
<span onClick={handleClick} className={className}>
{children}
</span>
);
}

View File

@@ -1,6 +1,4 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import {
CollectionIcon,
CoinsIcon,
@@ -22,14 +20,17 @@ import {
VehicleIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import { DropdownMenu } from "components/DropdownMenu";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { LabelText } from "components/Input";
import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter")
);
@@ -122,107 +123,77 @@ const colors = [
"#2F362F",
];
type Props = {
type Props = {|
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
t: TFunction,
};
|};
function preventEventBubble(event) {
event.stopPropagation();
function IconPicker({ onOpen, icon, color, onChange }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
return (
<Wrapper>
<Label>
<LabelText>{t("Icon")}</LabelText>
</Label>
<MenuButton {...menu}>
{(props) => (
<Button {...props}>
<Component role="button" color={color} size={30} />
</Button>
)}
</MenuButton>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
onClick={() => onChange(color, name)}
{...menu}
>
{(props) => (
<IconButton style={style} {...props}>
<Component color={color} size={30} />
</IconButton>
)}
</MenuItem>
);
})}
</Icons>
<Flex>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</ContextMenu>
</Wrapper>
);
}
@observer
class IconPicker extends React.Component<Props> {
@observable isOpen: boolean = false;
node: ?HTMLElement;
componentDidMount() {
window.addEventListener("click", this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener("click", this.handleClickOutside);
}
handleClose = () => {
this.isOpen = false;
};
handleOpen = () => {
this.isOpen = true;
if (this.props.onOpen) {
this.props.onOpen();
}
};
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (ev.target && this.node && this.node.contains(ev.target)) {
return;
}
this.handleClose();
};
render() {
const { t } = this.props;
const Component = icons[this.props.icon || "collection"].component;
return (
<Wrapper ref={(ref) => (this.node = ref)}>
<label>
<LabelText>{t("Icon")}</LabelText>
</label>
<DropdownMenu
onOpen={this.handleOpen}
label={
<LabelButton>
<Component role="button" color={this.props.color} size={30} />
</LabelButton>
}
>
<Icons onClick={preventEventBubble}>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<IconButton
key={name}
onClick={() => this.props.onChange(this.props.color, name)}
style={{ width: 30, height: 30 }}
>
<Component color={this.props.color} size={30} />
</IconButton>
);
})}
</Icons>
<Flex onClick={preventEventBubble}>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<ColorPicker
color={this.props.color}
onChange={(color) =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</DropdownMenu>
</Wrapper>
);
}
}
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 15px 9px 9px 15px;
width: 276px;
`;
const LabelButton = styled(NudeButton)`
const Button = styled(NudeButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
width: 32px;
height: 32px;
@@ -249,4 +220,4 @@ const Wrapper = styled("div")`
position: relative;
`;
export default withTranslation()<IconPicker>(IconPicker);
export default IconPicker;

View File

@@ -75,16 +75,17 @@ class MainSidebar extends React.Component<Props> {
return (
<Sidebar>
<AccountMenu
label={
<AccountMenu>
{(props) => (
<HeaderBlock
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
/>
}
/>
)}
</AccountMenu>
<Flex auto column>
<Scrollable shadow>
<Section>

View File

@@ -100,14 +100,12 @@ function CollectionLink({
<>
{can.update && (
<CollectionSortMenuWithMargin
position="right"
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
position="right"
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}

View File

@@ -242,7 +242,6 @@ function DocumentLink({
document && !isMoving ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}

View File

@@ -12,26 +12,22 @@ type Props = {
logoUrl: string,
};
function HeaderBlock({
showDisclosure,
teamName,
subheading,
logoUrl,
...rest
}: Props) {
return (
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
);
}
const HeaderBlock = React.forwardRef<Props, any>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => {
return (
<Header justify="flex-start" align="center" ref={ref} {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
);
}
);
const StyledExpandedIcon = styled(ExpandedIcon)`
position: absolute;

View File

@@ -1,7 +1,13 @@
// @flow
import * as React from "react";
import { withRouter, NavLink } from "react-router-dom";
import {
withRouter,
NavLink,
type RouterHistory,
type Match,
} from "react-router-dom";
import styled, { withTheme } from "styled-components";
import EventBoundary from "components/EventBoundary";
import { type Theme } from "types";
type Props = {
@@ -10,6 +16,7 @@ type Props = {
innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => void,
onMouseEnter?: (SyntheticEvent<>) => void,
className?: string,
children?: React.Node,
icon?: React.Node,
label?: React.Node,
@@ -18,6 +25,8 @@ type Props = {
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme,
exact?: boolean,
depth?: number,
@@ -39,7 +48,9 @@ function SidebarLink({
href,
innerRef,
depth,
...rest
history,
match,
className,
}: Props) {
const style = React.useMemo(() => {
return {
@@ -70,7 +81,7 @@ function SidebarLink({
as={to ? undefined : href ? "a" : "div"}
href={href}
ref={innerRef}
{...rest}
className={className}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -84,9 +95,10 @@ const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
`;
const Actions = styled.span`
const Actions = styled(EventBoundary)`
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
position: absolute;
top: 4px;
@@ -110,7 +122,6 @@ const Actions = styled.span`
const StyledNavLink = styled(NavLink)`
display: flex;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 16px;
border-radius: 4px;
@@ -121,6 +132,7 @@ const StyledNavLink = styled(NavLink)`
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 15px;
cursor: pointer;
overflow: hidden;
svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}

View File

@@ -8,7 +8,7 @@ import type { Toast as TToast } from "types";
type Props = {
onRequestClose: () => void,
closeAfterMs: number,
closeAfterMs?: number,
toast: TToast,
};