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,229 +1,221 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import Collection from "models/Collection";
|
||||
import CollectionDelete from "scenes/CollectionDelete";
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
import CollectionExport from "scenes/CollectionExport";
|
||||
import CollectionMembers from "scenes/CollectionMembers";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
position?: "left" | "right" | "center",
|
||||
ui: UiStore,
|
||||
policies: PoliciesStore,
|
||||
documents: DocumentsStore,
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
history: RouterHistory,
|
||||
placement?: string,
|
||||
modal?: boolean,
|
||||
label?: (any) => React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class CollectionMenu extends React.Component<Props> {
|
||||
file: ?HTMLInputElement;
|
||||
@observable showCollectionMembers = false;
|
||||
@observable showCollectionEdit = false;
|
||||
@observable showCollectionDelete = false;
|
||||
@observable showCollectionExport = false;
|
||||
function CollectionMenu({
|
||||
collection,
|
||||
label,
|
||||
modal = true,
|
||||
placement,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const menu = useMenuState({ modal, placement });
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const { ui, documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { collection } = this.props;
|
||||
this.props.history.push(newDocumentUrl(collection.id));
|
||||
};
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
const [showCollectionMembers, setShowCollectionMembers] = React.useState(
|
||||
false
|
||||
);
|
||||
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
|
||||
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
|
||||
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
|
||||
|
||||
onImportDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// simulate a click on the file upload input element
|
||||
if (this.file) this.file.click();
|
||||
};
|
||||
|
||||
onFilePicked = async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await this.props.documents.import(
|
||||
file,
|
||||
null,
|
||||
this.props.collection.id,
|
||||
{ publish: true }
|
||||
);
|
||||
this.props.history.push(document.url);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
}, [onOpen]);
|
||||
|
||||
handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionEdit = true;
|
||||
};
|
||||
const handleNewDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
history.push(newDocumentUrl(collection.id));
|
||||
},
|
||||
[history, collection.id]
|
||||
);
|
||||
|
||||
handleEditCollectionClose = () => {
|
||||
this.showCollectionEdit = false;
|
||||
};
|
||||
const handleImportDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
handleDeleteCollectionOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionDelete = true;
|
||||
};
|
||||
// simulate a click on the file upload input element
|
||||
if (file.current) {
|
||||
file.current.click();
|
||||
}
|
||||
},
|
||||
[file]
|
||||
);
|
||||
|
||||
handleDeleteCollectionClose = () => {
|
||||
this.showCollectionDelete = false;
|
||||
};
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
handleExportCollectionOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionExport = true;
|
||||
};
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
this.props.collection.id,
|
||||
{ publish: true }
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[history, ui, documents]
|
||||
);
|
||||
|
||||
handleExportCollectionClose = () => {
|
||||
this.showCollectionExport = false;
|
||||
};
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
handleMembersModalOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionMembers = true;
|
||||
};
|
||||
|
||||
handleMembersModalClose = () => {
|
||||
this.showCollectionMembers = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
policies,
|
||||
documents,
|
||||
collection,
|
||||
position,
|
||||
onOpen,
|
||||
onClose,
|
||||
t,
|
||||
} = this.props;
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
ref={(ref) => (this.file = ref)}
|
||||
onChange={this.onFilePicked}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={this.handleMembersModalClose}
|
||||
isOpen={this.showCollectionMembers}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={collection}
|
||||
onSubmit={this.handleMembersModalClose}
|
||||
handleEditCollectionOpen={this.handleEditCollectionOpen}
|
||||
onEdit={this.handleEditCollectionOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: this.onNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: this.onImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: this.handleEditCollectionOpen,
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: this.handleMembersModalOpen,
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: this.handleExportCollectionOpen,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: this.handleDeleteCollectionOpen,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={this.showCollectionEdit}
|
||||
onRequestClose={this.handleEditCollectionClose}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={this.handleEditCollectionClose}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete collection")}
|
||||
isOpen={this.showCollectionDelete}
|
||||
onRequestClose={this.handleDeleteCollectionClose}
|
||||
>
|
||||
<CollectionDelete
|
||||
onSubmit={this.handleDeleteCollectionClose}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={this.showCollectionExport}
|
||||
onRequestClose={this.handleExportCollectionClose}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={this.handleExportCollectionClose}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
ref={file}
|
||||
onChange={handleFilePicked}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton {...menu} />
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={handleOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionMembers(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionMembers(false)}
|
||||
isOpen={showCollectionMembers}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={collection}
|
||||
onSubmit={() => setShowCollectionMembers(false)}
|
||||
onEdit={() => setShowCollectionEdit(true)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={showCollectionEdit}
|
||||
onRequestClose={() => setShowCollectionEdit(false)}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={() => setShowCollectionEdit(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete collection")}
|
||||
isOpen={showCollectionDelete}
|
||||
onRequestClose={() => setShowCollectionDelete(false)}
|
||||
>
|
||||
<CollectionDelete
|
||||
onSubmit={() => setShowCollectionDelete(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={showCollectionExport}
|
||||
onRequestClose={() => setShowCollectionExport(false)}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={() => setShowCollectionExport(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<CollectionMenu>(
|
||||
inject("ui", "documents", "policies")(withRouter(CollectionMenu))
|
||||
);
|
||||
export default observer(CollectionMenu);
|
||||
|
Reference in New Issue
Block a user