chore: new arrow key navigation (#3229)

* rebuild keyboard navigation lists
* add new keyboard navigation components
* remove references to boundless-arrow-key-navigation
* fix aria-labels on paginated lists everywhere
This commit is contained in:
Nan Yu
2022-03-15 10:36:10 -07:00
committed by GitHub
parent 093158cb11
commit d1b28499c6
18 changed files with 270 additions and 112 deletions

View File

@@ -0,0 +1,51 @@
import { observer } from "mobx-react";
import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback(
(ev) => {
if (onEscape) {
if (ev.key === "Escape") {
onEscape(ev);
}
if (
ev.key === "ArrowUp" &&
composite.currentId === composite.items[0].id
) {
onEscape(ev);
}
}
},
[composite.currentId, composite.items, onEscape]
);
return (
<Composite
{...rest}
{...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
>
{children(composite)}
</Composite>
);
}
export default observer(React.forwardRef(ArrowKeyNavigation));

View File

@@ -72,6 +72,7 @@ function DocumentHistory() {
</Header> </Header>
<Scrollable topShadow> <Scrollable topShadow>
<PaginatedEventList <PaginatedEventList
aria-label={t("History")}
fetch={events.fetchPage} fetch={events.fetchPage}
events={items} events={items}
options={{ options={{

View File

@@ -3,6 +3,7 @@ import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document"; import Document from "~/models/Document";
@@ -33,7 +34,8 @@ type Props = {
showPin?: boolean; showPin?: boolean;
showDraft?: boolean; showDraft?: boolean;
showTemplate?: boolean; showTemplate?: boolean;
}; } & CompositeStateReturn;
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) { function replaceResultMarks(tag: string) {
@@ -61,6 +63,7 @@ function DocumentListItem(
showTemplate, showTemplate,
highlight, highlight,
context, context,
...rest
} = props; } = props;
const queryIsInTitle = const queryIsInTitle =
!!highlight && !!highlight &&
@@ -71,7 +74,8 @@ function DocumentListItem(
const canCollection = usePolicy(document.collectionId); const canCollection = usePolicy(document.collectionId);
return ( return (
<DocumentLink <CompositeItem
as={DocumentLink}
ref={ref} ref={ref}
dir={document.dir} dir={document.dir}
$isStarred={document.isStarred} $isStarred={document.isStarred}
@@ -82,6 +86,7 @@ function DocumentListItem(
title: document.titleWithDefault, title: document.titleWithDefault,
}, },
}} }}
{...rest}
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
@@ -155,7 +160,7 @@ function DocumentListItem(
modal={false} modal={false}
/> />
</Actions> </Actions>
</DocumentLink> </CompositeItem>
); );
} }
@@ -196,6 +201,10 @@ const DocumentLink = styled(Link)<{
max-height: 50vh; max-height: 50vh;
width: calc(100vw - 8px); width: calc(100vw - 8px);
&:focus-visible {
outline: none;
}
${breakpoint("tablet")` ${breakpoint("tablet")`
width: auto; width: auto;
`}; `};

View File

@@ -40,6 +40,7 @@ function DocumentViews({ document, isOpen }: Props) {
<> <>
{isOpen && ( {isOpen && (
<PaginatedList <PaginatedList
aria-label={t("Viewers")}
items={users} items={users}
renderItem={(item) => { renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id); const view = documentViews.find((v) => v.user.id === item.id);

View File

@@ -9,10 +9,14 @@ import {
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import styled from "styled-components"; import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Event from "~/models/Event"; import Event from "~/models/Event";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import CompositeItem, {
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item"; import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time"; import Time from "~/components/Time";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
@@ -23,9 +27,9 @@ type Props = {
document: Document; document: Document;
event: Event; event: Event;
latest?: boolean; latest?: boolean;
}; } & CompositeStateReturn;
const EventListItem = ({ event, latest, document }: Props) => { const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const can = usePolicy(document.id); const can = usePolicy(document.id);
@@ -35,6 +39,13 @@ const EventListItem = ({ event, latest, document }: Props) => {
const isRevision = event.name === "revisions.create"; const isRevision = event.name === "revisions.create";
let meta, icon, to; let meta, icon, to;
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = React.useCallback(() => {
ref.current?.focus();
}, [ref]);
switch (event.name) { switch (event.name) {
case "revisions.create": case "revisions.create":
case "documents.latest_version": { case "documents.latest_version": {
@@ -89,11 +100,15 @@ const EventListItem = ({ event, latest, document }: Props) => {
const isActive = location.pathname === to; const isActive = location.pathname === to;
if (document.isDeleted) {
to = undefined;
}
return ( return (
<ListItem <BaseItem
small small
exact exact
to={document.isDeleted ? undefined : to} to={to}
title={ title={
<Time <Time
dateTime={event.createdAt} dateTime={event.createdAt}
@@ -101,6 +116,7 @@ const EventListItem = ({ event, latest, document }: Props) => {
format="MMM do, h:mm a" format="MMM do, h:mm a"
relative={false} relative={false}
addSuffix addSuffix
onClick={handleTimeClick}
/> />
} }
image={<Avatar src={event.actor?.avatarUrl} size={32} />} image={<Avatar src={event.actor?.avatarUrl} size={32} />}
@@ -115,10 +131,22 @@ const EventListItem = ({ event, latest, document }: Props) => {
<RevisionMenu document={document} revisionId={event.modelId} /> <RevisionMenu document={document} revisionId={event.modelId} />
) : undefined ) : undefined
} }
ref={ref}
{...rest}
/> />
); );
}; };
const BaseItem = React.forwardRef(
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
}
);
const Subtitle = styled.span` const Subtitle = styled.span`
svg { svg {
margin: -3px; margin: -3px;
@@ -126,7 +154,7 @@ const Subtitle = styled.span`
} }
`; `;
const ListItem = styled(Item)` const ItemStyle = css`
border: 0; border: 0;
position: relative; position: relative;
margin: 8px; margin: 8px;
@@ -172,4 +200,12 @@ const ListItem = styled(Item)`
} }
`; `;
const ListItem = styled(Item)`
${ItemStyle}
`;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default EventListItem; export default EventListItem;

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import {
CompositeStateReturn,
CompositeItem as BaseCompositeItem,
} from "reakit/Composite";
import Item, { Props as ItemProps } from "./Item";
export type Props = ItemProps & CompositeStateReturn;
function CompositeItem(
{ to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) {
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
}
export default React.forwardRef(CompositeItem);

View File

@@ -3,7 +3,7 @@ import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink"; import NavLink from "~/components/NavLink";
type Props = { export type Props = {
image?: React.ReactNode; image?: React.ReactNode;
to?: string; to?: string;
exact?: boolean; exact?: boolean;
@@ -63,13 +63,13 @@ const ListItem = (
} }
return ( return (
<Wrapper $border={border} $small={small} {...rest}> <Wrapper ref={ref} $border={border} $small={small} {...rest}>
{content(false)} {content(false)}
</Wrapper> </Wrapper>
); );
}; };
const Wrapper = styled.div<{ $small?: boolean; $border?: boolean }>` const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
display: flex; display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")}; padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => margin: ${(props) =>
@@ -81,6 +81,8 @@ const Wrapper = styled.div<{ $small?: boolean; $border?: boolean }>`
&:last-child { &:last-child {
border-bottom: 0; border-bottom: 0;
} }
cursor: ${({ to }) => (to ? "pointer" : "default")};
`; `;
const Image = styled(Flex)` const Image = styled(Flex)`

View File

@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document"; import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem"; import DocumentListItem from "~/components/DocumentListItem";
import PaginatedList from "~/components/PaginatedList"; import PaginatedList from "~/components/PaginatedList";
@@ -22,23 +23,37 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
documents, documents,
fetch, fetch,
options, options,
showParentDocuments,
showCollection,
showPublished,
showTemplate,
showDraft,
...rest ...rest
}: Props) { }: Props) {
const { t } = useTranslation();
return ( return (
<PaginatedList <PaginatedList
aria-label={t("Documents")}
items={documents} items={documents}
empty={empty} empty={empty}
heading={heading} heading={heading}
fetch={fetch} fetch={fetch}
options={options} options={options}
renderItem={(item) => ( renderItem={(item, _index, compositeProps) => (
<DocumentListItem <DocumentListItem
key={item.id} key={item.id}
document={item} document={item}
showPin={!!options?.collectionId} showPin={!!options?.collectionId}
{...rest} showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
{...compositeProps}
/> />
)} )}
{...rest}
/> />
); );
}); });

View File

@@ -29,16 +29,19 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading} heading={heading}
fetch={fetch} fetch={fetch}
options={options} options={options}
renderItem={(item, index) => ( renderItem={(item, index, compositeProps) => {
return (
<EventListItem <EventListItem
key={item.id} key={item.id}
event={item} event={item}
document={document} document={document}
latest={index === 0} latest={index === 0}
{...rest} {...compositeProps}
/> />
)} );
}}
renderHeading={(name) => <Heading>{name}</Heading>} renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/> />
); );
}); });

View File

@@ -1,12 +1,13 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { observable, action } from "mobx"; import { observable, action } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next"; import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint"; import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore"; import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount"; import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder"; import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores"; import withStores from "~/components/withStores";
@@ -18,9 +19,12 @@ type Props = WithTranslation &
options?: Record<string, any>; options?: Record<string, any>;
heading?: React.ReactNode; heading?: React.ReactNode;
empty?: React.ReactNode; empty?: React.ReactNode;
items: any[]; items: any[];
renderItem: (arg0: any, index: number) => React.ReactNode; renderItem: (
item: any,
index: number,
composite: CompositeStateReturn
) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode; renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
}; };
@@ -129,12 +133,14 @@ class PaginatedList extends React.Component<Props> {
{showList && ( {showList && (
<> <>
{heading} {heading}
<ArrowKeyNavigation <ArrowKeyNavigation aria-label={this.props["aria-label"]}>
mode={ArrowKeyNavigation.mode.VERTICAL} {(composite: CompositeStateReturn) =>
defaultActiveChildIndex={0} items.slice(0, this.renderCount).map((item, index) => {
> const children = this.props.renderItem(
{items.slice(0, this.renderCount).map((item, index) => { item,
const children = this.props.renderItem(item, index); index,
composite
);
// If there is no renderHeading method passed then no date // If there is no renderHeading method passed then no date
// headings are rendered // headings are rendered
@@ -166,7 +172,8 @@ class PaginatedList extends React.Component<Props> {
} }
return children; return children;
})} })
}
</ArrowKeyNavigation> </ArrowKeyNavigation>
{this.allowLoadMore && ( {this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} /> <Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />

View File

@@ -9,9 +9,11 @@ const LocaleTime = React.lazy(
) )
); );
type Props = React.ComponentProps<typeof LocaleTime>; type Props = React.ComponentProps<typeof LocaleTime> & {
onClick?: () => void;
};
function Time(props: Props) { function Time({ onClick, ...props }: Props) {
let content = formatDistanceToNow(Date.parse(props.dateTime), { let content = formatDistanceToNow(Date.parse(props.dateTime), {
addSuffix: props.addSuffix, addSuffix: props.addSuffix,
}); });
@@ -24,6 +26,7 @@ function Time(props: Props) {
} }
return ( return (
<span onClick={onClick}>
<React.Suspense <React.Suspense
fallback={ fallback={
<time dateTime={props.dateTime}>{props.children || content}</time> <time dateTime={props.dateTime}>{props.children || content}</time>
@@ -31,6 +34,7 @@ function Time(props: Props) {
> >
<LocaleTime tooltipDelay={250} {...props} /> <LocaleTime tooltipDelay={250} {...props} />
</React.Suspense> </React.Suspense>
</span>
); );
} }

View File

@@ -13,7 +13,7 @@ const ErrorSuspended = () => {
<CenteredContent> <CenteredContent>
<PageTitle title={t("Your account has been suspended")} /> <PageTitle title={t("Your account has been suspended")} />
<h1> <h1>
<span role="img" aria-label="Warning sign"> <span role="img" aria-label={t("Warning Sign")}>
</span>{" "} </span>{" "}
{t("Your account has been suspended")} {t("Your account has been suspended")}

View File

@@ -1,4 +1,3 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { observable, action } from "mobx"; import { observable, action } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
@@ -14,6 +13,7 @@ import { DateFilter as TDateFilter } from "@shared/types";
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore"; import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
import { SearchParams } from "~/stores/DocumentsStore"; import { SearchParams } from "~/stores/DocumentsStore";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem"; import DocumentListItem from "~/components/DocumentListItem";
import Empty from "~/components/Empty"; import Empty from "~/components/Empty";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
@@ -44,7 +44,8 @@ type Props = RouteComponentProps<
@observer @observer
class Search extends React.Component<Props> { class Search extends React.Component<Props> {
firstDocument: HTMLAnchorElement | null | undefined; compositeRef: HTMLDivElement | null | undefined;
searchInputRef: HTMLInputElement | null | undefined;
lastQuery = ""; lastQuery = "";
@@ -102,10 +103,11 @@ class Search extends React.Component<Props> {
if (ev.key === "ArrowDown") { if (ev.key === "ArrowDown") {
ev.preventDefault(); ev.preventDefault();
if (this.firstDocument) { if (this.compositeRef) {
if (this.firstDocument instanceof HTMLElement) { const linkItems = this.compositeRef.querySelectorAll(
this.firstDocument.focus(); "[href]"
} ) as NodeListOf<HTMLAnchorElement>;
linkItems[0]?.focus();
} }
} }
}; };
@@ -252,8 +254,16 @@ class Search extends React.Component<Props> {
}); });
}; };
setFirstDocumentRef = (ref: HTMLAnchorElement | null) => { setCompositeRef = (ref: HTMLDivElement | null) => {
this.firstDocument = ref; this.compositeRef = ref;
};
setSearchInputRef = (ref: HTMLInputElement | null) => {
this.searchInputRef = ref;
};
handleEscape = () => {
this.searchInputRef?.focus();
}; };
render() { render() {
@@ -275,6 +285,7 @@ class Search extends React.Component<Props> {
)} )}
<ResultsWrapper column auto> <ResultsWrapper column auto>
<SearchInput <SearchInput
ref={this.setSearchInputRef}
placeholder={`${t("Search")}`} placeholder={`${t("Search")}`}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
defaultValue={this.query} defaultValue={this.query}
@@ -329,26 +340,29 @@ class Search extends React.Component<Props> {
)} )}
<ResultList column> <ResultList column>
<StyledArrowKeyNavigation <StyledArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL} ref={this.setCompositeRef}
defaultActiveChildIndex={0} onEscape={this.handleEscape}
aria-label={t("Search Results")}
> >
{results.map((result, index) => { {(compositeProps) =>
results.map((result) => {
const document = documents.data.get(result.document.id); const document = documents.data.get(result.document.id);
if (!document) { if (!document) {
return null; return null;
} }
return ( return (
<DocumentListItem <DocumentListItem
ref={(ref) => index === 0 && this.setFirstDocumentRef(ref)}
key={document.id} key={document.id}
document={document} document={document}
highlight={this.query} highlight={this.query}
context={result.context} context={result.context}
showCollection showCollection
showTemplate showTemplate
{...compositeProps}
/> />
); );
})} })
}
</StyledArrowKeyNavigation> </StyledArrowKeyNavigation>
{this.allowLoadMore && ( {this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} /> <Waypoint key={this.offset} onEnter={this.loadMoreResults} />

View File

@@ -8,18 +8,19 @@ type Props = React.HTMLAttributes<HTMLInputElement> & {
placeholder?: string; placeholder?: string;
}; };
function SearchInput({ defaultValue, ...rest }: Props) { function SearchInput(
{ defaultValue, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
const theme = useTheme(); const theme = useTheme();
const inputRef = React.useRef<HTMLInputElement>(null);
const focusInput = React.useCallback(() => { const focusInput = React.useCallback(() => {
inputRef.current?.focus(); ref.current?.focus();
}, []); }, [ref]);
React.useEffect(() => { React.useEffect(() => {
// ensure that focus is placed at end of input // ensure that focus is placed at end of input
const len = (defaultValue || "").length; const len = (defaultValue || "").length;
inputRef.current?.setSelectionRange(len, len); ref.current?.setSelectionRange(len, len);
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
focusInput(); focusInput();
}, 100); // arbitrary number }, 100); // arbitrary number
@@ -27,7 +28,7 @@ function SearchInput({ defaultValue, ...rest }: Props) {
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
}; };
}, [defaultValue, focusInput]); }, [ref, defaultValue, focusInput]);
return ( return (
<Wrapper align="center"> <Wrapper align="center">
@@ -35,7 +36,7 @@ function SearchInput({ defaultValue, ...rest }: Props) {
<StyledInput <StyledInput
{...rest} {...rest}
defaultValue={defaultValue} defaultValue={defaultValue}
ref={inputRef} ref={ref}
spellCheck="false" spellCheck="false"
type="search" type="search"
autoFocus autoFocus
@@ -84,4 +85,4 @@ const StyledIcon = styled(SearchIcon)`
left: 8px; left: 8px;
`; `;
export default SearchInput; export default React.forwardRef(SearchInput);

View File

@@ -97,5 +97,6 @@ export default createGlobalStyle`
.js-focus-visible .focus-visible { .js-focus-visible .focus-visible {
outline-color: ${(props) => props.theme.primary}; outline-color: ${(props) => props.theme.primary};
outline-offset: -1px;
} }
`; `;

View File

@@ -1,7 +1,5 @@
declare module "autotrack/autotrack.js"; declare module "autotrack/autotrack.js";
declare module "boundless-arrow-key-navigation";
declare module "string-replace-to-array"; declare module "string-replace-to-array";
declare module "sequelize-encrypted"; declare module "sequelize-encrypted";

View File

@@ -67,7 +67,6 @@
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "^3.3.4",
"babel-plugin-styled-components": "^1.11.1", "babel-plugin-styled-components": "^1.11.1",
"babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-class-properties": "^6.24.1",
"boundless-arrow-key-navigation": "^1.0.4",
"bull": "^3.29.0", "bull": "^3.29.0",
"cancan": "3.1.0", "cancan": "3.1.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",

View File

@@ -49,7 +49,6 @@ module.exports = {
"~": path.resolve(__dirname, 'app'), "~": path.resolve(__dirname, 'app'),
"@shared": path.resolve(__dirname, 'shared'), "@shared": path.resolve(__dirname, 'shared'),
"@server": path.resolve(__dirname, 'server'), "@server": path.resolve(__dirname, 'server'),
'boundless-arrow-key-navigation': 'boundless-arrow-key-navigation/build',
'boundless-popover': 'boundless-popover/build', 'boundless-popover': 'boundless-popover/build',
'boundless-utils-omit-keys': 'boundless-utils-omit-keys/build', 'boundless-utils-omit-keys': 'boundless-utils-omit-keys/build',
'boundless-utils-uuid': 'boundless-utils-uuid/build' 'boundless-utils-uuid': 'boundless-utils-uuid/build'