feat: add ability to filter doc sidebar items

This commit is contained in:
Alexey Pyltsyn 2022-03-20 13:05:28 +03:00
parent a1d333e96b
commit 251223f75e
14 changed files with 276 additions and 20 deletions

View file

@ -166,7 +166,7 @@ declare module '@theme/DocSidebar' {
export interface Props {
readonly path: string;
readonly sidebar: readonly PropSidebarItem[];
readonly sidebar: PropSidebarItem[];
readonly onCollapse: () => void;
readonly isHidden: boolean;
// MobileSecondaryFilter expects Record<string, unknown>
@ -198,7 +198,7 @@ declare module '@theme/DocSidebar/Desktop/Content' {
export interface Props {
readonly className?: string;
readonly path: string;
readonly sidebar: readonly PropSidebarItem[];
readonly sidebar: PropSidebarItem[];
}
export default function Content(props: Props): JSX.Element;
@ -212,6 +212,10 @@ declare module '@theme/DocSidebar/Desktop/CollapseButton' {
export default function CollapseButton(props: Props): JSX.Element;
}
declare module '@theme/DocSidebar/Desktop/Filter' {
export default function Filter(): JSX.Element;
}
declare module '@theme/DocSidebarItem' {
import type {PropSidebarItem} from '@docusaurus/plugin-content-docs';
@ -941,3 +945,12 @@ declare module '@theme/Seo' {
export default function Seo(props: Props): JSX.Element;
}
declare module '@theme/TextHighlight' {
export interface Props {
readonly text?: string;
readonly highlight?: string;
}
export default function TextHighlight(props: Props): JSX.Element;
}

View file

@ -8,6 +8,15 @@
import React, {type ReactNode, useState, useCallback} from 'react';
import renderRoutes from '@docusaurus/renderRoutes';
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
import {
DocsFilterProvider,
HtmlClassNameProvider,
ThemeClassNames,
docVersionSearchTag,
DocsSidebarProvider,
useDocsSidebar,
DocsVersionProvider,
} from '@docusaurus/theme-common';
import Layout from '@theme/Layout';
import DocSidebar from '@theme/DocSidebar';
import NotFound from '@theme/NotFound';
@ -21,15 +30,6 @@ import {translate} from '@docusaurus/Translate';
import clsx from 'clsx';
import styles from './styles.module.css';
import {
HtmlClassNameProvider,
ThemeClassNames,
docVersionSearchTag,
DocsSidebarProvider,
useDocsSidebar,
DocsVersionProvider,
} from '@docusaurus/theme-common';
type DocPageContentProps = {
readonly currentDocRoute: DocumentRoute;
readonly versionMetadata: PropVersionMetadata;
@ -164,12 +164,14 @@ export default function DocPage(props: Props): JSX.Element {
<HtmlClassNameProvider className={versionMetadata.className}>
<DocsVersionProvider version={versionMetadata}>
<DocsSidebarProvider sidebar={sidebar ?? null}>
<DocPageContent
currentDocRoute={currentDocRoute}
versionMetadata={versionMetadata}
sidebarName={sidebarName}>
{renderRoutes(docRoutes, {versionMetadata})}
</DocPageContent>
<DocsFilterProvider>
<DocPageContent
currentDocRoute={currentDocRoute}
versionMetadata={versionMetadata}
sidebarName={sidebarName}>
{renderRoutes(docRoutes, {versionMetadata})}
</DocPageContent>
</DocsFilterProvider>
</DocsSidebarProvider>
</DocsVersionProvider>
</HtmlClassNameProvider>

View file

@ -11,6 +11,8 @@ import {
ThemeClassNames,
useAnnouncementBar,
useScrollPosition,
useDocsFilter,
filterDocsSidebar,
} from '@docusaurus/theme-common';
import DocSidebarItems from '@theme/DocSidebarItems';
import type {Props} from '@theme/DocSidebar/Desktop/Content';
@ -38,6 +40,8 @@ export default function DocSidebarDesktopContent({
className,
}: Props): JSX.Element {
const showAnnouncementBar = useShowAnnouncementBar();
const {filterTerm} = useDocsFilter();
const filteredSidebar = filterDocsSidebar(sidebar, filterTerm);
return (
<nav
@ -48,7 +52,7 @@ export default function DocSidebarDesktopContent({
className,
)}>
<ul className={clsx(ThemeClassNames.docs.docSidebarMenu, 'menu__list')}>
<DocSidebarItems items={sidebar} activePath={path} level={1} />
<DocSidebarItems items={filteredSidebar} activePath={path} level={1} />
</ul>
</nav>
);

View file

@ -0,0 +1,46 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import clsx from 'clsx';
import {useDocsFilter} from '@docusaurus/theme-common';
import styles from './styles.module.css';
function Filter(): JSX.Element {
const {setFilterTerm, filterTerm = ''} = useDocsFilter();
return (
<div className={styles.filter}>
<input
placeholder="Filter by title" // todo: i18n
type="text"
className={styles.filterInput}
onChange={(e) => setFilterTerm(e.target.value)}
value={filterTerm}
/>
{filterTerm && (
<button
type="button"
className={clsx('clean-btn', styles.clearFilterInputBtn)}
onClick={() => setFilterTerm('')}>
<svg
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
);
}
export default Filter;

View file

@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.filter {
--docusaurus-clear-filter-icon: 1rem;
position: relative;
padding: 0.5rem;
}
.filterInput {
appearance: none;
width: 100%;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
background: var(--docsearch-searchbox-focus-background);
color: var(--ifm-color-emphasis-800);
font-size: 0.875rem;
padding: 0.5rem calc(0.5rem + var(--docusaurus-clear-filter-icon)) 0.5rem
0.5rem;
transition: border var(--ifm-transition-fast) ease;
}
.filterInput:focus {
border-color: var(--docsearch-primary-color);
outline: none;
}
.clearFilterInputBtn {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
border-radius: 50%;
padding: 0.2rem;
color: var(--ifm-color-emphasis-800);
transition: background var(--ifm-transition-fast);
}
.clearFilterInputBtn:hover {
background: var(--ifm-color-emphasis-200);
}
.clearFilterInputBtn svg {
width: var(--docusaurus-clear-filter-icon);
height: var(--docusaurus-clear-filter-icon);
display: block;
}

View file

@ -11,6 +11,7 @@ import {useThemeConfig} from '@docusaurus/theme-common';
import Logo from '@theme/Logo';
import CollapseButton from '@theme/DocSidebar/Desktop/CollapseButton';
import Content from '@theme/DocSidebar/Desktop/Content';
import Filter from '@theme/DocSidebar/Desktop/Filter';
import type {Props} from '@theme/DocSidebar/Desktop';
import styles from './styles.module.css';
@ -19,6 +20,7 @@ function DocSidebarDesktop({path, sidebar, onCollapse, isHidden}: Props) {
const {
navbar: {hideOnScroll},
hideableSidebar,
filterableSidebar,
} = useThemeConfig();
return (
@ -29,6 +31,7 @@ function DocSidebarDesktop({path, sidebar, onCollapse, isHidden}: Props) {
isHidden && styles.sidebarHidden,
)}>
{hideOnScroll && <Logo tabIndex={-1} className={styles.sidebarLogo} />}
{filterableSidebar && <Filter />}
<Content path={path} sidebar={sidebar} />
{hideableSidebar && <CollapseButton onClick={onCollapse} />}
</div>

View file

@ -17,11 +17,13 @@ import {
useThemeConfig,
useDocSidebarItemsExpandedState,
isSamePath,
useDocsFilter,
} from '@docusaurus/theme-common';
import Link from '@docusaurus/Link';
import isInternalUrl from '@docusaurus/isInternalUrl';
import {translate} from '@docusaurus/Translate';
import IconExternalLink from '@theme/IconExternalLink';
import TextHighlight from '@theme/TextHighlight';
import DocSidebarItems from '@theme/DocSidebarItems';
import type {Props} from '@theme/DocSidebarItem';
@ -104,6 +106,7 @@ function DocSidebarItemCategory({
}: Props & {item: PropSidebarItemCategory}) {
const {items, label, collapsible, className, href} = item;
const hrefWithSSRFallback = useCategoryHrefWithSSRFallback(item);
const {filterTerm} = useDocsFilter();
const isActive = isActiveSidebarItem(item, activePath);
const isCurrentPage = isSamePath(href, activePath);
@ -182,7 +185,7 @@ function DocSidebarItemCategory({
aria-expanded={collapsible ? !collapsed : undefined}
href={collapsible ? hrefWithSSRFallback ?? '#' : hrefWithSSRFallback}
{...props}>
{label}
<TextHighlight text={label} highlight={filterTerm} />
</Link>
{href && collapsible && (
<button
@ -249,6 +252,7 @@ function DocSidebarItemLink({
}: Props & {item: PropSidebarItemLink}) {
const {href, label, className} = item;
const isActive = isActiveSidebarItem(item, activePath);
const {filterTerm} = useDocsFilter();
return (
<li
className={clsx(
@ -269,7 +273,7 @@ function DocSidebarItemLink({
})}
{...props}>
<span>
{label}
<TextHighlight text={label} highlight={filterTerm} />
{!isInternalUrl(href) && <IconExternalLink />}
</span>
</Link>

View file

@ -0,0 +1,31 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import type {Props} from '@theme/TextHighlight';
import styles from './styles.module.css';
function TextHighlight({text, highlight}: Props): JSX.Element {
if (!highlight) {
return <>{text}</>;
}
const highlightedText = text.replace(
new RegExp(highlight, 'gi'),
(match) => `<mark>${match}</mark>`,
);
return (
<span
className={styles.highlightText}
dangerouslySetInnerHTML={{__html: highlightedText}}
/>
);
}
export default TextHighlight;

View file

@ -0,0 +1,11 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.highlightText mark {
background: none;
color: var(--ifm-color-primary);
}

View file

@ -35,6 +35,7 @@ export const DEFAULT_CONFIG = {
items: [],
},
hideableSidebar: false,
filterableSidebar: false,
autoCollapseSidebarCategories: false,
tableOfContents: {
minHeadingLevel: 2,
@ -343,6 +344,7 @@ export const ThemeConfigSchema = Joi.object({
.default(DEFAULT_CONFIG.prism)
.unknown(),
hideableSidebar: Joi.bool().default(DEFAULT_CONFIG.hideableSidebar),
filterableSidebar: Joi.bool().default(DEFAULT_CONFIG.filterableSidebar),
autoCollapseSidebarCategories: Joi.bool().default(
DEFAULT_CONFIG.autoCollapseSidebarCategories,
),

View file

@ -155,6 +155,12 @@ export {
} from './utils/navbarSecondaryMenuUtils';
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenuUtils';
export {
DocsFilterProvider,
useDocsFilter,
filterDocsSidebar,
} from './utils/docsFilterUtils';
export {default as useHideableNavbar} from './hooks/useHideableNavbar';
export {
default as useKeyboardNavigation,

View file

@ -0,0 +1,80 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode, useMemo, useState, useContext} from 'react';
import {ReactContextError} from './reactUtils';
import type {PropSidebarItem} from '@docusaurus/plugin-content-docs';
type DocsFilterContextValue = {
filterTerm: string | undefined;
setFilterTerm: (value: string) => void;
};
const DocsFilterContext = React.createContext<
DocsFilterContextValue | undefined
>(undefined);
function useDocsFilterContextValue(): DocsFilterContextValue {
const [filterTerm, setFilterTerm] = useState<string | undefined>(undefined);
return useMemo(() => ({filterTerm, setFilterTerm}), [filterTerm]);
}
export function DocsFilterProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const contextValue = useDocsFilterContextValue();
return (
<DocsFilterContext.Provider value={contextValue}>
{children}
</DocsFilterContext.Provider>
);
}
export function useDocsFilter(): DocsFilterContextValue {
const context = useContext<DocsFilterContextValue | undefined>(
DocsFilterContext,
);
if (context == null) {
throw new ReactContextError('DocsFilterProvider');
}
return context;
}
export function filterDocsSidebar(
sidebar: PropSidebarItem[],
filterTerm: string | undefined,
): PropSidebarItem[] {
if (!filterTerm) {
return sidebar;
}
return sidebar.reduce((acc, item) => {
if (!('label' in item)) {
return acc;
}
const isLabelMatch = new RegExp(filterTerm, 'i').test(item.label);
if (item.type !== 'category') {
return isLabelMatch ? acc.concat(item) : acc;
}
const filteredItems = filterDocsSidebar(item.items, filterTerm);
const isCategoryMatch = isLabelMatch || filteredItems.length > 0;
const filteredItem = {
...item,
items: filteredItems, // or it's to worth showing items even they do not meet the filter criteria?
collapsed: !isCategoryMatch, // todo: fix bug with auto collapse category feature
collapsible: filteredItems.length > 0, // or disable it at all?
};
return isCategoryMatch ? acc.concat(filteredItem) : acc;
}, [] as PropSidebarItem[]);
}

View file

@ -117,6 +117,7 @@ export type ThemeConfig = {
prism: PrismConfig;
footer?: Footer;
hideableSidebar: boolean;
filterableSidebar: boolean;
autoCollapseSidebarCategories: boolean;
image?: string;
metadata: Array<Record<string, string>>;

View file

@ -339,6 +339,7 @@ const config = {
},
hideableSidebar: true,
autoCollapseSidebarCategories: true,
filterableSidebar: true,
colorMode: {
defaultMode: 'light',
disableSwitch: false,