Replace reakit/Composite with react-roving-tabindex (#6985)
* fix: replace reakit composite with react-roving-tabindex * fix: touch points * fix: focus stuck at first list item * fix: document history navigation * fix: remove ununsed ListItem components * fix: keyboard navigation in recent search list * fix: updated lib
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
canEdit: boolean;
|
||||
onAdd: () => void;
|
||||
};
|
||||
|
||||
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(UserListItem);
|
||||
@@ -53,8 +53,8 @@ function Search(props: Props) {
|
||||
|
||||
// refs
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const resultListCompositeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const recentSearchesCompositeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const resultListRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const recentSearchesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// filters
|
||||
const query = decodeURIComponentSafe(routeMatch.params.term ?? "");
|
||||
@@ -178,19 +178,9 @@ function Search(props: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const firstResultItem = (
|
||||
resultListCompositeRef.current?.querySelectorAll(
|
||||
"[href]"
|
||||
) as NodeListOf<HTMLAnchorElement>
|
||||
)?.[0];
|
||||
const firstItem = (resultListRef.current?.firstElementChild ??
|
||||
recentSearchesRef.current?.firstElementChild) as HTMLAnchorElement;
|
||||
|
||||
const firstRecentSearchItem = (
|
||||
recentSearchesCompositeRef.current?.querySelectorAll(
|
||||
"li > [href]"
|
||||
) as NodeListOf<HTMLAnchorElement>
|
||||
)?.[0];
|
||||
|
||||
const firstItem = firstResultItem ?? firstRecentSearchItem;
|
||||
firstItem?.focus();
|
||||
}
|
||||
};
|
||||
@@ -277,11 +267,11 @@ function Search(props: Props) {
|
||||
)}
|
||||
<ResultList column>
|
||||
<StyledArrowKeyNavigation
|
||||
ref={resultListCompositeRef}
|
||||
ref={resultListRef}
|
||||
onEscape={handleEscape}
|
||||
aria-label={t("Search Results")}
|
||||
>
|
||||
{(compositeProps) =>
|
||||
{() =>
|
||||
data?.length
|
||||
? data.map((result) => (
|
||||
<DocumentListItem
|
||||
@@ -291,7 +281,6 @@ function Search(props: Props) {
|
||||
context={result.context}
|
||||
showCollection
|
||||
showTemplate
|
||||
{...compositeProps}
|
||||
/>
|
||||
))
|
||||
: null
|
||||
@@ -305,10 +294,7 @@ function Search(props: Props) {
|
||||
</ResultList>
|
||||
</>
|
||||
) : documentId || collectionId ? null : (
|
||||
<RecentSearches
|
||||
ref={recentSearchesCompositeRef}
|
||||
onEscape={handleEscape}
|
||||
/>
|
||||
<RecentSearches ref={recentSearchesRef} onEscape={handleEscape} />
|
||||
)}
|
||||
</ResultsWrapper>
|
||||
</Scene>
|
||||
|
||||
92
app/scenes/Search/components/RecentSearchListItem.tsx
Normal file
92
app/scenes/Search/components/RecentSearchListItem.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
useFocusEffect,
|
||||
useRovingTabIndex,
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type SearchQuery from "~/models/SearchQuery";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { hover } from "~/styles";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
searchQuery: SearchQuery;
|
||||
};
|
||||
|
||||
function RecentSearchListItem({ searchQuery }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(ref, false);
|
||||
useFocusEffect(focused, ref);
|
||||
|
||||
return (
|
||||
<RecentSearch
|
||||
to={searchPath(searchQuery.query)}
|
||||
ref={ref}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
{searchQuery.query}
|
||||
<Tooltip content={t("Remove search")} delay={150}>
|
||||
<RemoveButton
|
||||
aria-label={t("Remove search")}
|
||||
onClick={async (ev) => {
|
||||
ev.preventDefault();
|
||||
await searchQuery.delete();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</RemoveButton>
|
||||
</Tooltip>
|
||||
</RecentSearch>
|
||||
);
|
||||
}
|
||||
|
||||
const RemoveButton = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover {
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
|
||||
const RecentSearch = styled(Link)`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
|
||||
&:before {
|
||||
content: "·";
|
||||
color: ${s("textTertiary")};
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:${hover} {
|
||||
color: ${s("text")};
|
||||
background: ${s("secondaryBackground")};
|
||||
|
||||
${RemoveButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default RecentSearchListItem;
|
||||
@@ -1,18 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeItem } from "reakit/Composite";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import RecentSearchListItem from "./RecentSearchListItem";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the Escape key is pressed while navigating the list */
|
||||
@@ -36,39 +30,20 @@ function RecentSearches(
|
||||
const content = searches.recent.length ? (
|
||||
<>
|
||||
<Heading>{t("Recent searches")}</Heading>
|
||||
<List>
|
||||
<ArrowKeyNavigation
|
||||
ref={ref}
|
||||
onEscape={onEscape}
|
||||
aria-label={t("Search Results")}
|
||||
>
|
||||
{(compositeProps) =>
|
||||
searches.recent.map((searchQuery) => (
|
||||
<ListItem key={searchQuery.id}>
|
||||
<CompositeItem
|
||||
as={RecentSearch}
|
||||
to={searchPath(searchQuery.query)}
|
||||
role="menuitem"
|
||||
{...compositeProps}
|
||||
>
|
||||
{searchQuery.query}
|
||||
<Tooltip content={t("Remove search")} delay={150}>
|
||||
<RemoveButton
|
||||
aria-label={t("Remove search")}
|
||||
onClick={async (ev) => {
|
||||
ev.preventDefault();
|
||||
await searchQuery.delete();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</RemoveButton>
|
||||
</Tooltip>
|
||||
</CompositeItem>
|
||||
</ListItem>
|
||||
))
|
||||
}
|
||||
</ArrowKeyNavigation>
|
||||
</List>
|
||||
<StyledArrowKeyNavigation
|
||||
ref={ref}
|
||||
onEscape={onEscape}
|
||||
aria-label={t("Recent searches")}
|
||||
>
|
||||
{() =>
|
||||
searches.recent.map((searchQuery) => (
|
||||
<RecentSearchListItem
|
||||
key={searchQuery.id}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</StyledArrowKeyNavigation>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
@@ -83,55 +58,9 @@ const Heading = styled.h2`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const List = styled.ol`
|
||||
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
|
||||
padding: 0;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: "·";
|
||||
color: ${s("textTertiary")};
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const RemoveButton = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover {
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
|
||||
const RecentSearch = styled(Link)`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&: ${hover} {
|
||||
color: ${s("text")};
|
||||
background: ${s("secondaryBackground")};
|
||||
|
||||
${RemoveButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(RecentSearches));
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Share from "~/models/Share";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import ShareMenu from "~/menus/ShareMenu";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
};
|
||||
|
||||
const ShareListItem = ({ share }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { lastAccessedAt } = share;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={share.documentTitle}
|
||||
subtitle={
|
||||
<>
|
||||
{t("Shared")} <Time dateTime={share.createdAt} addSuffix />{" "}
|
||||
{t("by {{ name }}", {
|
||||
name: share.createdBy.name,
|
||||
})}{" "}
|
||||
{lastAccessedAt && (
|
||||
<>
|
||||
{" "}
|
||||
· {t("Last accessed")}{" "}
|
||||
<Time dateTime={lastAccessedAt} addSuffix />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={<ShareMenu share={share} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareListItem;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
showMenu: boolean;
|
||||
};
|
||||
|
||||
const UserListItem = ({ user, showMenu }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={<Title>{user.name}</Title>}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
subtitle={
|
||||
<>
|
||||
{user.email ? `${user.email} · ` : undefined}
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Invited")
|
||||
)}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={showMenu ? <UserMenu user={user} /> : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(UserListItem);
|
||||
Reference in New Issue
Block a user