mirror of
https://github.com/facebook/docusaurus.git
synced 2025-08-02 08:19:07 +02:00
feat: add ability to filter doc sidebar items
This commit is contained in:
parent
a1d333e96b
commit
251223f75e
14 changed files with 276 additions and 20 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[]);
|
||||
}
|
|
@ -117,6 +117,7 @@ export type ThemeConfig = {
|
|||
prism: PrismConfig;
|
||||
footer?: Footer;
|
||||
hideableSidebar: boolean;
|
||||
filterableSidebar: boolean;
|
||||
autoCollapseSidebarCategories: boolean;
|
||||
image?: string;
|
||||
metadata: Array<Record<string, string>>;
|
||||
|
|
|
@ -339,6 +339,7 @@ const config = {
|
|||
},
|
||||
hideableSidebar: true,
|
||||
autoCollapseSidebarCategories: true,
|
||||
filterableSidebar: true,
|
||||
colorMode: {
|
||||
defaultMode: 'light',
|
||||
disableSwitch: false,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue