feat: Search shared documents (#3126)
* provide a type-ahead search input on shared document pages that allow search of child document tree * improve keyboard navigation handling of all search views * improve coloring on dark mode list selection states * refactor PaginatedList component to eliminate edge cases
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -9,7 +10,6 @@ import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
@@ -49,21 +49,25 @@ function CollectionDescription({ collection }: Props) {
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000);
|
||||
const handleSave = React.useMemo(
|
||||
() =>
|
||||
debounce(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
|
||||
@@ -41,10 +41,10 @@ function Highlight({
|
||||
);
|
||||
}
|
||||
|
||||
const Mark = styled.mark`
|
||||
export const Mark = styled.mark`
|
||||
background: ${(props) => props.theme.searchHighlight};
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
padding: 0 2px;
|
||||
`;
|
||||
|
||||
export default Highlight;
|
||||
|
||||
@@ -119,6 +119,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
onChange?: (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
@@ -126,7 +127,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
input = this.props.innerRef;
|
||||
|
||||
@observable
|
||||
focused = false;
|
||||
@@ -147,10 +148,6 @@ class Input extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
focus() {
|
||||
this.input.current?.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
type = "text",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
import Input, { Props as InputProps } from "~/components/Input";
|
||||
|
||||
type Props = InputProps & {
|
||||
placeholder?: string;
|
||||
@@ -11,7 +11,10 @@ type Props = InputProps & {
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
};
|
||||
|
||||
export default function InputSearch(props: Props) {
|
||||
function InputSearch(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
@@ -39,7 +42,10 @@ export default function InputSearch(props: Props) {
|
||||
onBlur={handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
innerRef={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef(InputSearch);
|
||||
|
||||
@@ -8,7 +8,7 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input from "./Input";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
type Props = {
|
||||
source: string;
|
||||
@@ -30,7 +30,7 @@ function InputSearchPage({
|
||||
collectionId,
|
||||
source,
|
||||
}: Props) {
|
||||
const inputRef = React.useRef<Input>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
@@ -67,7 +67,7 @@ function InputSearchPage({
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={inputRef}
|
||||
innerRef={inputRef}
|
||||
type="search"
|
||||
placeholder={placeholder || `${t("Search")}…`}
|
||||
value={value}
|
||||
@@ -89,6 +89,10 @@ function InputSearchPage({
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(InputSearchPage);
|
||||
|
||||
@@ -3,19 +3,24 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import PlaceholderText, {
|
||||
Props as PlaceholderTextProps,
|
||||
} from "~/components/PlaceholderText";
|
||||
|
||||
type Props = {
|
||||
count?: number;
|
||||
className?: string;
|
||||
header?: PlaceholderTextProps;
|
||||
body?: PlaceholderTextProps;
|
||||
};
|
||||
|
||||
const ListPlaceHolder = ({ count }: Props) => {
|
||||
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} column auto>
|
||||
<PlaceholderText header delay={0.2 * index} />
|
||||
<PlaceholderText delay={0.2 * index} />
|
||||
<Item key={index} className={className} column auto>
|
||||
<PlaceholderText {...header} header delay={0.2 * index} />
|
||||
<PlaceholderText {...body} delay={0.2 * index} />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
|
||||
@@ -15,32 +15,33 @@ import { dateToHeading } from "~/utils/dates";
|
||||
|
||||
type Props = WithTranslation &
|
||||
RootStore & {
|
||||
fetch?: (options: Record<string, any> | null | undefined) => Promise<any>;
|
||||
fetch?: (
|
||||
options: Record<string, any> | null | undefined
|
||||
) => Promise<any> | undefined;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
items: any[];
|
||||
loading?: React.ReactElement;
|
||||
items?: any[];
|
||||
renderItem: (
|
||||
item: any,
|
||||
index: number,
|
||||
composite: CompositeStateReturn
|
||||
compositeProps: CompositeStateReturn
|
||||
) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedList extends React.Component<Props> {
|
||||
isInitiallyLoaded = this.props.items.length > 0;
|
||||
|
||||
@observable
|
||||
isLoaded = false;
|
||||
|
||||
@observable
|
||||
isFetchingMore = false;
|
||||
|
||||
@observable
|
||||
isFetching = false;
|
||||
|
||||
fetchCounter = 0;
|
||||
|
||||
@observable
|
||||
renderCount: number = DEFAULT_PAGINATION_LIMIT;
|
||||
|
||||
@@ -70,7 +71,6 @@ class PaginatedList extends React.Component<Props> {
|
||||
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
this.isLoaded = false;
|
||||
};
|
||||
|
||||
fetchResults = async () => {
|
||||
@@ -78,7 +78,9 @@ class PaginatedList extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
@@ -92,9 +94,12 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -105,7 +110,7 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
// If there are already cached results that we haven't yet rendered because
|
||||
// of lazy rendering then show another page.
|
||||
const leftToRender = this.props.items.length - this.renderCount;
|
||||
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||
|
||||
if (leftToRender > 1) {
|
||||
this.renderCount += DEFAULT_PAGINATION_LIMIT;
|
||||
@@ -120,20 +125,24 @@ class PaginatedList extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { items, heading, auth, empty, renderHeading } = this.props;
|
||||
const { items, heading, auth, empty, renderHeading, onEscape } = this.props;
|
||||
let previousHeading = "";
|
||||
|
||||
const showList = !!items?.length;
|
||||
const showEmpty = items?.length === 0;
|
||||
const showLoading =
|
||||
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
||||
const showEmpty = !items.length && !showLoading;
|
||||
const showList =
|
||||
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
|
||||
this.isFetching && !this.isFetchingMore && !showList && !showEmpty;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showEmpty && empty}
|
||||
{showList && (
|
||||
<>
|
||||
{heading}
|
||||
<ArrowKeyNavigation aria-label={this.props["aria-label"]}>
|
||||
<ArrowKeyNavigation
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
>
|
||||
{(composite: CompositeStateReturn) =>
|
||||
items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(
|
||||
@@ -180,11 +189,12 @@ class PaginatedList extends React.Component<Props> {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showLoading && (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
)}
|
||||
{showLoading &&
|
||||
(this.props.loading || (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { randomInteger } from "@shared/random";
|
||||
import Flex from "~/components/Flex";
|
||||
import { pulsate } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
header?: boolean;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { Dialog } from "reakit/Dialog";
|
||||
import { Popover as ReakitPopover } from "reakit/Popover";
|
||||
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
tabIndex?: number;
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
shrink?: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
const Popover: React.FC<Props> = ({ children, width = 380, ...rest }) => {
|
||||
const Popover: React.FC<Props> = ({
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
...rest
|
||||
}) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Dialog {...rest} modal>
|
||||
<Contents>{children}</Contents>
|
||||
<Contents $shrink={shrink}>{children}</Contents>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReakitPopover {...rest}>
|
||||
<Contents $width={width}>{children}</Contents>
|
||||
<Contents $shrink={shrink} $width={width}>
|
||||
{children}
|
||||
</Contents>
|
||||
</ReakitPopover>
|
||||
);
|
||||
};
|
||||
|
||||
const Contents = styled.div<{ $width?: number }>`
|
||||
const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 12px 24px;
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
|
||||
148
app/components/SearchListItem.tsx
Normal file
148
app/components/SearchListItem.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "~/models/Document";
|
||||
import Highlight, { Mark } from "~/components/Highlight";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
highlight: string;
|
||||
context: string | undefined;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
shareId?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
};
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { document, highlight, context, shareId, ...rest } = props;
|
||||
|
||||
return (
|
||||
<CompositeItem
|
||||
as={DocumentLink}
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
to={{
|
||||
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
</Heading>
|
||||
|
||||
{
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
}
|
||||
</Content>
|
||||
</CompositeItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
max-height: 50vh;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h4<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${Mark} {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
${Mark} {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
197
app/components/SearchPopover.tsx
Normal file
197
app/components/SearchPopover.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Empty from "~/components/Empty";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Popover from "~/components/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
type Props = { shareId: string };
|
||||
|
||||
function SearchPopover({ shareId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: "bottom-start",
|
||||
unstable_offset: [-24, 0],
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const searchResults = documents.searchResults(query);
|
||||
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
Record<string, any>[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
popover.show();
|
||||
}
|
||||
}, [searchResults, query, popover.show]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }: Record<string, any>) => {
|
||||
if (query?.length > 0) {
|
||||
return await documents.search(query, { shareId, ...options });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const handleSearch = React.useMemo(
|
||||
() =>
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value.trim());
|
||||
|
||||
// covers edge case: user manually dismisses popover then
|
||||
// quickly edits input resulting in no change in query
|
||||
// the useEffect that normally shows the popover will miss it
|
||||
if (value === cachedQuery) {
|
||||
popover.show();
|
||||
}
|
||||
|
||||
if (!value.length) {
|
||||
popover.hide();
|
||||
}
|
||||
}, 300),
|
||||
[popover, cachedQuery]
|
||||
);
|
||||
|
||||
const searchInputRef = popover.unstable_referenceRef;
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef?.current?.focus(),
|
||||
[searchInputRef]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
if (searchResults) {
|
||||
popover.show();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
if (ev.currentTarget.value.length) {
|
||||
if (
|
||||
ev.currentTarget.value.length === ev.currentTarget.selectionStart
|
||||
) {
|
||||
popover.show();
|
||||
}
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
if (!ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.currentTarget.value) {
|
||||
if (ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[popover, searchResults]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => {
|
||||
// props assumes the disclosure is a button, but we want a type-ahead
|
||||
// so we take the aria props, and ref and ignore the event handlers
|
||||
return (
|
||||
<StyledInputSearch
|
||||
aria-controls={props["aria-controls"]}
|
||||
aria-expanded={props["aria-expanded"]}
|
||||
aria-haspopup={props["aria-haspopup"]}
|
||||
ref={props.ref}
|
||||
onChange={handleSearch}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PopoverDisclosure>
|
||||
|
||||
<Popover
|
||||
{...popover}
|
||||
aria-label={t("Results")}
|
||||
unstable_autoFocusOnShow={false}
|
||||
style={{ zIndex: depths.sidebar + 1 }}
|
||||
shrink
|
||||
>
|
||||
<PaginatedList
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||
}
|
||||
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||
renderItem={(item, index, compositeProps) => (
|
||||
<SearchListItem
|
||||
key={item.document.id}
|
||||
shareId={shareId}
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={cachedQuery}
|
||||
onClick={popover.hide}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const NoResults = styled(Empty)`
|
||||
padding: 0 12px;
|
||||
margin: 6px 0;
|
||||
`;
|
||||
|
||||
const PlaceholderList = styled(Placeholder)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
const StyledInputSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(SearchPopover);
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -19,6 +20,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
return (
|
||||
<Sidebar>
|
||||
<ScrollContainer flex>
|
||||
<TopSection>
|
||||
<SearchPopover shareId={shareId} />
|
||||
</TopSection>
|
||||
<Section>
|
||||
<DocumentLink
|
||||
index={0}
|
||||
@@ -38,4 +42,12 @@ const ScrollContainer = styled(Scrollable)`
|
||||
padding-bottom: 16px;
|
||||
`;
|
||||
|
||||
const TopSection = styled(Section)`
|
||||
// this weird looking && increases the specificity of the style rule
|
||||
&& {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
Reference in New Issue
Block a user