Compare commits

...

8 Commits

Author SHA1 Message Date
Alexey Pyltsyn d2fa385eed More proper spacing 2022-04-04 19:54:16 +03:00
Alexey Pyltsyn a1ba61fb6a Fix typing 2022-04-04 13:25:55 +03:00
Alexey Pyltsyn bf9f72412f Highlight term inside category 2022-04-04 13:20:52 +03:00
Alexey Pyltsyn 134edee147 Enable filter on mobiles 2022-04-04 13:17:48 +03:00
Alexey Pyltsyn 2bc3b28cec Merge branch 'main' of github.com:facebook/docusaurus into lex111/filter-sidebar 2022-03-28 21:53:22 +03:00
Alexey Pyltsyn 5403817d55 Fix bugs (wrong way) 2022-03-20 15:10:10 +03:00
Alexey Pyltsyn aa997da632 Merge branch 'main' of github.com:facebook/docusaurus into lex111/filter-sidebar 2022-03-20 13:24:48 +03:00
Alexey Pyltsyn 251223f75e feat: add ability to filter doc sidebar items 2022-03-20 13:05:28 +03:00
18 changed files with 337 additions and 24 deletions

View File

@ -181,7 +181,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>
@ -213,7 +213,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;
@ -227,6 +227,14 @@ declare module '@theme/DocSidebar/Desktop/CollapseButton' {
export default function CollapseButton(props: Props): JSX.Element;
}
declare module '@theme/DocSidebar/Common/Filter' {
export interface Props {
readonly className?: string;
}
export default function Filter(props: Props): JSX.Element;
}
declare module '@theme/DocSidebarItem' {
import type {PropSidebarItem} from '@docusaurus/plugin-content-docs';
@ -1029,6 +1037,15 @@ declare module '@theme/Tag' {
export default function Tag(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;
}
declare module '@theme/prism-include-languages' {
import type * as PrismNamespace from 'prismjs';

View File

@ -16,6 +16,7 @@ import {matchPath} from '@docusaurus/router';
import clsx from 'clsx';
import {
DocsFilterProvider,
HtmlClassNameProvider,
ThemeClassNames,
docVersionSearchTag,
@ -84,7 +85,9 @@ export default function DocPage(props: Props): JSX.Element {
)}>
<DocsVersionProvider version={versionMetadata}>
<DocsSidebarProvider name={sidebarName} items={sidebarItems}>
<DocPageLayout>{docElement}</DocPageLayout>
<DocsFilterProvider>
<DocPageLayout>{docElement}</DocPageLayout>
</DocsFilterProvider>
</DocsSidebarProvider>
</DocsVersionProvider>
</HtmlClassNameProvider>

View File

@ -0,0 +1,47 @@
/**
* 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 type {Props} from '@theme/DocSidebar/Common/Filter';
import styles from './styles.module.css';
function Filter({className}: Props): JSX.Element {
const {setFilterTerm, filterTerm = ''} = useDocsFilter();
return (
<div className={clsx(styles.filter, className)}>
<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,51 @@
/**
* 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;
}
.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: 0.5rem;
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,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

@ -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/Common/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 className={styles.filter} />}
<Content path={path} sidebar={sidebar} />
{hideableSidebar && <CollapseButton onClick={onCollapse} />}
</div>

View File

@ -43,6 +43,10 @@
margin-right: 0.5rem;
height: 2rem;
}
.filter {
margin: 0.5rem;
}
}
.sidebarLogo {

View File

@ -8,37 +8,49 @@
import React from 'react';
import clsx from 'clsx';
import {
useThemeConfig,
NavbarSecondaryMenuFiller,
type NavbarSecondaryMenuComponent,
ThemeClassNames,
useNavbarMobileSidebar,
useDocsFilter,
filterDocsSidebar,
} from '@docusaurus/theme-common';
import DocSidebarItems from '@theme/DocSidebarItems';
import Filter from '@theme/DocSidebar/Common/Filter';
import type {Props} from '@theme/DocSidebar/Mobile';
import styles from './styles.module.css';
// eslint-disable-next-line react/function-component-definition
const DocSidebarMobileSecondaryMenu: NavbarSecondaryMenuComponent<Props> = ({
sidebar,
path,
}) => {
const {filterableSidebar} = useThemeConfig();
const {filterTerm} = useDocsFilter();
const filteredSidebar = filterDocsSidebar(sidebar, filterTerm);
const mobileSidebar = useNavbarMobileSidebar();
return (
<ul className={clsx(ThemeClassNames.docs.docSidebarMenu, 'menu__list')}>
<DocSidebarItems
items={sidebar}
activePath={path}
onItemClick={(item) => {
// Mobile sidebar should only be closed if the category has a link
if (item.type === 'category' && item.href) {
mobileSidebar.toggle();
}
if (item.type === 'link') {
mobileSidebar.toggle();
}
}}
level={1}
/>
</ul>
<>
{filterableSidebar && <Filter className={styles.filter} />}
<ul className={clsx(ThemeClassNames.docs.docSidebarMenu, 'menu__list')}>
<DocSidebarItems
items={filteredSidebar}
activePath={path}
onItemClick={(item) => {
// Mobile sidebar should only be closed if the category has a link
if (item.type === 'category' && item.href) {
mobileSidebar.toggle();
}
if (item.type === 'link') {
mobileSidebar.toggle();
}
}}
level={1}
/>
</ul>
</>
);
};

View File

@ -0,0 +1,10 @@
/**
* 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 {
margin-bottom: 0.7rem;
}

View File

@ -17,11 +17,13 @@ import {
useThemeConfig,
useDocSidebarItemsExpandedState,
isSamePath,
useDocsFilter,
} from '@docusaurus/theme-common';
import Link from '@docusaurus/Link';
import {translate} from '@docusaurus/Translate';
import DocSidebarItems from '@theme/DocSidebarItems';
import TextHighlight from '@theme/TextHighlight';
import type {Props} from '@theme/DocSidebarItem/Category';
import useIsBrowser from '@docusaurus/useIsBrowser';
@ -106,6 +108,7 @@ export default function DocSidebarItemCategory({
}: Props): JSX.Element {
const {items, label, collapsible, className, href} = item;
const hrefWithSSRFallback = useCategoryHrefWithSSRFallback(item);
const {filterTerm} = useDocsFilter();
const isActive = isActiveSidebarItem(item, activePath);
const isCurrentPage = isSamePath(href, activePath);
@ -121,6 +124,17 @@ export default function DocSidebarItemCategory({
},
});
// This hook needed to collapse categories that were in collapsed state
// before filtering, relevant when only changing of filter term by word.
// TODO: but should this hook exist at all? When re-rendering
// (= clearing filter input), the `collapsed` state is not calculated
// correctly (if input of `item.collapsed` is `true`, it is `false` here).
useEffect(() => {
if (filterTerm === '' && !isActive && item.collapsible) {
setCollapsed(item.collapsed);
}
}, [filterTerm, isActive, item.collapsible, setCollapsed, item.collapsed]);
useAutoExpandActiveCategory({isActive, collapsed, setCollapsed});
const {expandedItem, setExpandedItem} = useDocSidebarItemsExpandedState();
function updateCollapsed(toCollapsed: boolean = !collapsed) {
@ -133,7 +147,8 @@ export default function DocSidebarItemCategory({
collapsible &&
expandedItem &&
expandedItem !== index &&
autoCollapseSidebarCategories
autoCollapseSidebarCategories &&
!filterTerm
) {
setCollapsed(true);
}
@ -143,6 +158,7 @@ export default function DocSidebarItemCategory({
index,
setCollapsed,
autoCollapseSidebarCategories,
filterTerm,
]);
return (
@ -185,7 +201,11 @@ export default function DocSidebarItemCategory({
aria-expanded={collapsible ? !collapsed : undefined}
href={collapsible ? hrefWithSSRFallback ?? '#' : hrefWithSSRFallback}
{...props}>
{label}
{filterTerm ? (
<TextHighlight text={label} highlight={filterTerm} />
) : (
label
)}
</Link>
{href && collapsible && (
<CollapseButton

View File

@ -7,10 +7,15 @@
import React from 'react';
import clsx from 'clsx';
import {isActiveSidebarItem, ThemeClassNames} from '@docusaurus/theme-common';
import {
isActiveSidebarItem,
ThemeClassNames,
useDocsFilter,
} from '@docusaurus/theme-common';
import Link from '@docusaurus/Link';
import isInternalUrl from '@docusaurus/isInternalUrl';
import IconExternalLink from '@theme/IconExternalLink';
import TextHighlight from '@theme/TextHighlight';
import type {Props} from '@theme/DocSidebarItem/Link';
@ -27,6 +32,7 @@ export default function DocSidebarItemLink({
const {href, label, className} = item;
const isActive = isActiveSidebarItem(item, activePath);
const isInternalLink = isInternalUrl(href);
const {filterTerm} = useDocsFilter();
return (
<li
className={clsx(
@ -50,7 +56,11 @@ export default function DocSidebarItemLink({
onClick: onItemClick ? () => onItemClick(item) : undefined,
})}
{...props}>
{label}
{filterTerm ? (
<TextHighlight text={label} highlight={filterTerm} />
) : (
label
)}
{!isInternalLink && <IconExternalLink />}
</Link>
</li>

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>`,
) as string;
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

@ -38,6 +38,7 @@ export const DEFAULT_CONFIG = {
items: [],
},
hideableSidebar: false,
filterableSidebar: false,
autoCollapseSidebarCategories: false,
tableOfContents: {
minHeadingLevel: 2,
@ -346,6 +347,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

@ -135,6 +135,12 @@ export {
export {splitNavbarItems, NavbarProvider} from './utils/navbarUtils';
export {
DocsFilterProvider,
useDocsFilter,
filterDocsSidebar,
} from './utils/docsFilterUtils';
export {
useTabGroupChoice,
TabGroupChoiceProvider,
} from './contexts/tabGroupChoice';

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<{[key: string]: string}>;

View File

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