diff --git a/frontend/i18n/fa/code.json b/frontend/i18n/fa/code.json
index 1b2eb6f7969..00fe06c5787 100644
--- a/frontend/i18n/fa/code.json
+++ b/frontend/i18n/fa/code.json
@@ -354,5 +354,21 @@
},
"navbar.item.Donate": {
"message": "حمایت مالی"
+ },
+ "theme.docs.sidebar.filterPlaceholder": {
+ "message": "فیلتر مستندات",
+ "description": "Placeholder for the docs sidebar filter input"
+ },
+ "theme.docs.sidebar.clearFilter": {
+ "message": "پاک کردن فیلتر",
+ "description": "ARIA label for clearing the docs sidebar filter"
+ },
+ "theme.docs.sidebar.filterNoResults": {
+ "message": "مستند مطابقی یافت نشد",
+ "description": "Title shown when the docs sidebar filter has no matches"
+ },
+ "theme.docs.sidebar.filterNoResultsHint": {
+ "message": "عبارت جستجوی دیگری را امتحان کنید.",
+ "description": "Hint shown when the docs sidebar filter has no matches"
}
}
diff --git a/frontend/i18n/id/code.json b/frontend/i18n/id/code.json
index 9acda2ec280..2cd5e6db8c8 100644
--- a/frontend/i18n/id/code.json
+++ b/frontend/i18n/id/code.json
@@ -405,5 +405,21 @@
},
"navbar.item.Donate": {
"message": "Donasi"
+ },
+ "theme.docs.sidebar.filterPlaceholder": {
+ "message": "Filter dokumentasi",
+ "description": "Placeholder for the docs sidebar filter input"
+ },
+ "theme.docs.sidebar.clearFilter": {
+ "message": "Hapus filter",
+ "description": "ARIA label for clearing the docs sidebar filter"
+ },
+ "theme.docs.sidebar.filterNoResults": {
+ "message": "Tidak ada dokumen yang cocok",
+ "description": "Title shown when the docs sidebar filter has no matches"
+ },
+ "theme.docs.sidebar.filterNoResultsHint": {
+ "message": "Coba kata kunci lain.",
+ "description": "Hint shown when the docs sidebar filter has no matches"
}
}
diff --git a/frontend/i18n/pt-BR/code.json b/frontend/i18n/pt-BR/code.json
index 17aab3ff007..d1cdbba6d62 100644
--- a/frontend/i18n/pt-BR/code.json
+++ b/frontend/i18n/pt-BR/code.json
@@ -696,5 +696,21 @@
"notFound.servers": {
"message": "Servidores",
"description": "404 page servers button"
+ },
+ "theme.docs.sidebar.filterPlaceholder": {
+ "message": "Filtrar documentação",
+ "description": "Placeholder for the docs sidebar filter input"
+ },
+ "theme.docs.sidebar.clearFilter": {
+ "message": "Limpar filtro",
+ "description": "ARIA label for clearing the docs sidebar filter"
+ },
+ "theme.docs.sidebar.filterNoResults": {
+ "message": "Nenhum documento correspondente",
+ "description": "Title shown when the docs sidebar filter has no matches"
+ },
+ "theme.docs.sidebar.filterNoResultsHint": {
+ "message": "Tente outro termo de pesquisa.",
+ "description": "Hint shown when the docs sidebar filter has no matches"
}
}
diff --git a/frontend/i18n/th/code.json b/frontend/i18n/th/code.json
index 71e31469735..bb8cc7e93ea 100644
--- a/frontend/i18n/th/code.json
+++ b/frontend/i18n/th/code.json
@@ -405,5 +405,21 @@
},
"navbar.item.Donate": {
"message": "บริจาค"
+ },
+ "theme.docs.sidebar.filterPlaceholder": {
+ "message": "กรองเอกสาร",
+ "description": "Placeholder for the docs sidebar filter input"
+ },
+ "theme.docs.sidebar.clearFilter": {
+ "message": "ล้างตัวกรอง",
+ "description": "ARIA label for clearing the docs sidebar filter"
+ },
+ "theme.docs.sidebar.filterNoResults": {
+ "message": "ไม่พบเอกสารที่ตรงกัน",
+ "description": "Title shown when the docs sidebar filter has no matches"
+ },
+ "theme.docs.sidebar.filterNoResultsHint": {
+ "message": "ลองใช้คำค้นหาอื่น",
+ "description": "Hint shown when the docs sidebar filter has no matches"
}
}
diff --git a/frontend/i18n/tr/code.json b/frontend/i18n/tr/code.json
index b1c7f481eaf..b556f2f433d 100644
--- a/frontend/i18n/tr/code.json
+++ b/frontend/i18n/tr/code.json
@@ -354,5 +354,21 @@
},
"navbar.item.Donate": {
"message": "Bağış Yap"
+ },
+ "theme.docs.sidebar.filterPlaceholder": {
+ "message": "Belgeleri filtrele",
+ "description": "Placeholder for the docs sidebar filter input"
+ },
+ "theme.docs.sidebar.clearFilter": {
+ "message": "Filtreyi temizle",
+ "description": "ARIA label for clearing the docs sidebar filter"
+ },
+ "theme.docs.sidebar.filterNoResults": {
+ "message": "Eşleşen belge yok",
+ "description": "Title shown when the docs sidebar filter has no matches"
+ },
+ "theme.docs.sidebar.filterNoResultsHint": {
+ "message": "Farklı bir arama terimi deneyin.",
+ "description": "Hint shown when the docs sidebar filter has no matches"
}
}
diff --git a/frontend/i18n/uk/code.json b/frontend/i18n/uk/code.json
index 1d7f1da5a85..650720d1f0d 100644
--- a/frontend/i18n/uk/code.json
+++ b/frontend/i18n/uk/code.json
@@ -354,5 +354,21 @@
},
"navbar.item.Donate": {
"message": "Донат"
+ },
+ "theme.docs.sidebar.filterPlaceholder": {
+ "message": "Фільтрувати документацію",
+ "description": "Placeholder for the docs sidebar filter input"
+ },
+ "theme.docs.sidebar.clearFilter": {
+ "message": "Очистити фільтр",
+ "description": "ARIA label for clearing the docs sidebar filter"
+ },
+ "theme.docs.sidebar.filterNoResults": {
+ "message": "Немає відповідних документів",
+ "description": "Title shown when the docs sidebar filter has no matches"
+ },
+ "theme.docs.sidebar.filterNoResultsHint": {
+ "message": "Спробуйте інший пошуковий запит.",
+ "description": "Hint shown when the docs sidebar filter has no matches"
}
}
diff --git a/frontend/i18n/zh-CN/code.json b/frontend/i18n/zh-CN/code.json
index 31f1d6a57f4..d4c4b8c4132 100644
--- a/frontend/i18n/zh-CN/code.json
+++ b/frontend/i18n/zh-CN/code.json
@@ -124,6 +124,22 @@
"message": "返回顶部",
"description": "返回顶部按钮的ARIA标签"
},
+ "theme.docs.sidebar.filterPlaceholder": {
+ "message": "筛选文档",
+ "description": "文档侧边栏筛选输入框的占位文本"
+ },
+ "theme.docs.sidebar.clearFilter": {
+ "message": "清除筛选",
+ "description": "清除文档侧边栏筛选的ARIA标签"
+ },
+ "theme.docs.sidebar.filterNoResults": {
+ "message": "没有匹配的文档",
+ "description": "文档侧边栏筛选无结果时显示的标题"
+ },
+ "theme.docs.sidebar.filterNoResultsHint": {
+ "message": "换个关键词试试。",
+ "description": "文档侧边栏筛选无结果时显示的提示"
+ },
"theme.blog.archive.title": {
"message": "归档",
"description": "博客归档页面的页面标题和主标题"
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 97d845b2021..5abe999ddc1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,6 +17,9 @@
"nprogress": "^0.2.0",
"prism-react-renderer": "^2.3.0",
"react": "^18.3.1",
+ "react-arborist": "^4.0.1",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-router-dom": "^7.1.5",
@@ -5123,6 +5126,24 @@
"unist-util-is": "^3.0.0"
}
},
+ "node_modules/@react-dnd/asap": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
+ "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
+ "license": "MIT"
+ },
+ "node_modules/@react-dnd/invariant": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
+ "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
+ "license": "MIT"
+ },
+ "node_modules/@react-dnd/shallowequal": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
+ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
+ "license": "MIT"
+ },
"node_modules/@rspack/binding": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz",
@@ -6359,6 +6380,15 @@
"@types/react-router": "*"
}
},
+ "node_modules/@types/react-window": {
+ "version": "1.8.8",
+ "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
+ "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -8763,6 +8793,26 @@
"node": ">=8"
}
},
+ "node_modules/dnd-core": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
+ "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-dnd/asap": "^5.0.1",
+ "@react-dnd/invariant": "^4.0.1",
+ "redux": "^4.2.0"
+ }
+ },
+ "node_modules/dnd-core/node_modules/redux": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
+ "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.9.2"
+ }
+ },
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
@@ -12132,6 +12182,12 @@
"tslib": "2"
}
},
+ "node_modules/memoize-one": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
+ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
+ "license": "MIT"
+ },
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -16492,6 +16548,82 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-arborist": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-4.0.1.tgz",
+ "integrity": "sha512-rhpgQjVL6SRCOpg9cmaxn08y2Qtc1zrd+eVO4R9IvdRLF9Pz619HLr10NKV5AmC9QkGvM2dRTHglevdugQEHSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-window": "^1.8.8",
+ "react-window": "^1.8.11",
+ "redux": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=22.12"
+ },
+ "peerDependencies": {
+ "react": ">= 18",
+ "react-dnd": "^16",
+ "react-dnd-html5-backend": "^16",
+ "react-dom": ">= 18"
+ }
+ },
+ "node_modules/react-arborist/node_modules/react-window": {
+ "version": "1.8.11",
+ "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
+ "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.0.0",
+ "memoize-one": ">=3.1.1 <6"
+ },
+ "engines": {
+ "node": ">8.0.0"
+ },
+ "peerDependencies": {
+ "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-dnd": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
+ "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-dnd/invariant": "^4.0.1",
+ "@react-dnd/shallowequal": "^4.0.1",
+ "dnd-core": "^16.0.1",
+ "fast-deep-equal": "^3.1.3",
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "peerDependencies": {
+ "@types/hoist-non-react-statics": ">= 3.3.1",
+ "@types/node": ">= 12",
+ "@types/react": ">= 16",
+ "react": ">= 16.14"
+ },
+ "peerDependenciesMeta": {
+ "@types/hoist-non-react-statics": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-dnd-html5-backend": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
+ "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
+ "license": "MIT",
+ "dependencies": {
+ "dnd-core": "^16.0.1"
+ }
+ },
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -16772,6 +16904,12 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 8e74bc91cb5..c452cbd2304 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,6 +24,9 @@
"nprogress": "^0.2.0",
"prism-react-renderer": "^2.3.0",
"react": "^18.3.1",
+ "react-arborist": "^4.0.1",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-router-dom": "^7.1.5",
diff --git a/frontend/src/components/BackToTopButton/ArrowUpIcon.tsx b/frontend/src/components/BackToTopButton/ArrowUpIcon.tsx
new file mode 100644
index 00000000000..f306b47fbfc
--- /dev/null
+++ b/frontend/src/components/BackToTopButton/ArrowUpIcon.tsx
@@ -0,0 +1,15 @@
+import {type ReactNode} from 'react';
+
+export default function ArrowUpIcon(): ReactNode {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/BackToTopButton/index.tsx b/frontend/src/components/BackToTopButton/index.tsx
new file mode 100644
index 00000000000..f80d4571f65
--- /dev/null
+++ b/frontend/src/components/BackToTopButton/index.tsx
@@ -0,0 +1,37 @@
+import {type ReactNode} from 'react';
+import clsx from 'clsx';
+
+import ArrowUpIcon from './ArrowUpIcon';
+import styles from './styles.module.css';
+
+type BackToTopButtonProps = {
+ label: string;
+ visible: boolean;
+ onClick: () => void;
+ className?: string;
+};
+
+export default function BackToTopButton({
+ label,
+ visible,
+ onClick,
+ className,
+}: BackToTopButtonProps): ReactNode {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/BackToTopButton/styles.module.css b/frontend/src/components/BackToTopButton/styles.module.css
new file mode 100644
index 00000000000..6758c10dba9
--- /dev/null
+++ b/frontend/src/components/BackToTopButton/styles.module.css
@@ -0,0 +1,66 @@
+.button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--back-to-top-size, 3rem);
+ height: var(--back-to-top-size, 3rem);
+ padding: 0;
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: 999px;
+ background: var(--ifm-background-surface-color);
+ color: var(--ifm-color-emphasis-700);
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
+ cursor: pointer;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transform: translateY(6px) scale(0.96);
+ transition:
+ opacity 160ms ease,
+ transform 160ms ease,
+ visibility 0s linear 160ms,
+ border-color 150ms ease,
+ color 150ms ease,
+ box-shadow 150ms ease;
+}
+
+.visible {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: none;
+ transition-delay: 0s;
+}
+
+.button:hover {
+ border-color: var(--ifm-color-primary);
+ color: var(--ifm-color-primary);
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
+ transform: translateY(-1px);
+}
+
+.button:active {
+ transform: scale(0.94);
+}
+
+.button:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 2px;
+}
+
+.button svg {
+ width: var(--back-to-top-icon-size, 1.25rem);
+ height: var(--back-to-top-icon-size, 1.25rem);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .button,
+ .visible {
+ transition: none;
+ }
+
+ .button:hover,
+ .button:active {
+ transform: none;
+ }
+}
diff --git a/frontend/src/theme/BackToTopButton/index.tsx b/frontend/src/theme/BackToTopButton/index.tsx
new file mode 100644
index 00000000000..3cc4a8af20b
--- /dev/null
+++ b/frontend/src/theme/BackToTopButton/index.tsx
@@ -0,0 +1,70 @@
+import {
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import clsx from 'clsx';
+import {translate} from '@docusaurus/Translate';
+import {ThemeClassNames} from '@docusaurus/theme-common';
+import {useLocationChange} from '@docusaurus/theme-common/internal';
+
+import BackToTopButton from '../../components/BackToTopButton';
+import styles from './styles.module.css';
+
+const SHOW_THRESHOLD = 300;
+
+export default function PageBackToTopButton(): ReactNode {
+ const [shown, setShown] = useState(false);
+ const shownRef = useRef(false);
+
+ useEffect(() => {
+ const update = () => {
+ const nextShown = window.scrollY > SHOW_THRESHOLD;
+ if (shownRef.current === nextShown) {
+ return;
+ }
+ shownRef.current = nextShown;
+ setShown(nextShown);
+ };
+
+ update();
+ window.addEventListener('scroll', update, {passive: true});
+ return () => window.removeEventListener('scroll', update);
+ }, []);
+
+ useLocationChange(() => {
+ // New routes start at the top.
+ shownRef.current = false;
+ setShown(false);
+ });
+
+ const scrollToTop = () => {
+ const reducedMotion =
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ window.scrollTo({
+ top: 0,
+ behavior: reducedMotion ? 'auto' : 'smooth',
+ });
+ };
+
+ const label = translate({
+ id: 'theme.BackToTopButton.buttonAriaLabel',
+ message: 'Scroll back to top',
+ description: 'The ARIA label for the back to top button',
+ });
+
+ return (
+
+ );
+}
diff --git a/frontend/src/theme/BackToTopButton/styles.module.css b/frontend/src/theme/BackToTopButton/styles.module.css
new file mode 100644
index 00000000000..8756d7824ba
--- /dev/null
+++ b/frontend/src/theme/BackToTopButton/styles.module.css
@@ -0,0 +1,6 @@
+.pageButton {
+ position: fixed;
+ right: 1.5rem;
+ bottom: 3rem;
+ z-index: calc(var(--ifm-z-index-fixed) - 1);
+}
diff --git a/frontend/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx b/frontend/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx
new file mode 100644
index 00000000000..e2449f6dacf
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx
@@ -0,0 +1,31 @@
+import {type ReactNode} from 'react';
+import clsx from 'clsx';
+import {translate} from '@docusaurus/Translate';
+import IconArrow from '@theme/Icon/Arrow';
+import type {Props} from '@theme/DocSidebar/Desktop/CollapseButton';
+
+import styles from './styles.module.css';
+
+export default function CollapseButton({onClick}: Props): ReactNode {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/theme/DocSidebar/Desktop/CollapseButton/styles.module.css b/frontend/src/theme/DocSidebar/Desktop/CollapseButton/styles.module.css
new file mode 100644
index 00000000000..df46519f224
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Desktop/CollapseButton/styles.module.css
@@ -0,0 +1,40 @@
+:root {
+ --docusaurus-collapse-button-bg: transparent;
+ --docusaurus-collapse-button-bg-hover: rgb(0 0 0 / 10%);
+}
+
+[data-theme='dark']:root {
+ --docusaurus-collapse-button-bg: rgb(255 255 255 / 5%);
+ --docusaurus-collapse-button-bg-hover: rgb(255 255 255 / 10%);
+}
+
+@media (min-width: 997px) {
+ .collapseSidebarButton {
+ display: block !important;
+ background-color: var(--docusaurus-collapse-button-bg);
+ height: 40px;
+ position: sticky;
+ bottom: 0;
+ border-radius: 0;
+ border: 1px solid var(--ifm-toc-border-color);
+ }
+
+ .collapseSidebarButtonIcon {
+ transform: rotate(180deg);
+ margin-top: 4px;
+ }
+
+ [dir='rtl'] .collapseSidebarButtonIcon {
+ transform: rotate(0);
+ }
+
+ .collapseSidebarButton:hover,
+ .collapseSidebarButton:focus {
+ background-color: var(--docusaurus-collapse-button-bg-hover);
+ }
+}
+
+.collapseSidebarButton {
+ display: none;
+ margin: 0;
+}
diff --git a/frontend/src/theme/DocSidebar/Desktop/Content/index.tsx b/frontend/src/theme/DocSidebar/Desktop/Content/index.tsx
new file mode 100644
index 00000000000..e5efc1c1777
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Desktop/Content/index.tsx
@@ -0,0 +1,51 @@
+import {type ReactNode, useState} from 'react';
+import clsx from 'clsx';
+import {
+ useAnnouncementBar,
+ useScrollPosition,
+} from '@docusaurus/theme-common/internal';
+import {translate} from '@docusaurus/Translate';
+import DocsSidebarTree from '../../Tree';
+import type {Props} from '@theme/DocSidebar/Desktop/Content';
+
+import styles from './styles.module.css';
+
+function useShowAnnouncementBar() {
+ const {isActive} = useAnnouncementBar();
+ const [showAnnouncementBar, setShowAnnouncementBar] = useState(isActive);
+
+ useScrollPosition(
+ ({scrollY}) => {
+ if (isActive) {
+ setShowAnnouncementBar(scrollY === 0);
+ }
+ },
+ [isActive],
+ );
+ return isActive && showAnnouncementBar;
+}
+
+export default function DocSidebarDesktopContent({
+ path,
+ sidebar,
+ className,
+}: Props): ReactNode {
+ const showAnnouncementBar = useShowAnnouncementBar();
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/theme/DocSidebar/Desktop/Content/styles.module.css b/frontend/src/theme/DocSidebar/Desktop/Content/styles.module.css
new file mode 100644
index 00000000000..2c76475e11f
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Desktop/Content/styles.module.css
@@ -0,0 +1,20 @@
+@media (min-width: 997px) {
+ .menu {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ flex-grow: 1;
+ min-height: 0;
+ overflow: hidden;
+ padding: 0.5rem;
+ }
+ @supports (scrollbar-gutter: stable) {
+ .menu {
+ padding: 0.5rem 0 0.5rem 0.5rem;
+ }
+ }
+
+ .menuWithAnnouncementBar {
+ margin-bottom: var(--docusaurus-announcement-bar-height);
+ }
+}
diff --git a/frontend/src/theme/DocSidebar/Desktop/index.tsx b/frontend/src/theme/DocSidebar/Desktop/index.tsx
new file mode 100644
index 00000000000..9446ba23345
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Desktop/index.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import clsx from 'clsx';
+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 type {Props} from '@theme/DocSidebar/Desktop';
+
+import styles from './styles.module.css';
+
+function DocSidebarDesktop({path, sidebar, onCollapse, isHidden}: Props) {
+ const {
+ navbar: {hideOnScroll},
+ docs: {
+ sidebar: {hideable},
+ },
+ } = useThemeConfig();
+
+ return (
+
+ {hideOnScroll && }
+
+ {hideable && }
+
+ );
+}
+
+export default React.memo(DocSidebarDesktop);
diff --git a/frontend/src/theme/DocSidebar/Desktop/styles.module.css b/frontend/src/theme/DocSidebar/Desktop/styles.module.css
new file mode 100644
index 00000000000..c5d5e50e16f
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Desktop/styles.module.css
@@ -0,0 +1,37 @@
+@media (min-width: 997px) {
+ .sidebar {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding-top: var(--ifm-navbar-height);
+ width: var(--doc-sidebar-width);
+ }
+
+ .sidebarWithHideableNavbar {
+ padding-top: 0;
+ }
+
+ .sidebarHidden {
+ opacity: 0;
+ visibility: hidden;
+ }
+
+ .sidebarLogo {
+ display: flex !important;
+ align-items: center;
+ margin: 0 var(--ifm-navbar-padding-horizontal);
+ min-height: var(--ifm-navbar-height);
+ max-height: var(--ifm-navbar-height);
+ color: inherit !important;
+ text-decoration: none !important;
+ }
+
+ .sidebarLogo img {
+ margin-right: 0.5rem;
+ height: 2rem;
+ }
+}
+
+.sidebarLogo {
+ display: none;
+}
diff --git a/frontend/src/theme/DocSidebar/Mobile/index.tsx b/frontend/src/theme/DocSidebar/Mobile/index.tsx
new file mode 100644
index 00000000000..8790d04c4f7
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Mobile/index.tsx
@@ -0,0 +1,45 @@
+import React, {type ReactNode} from 'react';
+import {
+ NavbarSecondaryMenuFiller,
+ type NavbarSecondaryMenuComponent,
+} from '@docusaurus/theme-common';
+import {useNavbarMobileSidebar} from '@docusaurus/theme-common/internal';
+import DocsSidebarTree from '../Tree';
+import type {Props} from '@theme/DocSidebar/Mobile';
+
+import styles from './styles.module.css';
+
+const DocSidebarMobileSecondaryMenu: NavbarSecondaryMenuComponent = ({
+ sidebar,
+ path,
+}) => {
+ const mobileSidebar = useNavbarMobileSidebar();
+ return (
+
+ {
+ // 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();
+ }
+ }}
+ />
+
+ );
+};
+
+function DocSidebarMobile(props: Props): ReactNode {
+ return (
+
+ );
+}
+
+export default React.memo(DocSidebarMobile);
diff --git a/frontend/src/theme/DocSidebar/Mobile/styles.module.css b/frontend/src/theme/DocSidebar/Mobile/styles.module.css
new file mode 100644
index 00000000000..12cdacb4122
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Mobile/styles.module.css
@@ -0,0 +1,15 @@
+.mobileMenu {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+/* Keep the back button intact when the tree takes the remaining height. */
+:global(.navbar-sidebar__back) {
+ flex: 0 0 auto;
+}
+
+/* Only the panel that contains the docs menu becomes a flex column. */
+:global(.navbar-sidebar__item):has(.mobileMenu) {
+ display: flex;
+ flex-direction: column;
+}
diff --git a/frontend/src/theme/DocSidebar/Tree/context.ts b/frontend/src/theme/DocSidebar/Tree/context.ts
new file mode 100644
index 00000000000..da80d8aa550
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Tree/context.ts
@@ -0,0 +1,20 @@
+import {createContext, useContext} from 'react';
+import type {PropSidebarItem} from '@docusaurus/plugin-content-docs';
+
+export type SidebarContextValue = {
+ activePath: string;
+ filter: string;
+ onToggle: (id: string) => void;
+ onItemClick?: (item: PropSidebarItem) => void;
+ registerLink: (id: string, element: HTMLAnchorElement | null) => void;
+};
+
+export const SidebarContext = createContext(null);
+
+export function useSidebarContext(): SidebarContextValue {
+ const context = useContext(SidebarContext);
+ if (!context) {
+ throw new Error('SidebarContext is missing');
+ }
+ return context;
+}
diff --git a/frontend/src/theme/DocSidebar/Tree/icons.tsx b/frontend/src/theme/DocSidebar/Tree/icons.tsx
new file mode 100644
index 00000000000..9aba5d76968
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Tree/icons.tsx
@@ -0,0 +1,38 @@
+import {type ReactNode} from 'react';
+
+export function SearchIcon({className}: {className?: string}): ReactNode {
+ return (
+
+
+
+
+ );
+}
+
+export function ClearIcon(): ReactNode {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/theme/DocSidebar/Tree/index.tsx b/frontend/src/theme/DocSidebar/Tree/index.tsx
new file mode 100644
index 00000000000..0d30d266fc2
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Tree/index.tsx
@@ -0,0 +1,544 @@
+import {
+ useCallback,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEventHandler,
+ type ReactNode,
+} from 'react';
+import clsx from 'clsx';
+import useIsBrowser from '@docusaurus/useIsBrowser';
+import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect';
+import {translate} from '@docusaurus/Translate';
+import {ThemeClassNames, useThemeConfig} from '@docusaurus/theme-common';
+import {Tree, type NodeApi, type TreeApi} from 'react-arborist';
+import type {PropSidebarItem} from '@docusaurus/plugin-content-docs';
+
+import BackToTopButton from '../../../components/BackToTopButton';
+import {SidebarContext, type SidebarContextValue} from './context';
+import {ClearIcon, SearchIcon} from './icons';
+import {
+ DEFAULT_SIDEBAR_WIDTH,
+ INDENT,
+ buildSidebarNodes,
+ clearTextWidthCache,
+ countMatchingNodes,
+ findActiveNode,
+ getInitialOpenState,
+ getRowHeight,
+ matchesSidebarNode,
+ type SidebarNode,
+} from './model';
+import {
+ SidebarNodeRenderer,
+ SidebarTreeRow,
+ StaticSidebarRows,
+} from './rows';
+import {animateScrollTo} from './scroll';
+import styles from './styles.module.css';
+
+const BACK_TO_TOP_THRESHOLD = 200;
+
+export type DocsSidebarTreeProps = {
+ sidebar: readonly PropSidebarItem[];
+ path: string;
+ onItemClick?: (item: PropSidebarItem) => void;
+};
+
+export default function DocsSidebarTree({
+ sidebar,
+ path,
+ onItemClick,
+}: DocsSidebarTreeProps): ReactNode {
+ const isBrowser = useIsBrowser();
+ const treeRef = useRef | undefined>(undefined);
+ const treeViewportRef = useRef(null);
+ const inputRef = useRef(null);
+ const linkRefs = useRef(new Map());
+ const fontsReadyRef = useRef(false);
+ const scrollAnimationRef = useRef(null);
+ const showBackToTopRef = useRef(false);
+ const [showBackToTop, setShowBackToTop] = useState(false);
+ const [filter, setFilter] = useState('');
+ const deferredFilter = useDeferredValue(filter.trim());
+ const hasFilter = deferredFilter.length > 0;
+ const [size, setSize] = useState<{width: number; height: number} | null>(
+ null,
+ );
+ const [animated, setAnimated] = useState(false);
+
+ const {
+ docs: {
+ sidebar: {autoCollapseCategories},
+ },
+ } = useThemeConfig();
+
+ const nodes = useMemo(
+ () => buildSidebarNodes(sidebar, path),
+ [path, sidebar],
+ );
+ const initialOpenState = useMemo(
+ () => getInitialOpenState(nodes, path),
+ [nodes, path],
+ );
+ const activeNode = useMemo(
+ () => findActiveNode(nodes, path),
+ [nodes, path],
+ );
+ const matchCount = useMemo(
+ () => (hasFilter ? countMatchingNodes(nodes, deferredFilter) : 0),
+ [deferredFilter, hasFilter, nodes],
+ );
+ const isEmpty = hasFilter && matchCount === 0;
+
+ const filterPlaceholder = translate({
+ id: 'theme.docs.sidebar.filterPlaceholder',
+ message: 'Filter documentation',
+ description: 'Placeholder for the docs sidebar filter input',
+ });
+ const clearFilterLabel = translate({
+ id: 'theme.docs.sidebar.clearFilter',
+ message: 'Clear filter',
+ description: 'ARIA label for clearing the docs sidebar filter',
+ });
+ const noResultsTitle = translate({
+ id: 'theme.docs.sidebar.filterNoResults',
+ message: 'No matching docs',
+ description: 'Title shown when the docs sidebar filter has no matches',
+ });
+ const noResultsHint = translate({
+ id: 'theme.docs.sidebar.filterNoResultsHint',
+ message: 'Try a different search term.',
+ description: 'Hint shown when the docs sidebar filter has no matches',
+ });
+ const backToTopLabel = translate({
+ id: 'theme.BackToTopButton.buttonAriaLabel',
+ message: 'Scroll back to top',
+ description: 'The ARIA label for the back to top button',
+ });
+
+ const clearFilter = useCallback(() => {
+ setFilter('');
+ inputRef.current?.focus();
+ }, []);
+
+ const cancelScrollAnimation = useCallback(() => {
+ if (scrollAnimationRef.current !== null) {
+ cancelAnimationFrame(scrollAnimationRef.current);
+ scrollAnimationRef.current = null;
+ }
+ }, []);
+
+ const scrollToActiveNode = useCallback(() => {
+ const tree = treeRef.current;
+ if (!tree || !activeNode || tree.isFiltered) {
+ return;
+ }
+
+ cancelScrollAnimation();
+
+ const node = tree.get(activeNode.id);
+ const rowIndex = node?.rowIndex;
+ const outer = tree.listEl.current;
+ if (!node || typeof rowIndex !== 'number' || !outer) {
+ tree.scrollTo(activeNode.id, 'center');
+ return;
+ }
+
+ const itemTop =
+ tree.rowTopPosition(rowIndex) +
+ (tree.props.padding ?? tree.props.paddingTop ?? 0);
+ const itemHeight = tree.rowHeightAt(rowIndex);
+ const fullyVisible =
+ itemTop >= outer.scrollTop &&
+ itemTop + itemHeight <= outer.scrollTop + outer.clientHeight;
+ if (fullyVisible) {
+ return;
+ }
+
+ const centered = itemTop - (outer.clientHeight - itemHeight) / 2;
+ const maxOffset = Math.max(0, outer.scrollHeight - outer.clientHeight);
+ const target = Math.max(
+ 0,
+ Math.min(Math.round(centered), maxOffset),
+ );
+
+ scrollAnimationRef.current = animateScrollTo(outer, target) || null;
+ }, [activeNode, cancelScrollAnimation]);
+
+ const updateBackToTop = useCallback((scrollTop: number) => {
+ const next = scrollTop > BACK_TO_TOP_THRESHOLD;
+ if (showBackToTopRef.current !== next) {
+ showBackToTopRef.current = next;
+ setShowBackToTop(next);
+ }
+ }, []);
+
+ const scrollTreeToTop = useCallback(() => {
+ const outer = treeRef.current?.listEl.current;
+ if (!outer) {
+ return;
+ }
+
+ cancelScrollAnimation();
+ scrollAnimationRef.current = animateScrollTo(outer, 0) || null;
+ treeViewportRef.current?.focus({preventScroll: true});
+ }, [cancelScrollAnimation]);
+
+ const registerLink = useCallback(
+ (id: string, element: HTMLAnchorElement | null) => {
+ if (element) {
+ linkRefs.current.set(id, element);
+ } else {
+ linkRefs.current.delete(id);
+ }
+ },
+ [],
+ );
+
+ const toggleNode = useCallback((id: string) => {
+ treeRef.current?.get(id)?.toggle();
+ }, []);
+
+ const contextValue = useMemo(
+ () => ({
+ activePath: path,
+ filter: deferredFilter,
+ onToggle: toggleNode,
+ onItemClick,
+ registerLink,
+ }),
+ [deferredFilter, onItemClick, path, registerLink, toggleNode],
+ );
+
+ useIsomorphicLayoutEffect(() => {
+ const element = treeViewportRef.current;
+ if (!element) {
+ return undefined;
+ }
+
+ const measure = () => {
+ const rect = element.getBoundingClientRect();
+ const width = Math.max(0, Math.floor(rect.width));
+ const height = Math.max(0, Math.floor(rect.height));
+
+ setSize((previous) =>
+ previous &&
+ Math.abs(previous.width - width) < 1 &&
+ Math.abs(previous.height - height) < 1
+ ? previous
+ : {width, height},
+ );
+ };
+
+ measure();
+ window.addEventListener('resize', measure);
+
+ if (typeof ResizeObserver === 'undefined') {
+ return () => window.removeEventListener('resize', measure);
+ }
+
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener('resize', measure);
+ };
+ }, []);
+
+ const treeWidth = size?.width ?? DEFAULT_SIDEBAR_WIDTH;
+ const showTree = Boolean(
+ isBrowser && size && treeWidth > 0 && size.height > 0,
+ );
+
+ useEffect(() => {
+ if (!showTree) {
+ updateBackToTop(0);
+ }
+ }, [showTree, updateBackToTop]);
+
+ useEffect(() => {
+ const outer = treeRef.current?.listEl.current;
+ if (outer) {
+ updateBackToTop(outer.scrollTop);
+ }
+ }, [path, updateBackToTop]);
+
+ useEffect(() => {
+ if (!showTree) {
+ return undefined;
+ }
+
+ const outer = treeRef.current?.listEl.current;
+ if (!outer) {
+ return undefined;
+ }
+
+ let timeout: number | null = null;
+ const onScroll = () => {
+ // Don't animate rows while the user is scrolling.
+ outer.classList.add(styles.noTransition);
+ if (timeout !== null) {
+ window.clearTimeout(timeout);
+ }
+ timeout = window.setTimeout(() => {
+ timeout = null;
+ outer.classList.remove(styles.noTransition);
+ }, 100);
+ updateBackToTop(outer.scrollTop);
+ };
+ const cancel = () => cancelScrollAnimation();
+
+ outer.addEventListener('scroll', onScroll, {passive: true});
+ outer.addEventListener('wheel', cancel, {passive: true});
+ outer.addEventListener('touchstart', cancel, {passive: true});
+ outer.addEventListener('pointerdown', cancel, {passive: true});
+ outer.addEventListener('keydown', cancel);
+ return () => {
+ outer.removeEventListener('scroll', onScroll);
+ outer.removeEventListener('wheel', cancel);
+ outer.removeEventListener('touchstart', cancel);
+ outer.removeEventListener('pointerdown', cancel);
+ outer.removeEventListener('keydown', cancel);
+ cancel();
+ if (timeout !== null) {
+ window.clearTimeout(timeout);
+ }
+ outer.classList.remove(styles.noTransition);
+ };
+ }, [cancelScrollAnimation, showTree, updateBackToTop]);
+
+ useEffect(() => {
+ setAnimated(showTree);
+ }, [showTree]);
+
+ useEffect(() => {
+ if (!hasFilter) {
+ return;
+ }
+
+ cancelScrollAnimation();
+ treeRef.current?.scrollToOffset(0);
+ updateBackToTop(0);
+ }, [cancelScrollAnimation, deferredFilter, hasFilter, updateBackToTop]);
+
+ useEffect(() => {
+ if (!showTree || !activeNode || hasFilter) {
+ return undefined;
+ }
+
+ const tree = treeRef.current;
+ if (!tree) {
+ return undefined;
+ }
+
+ // Auto-collapse is handled by onToggle.
+ tree.openParents(activeNode.id);
+ if (activeNode.kind === 'category') {
+ tree.open(activeNode.id);
+ }
+
+ const frame = requestAnimationFrame(scrollToActiveNode);
+ return () => cancelAnimationFrame(frame);
+ }, [activeNode, hasFilter, scrollToActiveNode, showTree]);
+
+ useEffect(() => {
+ if (!isBrowser || !document.fonts || fontsReadyRef.current) {
+ return undefined;
+ }
+
+ let cancelled = false;
+ let frame = 0;
+ document.fonts.ready.then(() => {
+ if (cancelled) {
+ return;
+ }
+ fontsReadyRef.current = true;
+ clearTextWidthCache();
+ treeRef.current?.redrawList();
+ // Fonts affect row heights; recenter once loaded.
+ frame = requestAnimationFrame(scrollToActiveNode);
+ });
+
+ return () => {
+ cancelled = true;
+ cancelAnimationFrame(frame);
+ };
+ }, [isBrowser, scrollToActiveNode]);
+
+ const rowHeight = useCallback(
+ (node: NodeApi) =>
+ getRowHeight(node.data, node.level, treeWidth),
+ [treeWidth],
+ );
+
+ const handleToggle = useCallback(
+ (id: string) => {
+ const tree = treeRef.current;
+ if (!autoCollapseCategories || !tree || tree.isFiltered) {
+ return;
+ }
+
+ const node = tree.get(id);
+ if (!node?.isOpen) {
+ return;
+ }
+
+ node.parent?.children?.forEach((sibling) => {
+ if (sibling.id !== id && sibling.isInternal && sibling.isOpen) {
+ tree.close(sibling.id, false);
+ }
+ });
+ tree.redrawList();
+ },
+ [autoCollapseCategories],
+ );
+
+ const handleActivate = useCallback((node: NodeApi) => {
+ if (node.isLeaf) {
+ linkRefs.current.get(node.id)?.click();
+ }
+ }, []);
+
+ const handleKeyDown: KeyboardEventHandler = (event) => {
+ if (event.key !== 'Enter') {
+ return;
+ }
+
+ const activeElement = document.activeElement;
+ if (
+ !(activeElement instanceof HTMLElement) ||
+ activeElement.getAttribute('role') !== 'treeitem'
+ ) {
+ return;
+ }
+
+ const link = activeElement.querySelector('a');
+ if (link) {
+ event.preventDefault();
+ link.click();
+ }
+ };
+
+ return (
+
+
+
+
+ 0 && styles.filterInputWithActions,
+ )}
+ value={filter}
+ onChange={(event) => setFilter(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape' && filter) {
+ event.preventDefault();
+ clearFilter();
+ }
+ }}
+ placeholder={filterPlaceholder}
+ aria-label={filterPlaceholder}
+ autoComplete="off"
+ spellCheck={false}
+ />
+ {hasFilter && (
+
+ {matchCount.toLocaleString()}
+
+ )}
+ {filter.length > 0 && (
+
+
+
+ )}
+
+
+
+
+ {showTree && size ? (
+
+ ref={treeRef}
+ data={nodes}
+ searchTerm={deferredFilter}
+ searchMatch={matchesSidebarNode}
+ openByDefault={false}
+ initialOpenState={initialOpenState}
+ width={treeWidth}
+ height={size.height}
+ indent={INDENT}
+ rowHeight={rowHeight}
+ overscanCount={8}
+ paddingBottom={8}
+ disableDrag
+ disableDrop
+ disableEdit
+ disableSelect
+ disableMultiSelection
+ onToggle={handleToggle}
+ onActivate={handleActivate}
+ renderRow={SidebarTreeRow}
+ aria-label={translate({
+ id: 'theme.docs.sidebar.navAriaLabel',
+ message: 'Docs sidebar',
+ description:
+ 'The ARIA label for the sidebar navigation',
+ })}
+ className={clsx(
+ 'thin-scrollbar',
+ styles.tree,
+ animated && !hasFilter && styles.animated,
+ )}>
+ {SidebarNodeRenderer}
+
+ ) : (
+
+ )}
+ {isEmpty && showTree && (
+
+
+ {noResultsTitle}
+
+ “{deferredFilter}”
+
+ {noResultsHint}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/theme/DocSidebar/Tree/model.ts b/frontend/src/theme/DocSidebar/Tree/model.ts
new file mode 100644
index 00000000000..bd056491b2c
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Tree/model.ts
@@ -0,0 +1,324 @@
+import isInternalUrl from '@docusaurus/isInternalUrl';
+import {isSamePath} from '@docusaurus/theme-common/internal';
+import {
+ isActiveSidebarItem,
+ isVisibleSidebarItem,
+} from '@docusaurus/plugin-content-docs/client';
+import type {NodeApi} from 'react-arborist';
+import type {
+ PropSidebarItem,
+ PropSidebarItemCategory,
+} from '@docusaurus/plugin-content-docs';
+
+export const INDENT = 12; // 0.75rem, matches nested .menu__list padding
+export const DEFAULT_SIDEBAR_WIDTH = 292; // 300px minus the menu's left padding
+export const STATIC_ROW_LIMIT = 80;
+
+const ROW_GAP = 4; // 0.25rem, matches .menu__list-item margin
+const LINK_LINE_HEIGHT = 20; // 1.25rem, matches .menu__link line-height
+const LINK_VERTICAL_PADDING = 12; // 0.375rem top and bottom
+const LINK_HORIZONTAL_PADDING = 24; // 0.75rem left and right
+const CLEAR_TEXT_WIDTH = 8.2; // rough fallback without a canvas
+const SCROLLBAR_WIDTH = 10;
+const MAX_TEXT_LINES = 2;
+
+export type SidebarNode = {
+ id: string;
+ name: string;
+ kind: 'category' | 'link' | 'html';
+ label: string;
+ href?: string;
+ item: PropSidebarItem;
+ className?: string;
+ defaultStyle?: boolean;
+ html?: string;
+ autoAddBaseUrl?: boolean;
+ children?: SidebarNode[];
+};
+
+export type OpenState = Record;
+
+export type FlattenedRow = {
+ node: SidebarNode;
+ level: number;
+ top: number;
+ height: number;
+ isOpen: boolean;
+};
+
+let textMeasureContext: CanvasRenderingContext2D | null = null;
+const textWidths = new Map();
+
+function measureTextWidth(text: string): number {
+ if (typeof document === 'undefined') {
+ return Array.from(text).reduce(
+ (width, character) =>
+ width +
+ (character.charCodeAt(0) > 0x2e80 ? 15.5 : CLEAR_TEXT_WIDTH),
+ 0,
+ );
+ }
+
+ if (!textMeasureContext) {
+ const canvas = document.createElement('canvas');
+ textMeasureContext = canvas.getContext('2d');
+ if (textMeasureContext) {
+ textMeasureContext.font = '600 16px Inter, sans-serif';
+ }
+ }
+
+ if (!textMeasureContext) {
+ return text.length * CLEAR_TEXT_WIDTH;
+ }
+
+ const cached = textWidths.get(text);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const width = textMeasureContext.measureText(text).width;
+ textWidths.set(text, width);
+ return width;
+}
+
+export function clearTextWidthCache() {
+ textWidths.clear();
+}
+
+export function getRowHeight(
+ node: SidebarNode,
+ level: number,
+ containerWidth: number,
+): number {
+ const width = containerWidth > 0 ? containerWidth : DEFAULT_SIDEBAR_WIDTH;
+ const isCategory = node.kind === 'category';
+ const category = isCategory
+ ? (node.item as PropSidebarItemCategory)
+ : undefined;
+ const isExternal =
+ node.kind === 'link' && typeof node.href === 'string'
+ ? !isInternalUrl(node.href)
+ : false;
+ const caretWidth =
+ isCategory && category?.collapsible !== false
+ ? node.href
+ ? 44 // separate .menu__caret button
+ : 26 // caret pseudo-element including its margin
+ : 0;
+
+ const availableWidth = Math.max(
+ 80,
+ width -
+ SCROLLBAR_WIDTH -
+ level * INDENT -
+ LINK_HORIZONTAL_PADDING -
+ caretWidth -
+ (isExternal ? 20 : 0),
+ );
+
+ const textWidth =
+ node.kind === 'html' ? 0 : measureTextWidth(node.label || node.name);
+ const lines = textWidth > availableWidth ? MAX_TEXT_LINES : 1;
+ return lines * LINK_LINE_HEIGHT + LINK_VERTICAL_PADDING + ROW_GAP;
+}
+
+function createNodeId(
+ item: PropSidebarItem,
+ parentId: string,
+ index: number,
+): string {
+ if (item.type === 'category') {
+ return item.href
+ ? `category:${parentId}/${index}:${item.href}`
+ : `category:${parentId}/${index}:${item.label}`;
+ }
+ if (item.type === 'link') {
+ return `link:${parentId}/${index}:${item.href}`;
+ }
+ return `html:${parentId}/${index}`;
+}
+
+export function buildSidebarNodes(
+ items: readonly PropSidebarItem[],
+ activePath: string,
+ parentId = 'root',
+): SidebarNode[] {
+ const nodes: SidebarNode[] = [];
+
+ items.forEach((item, index) => {
+ if (!isVisibleSidebarItem(item, activePath)) {
+ return;
+ }
+
+ if (item.type === 'category') {
+ const id = createNodeId(item, parentId, index);
+ const children = buildSidebarNodes(item.items, activePath, id);
+ const href = item.href && !item.linkUnlisted ? item.href : undefined;
+
+ if (children.length === 0) {
+ if (!href) {
+ return;
+ }
+ nodes.push({
+ id,
+ name: item.label,
+ kind: 'link',
+ label: item.label,
+ href,
+ item: {
+ type: 'link',
+ href,
+ label: item.label,
+ className: item.className,
+ },
+ className: item.className,
+ });
+ return;
+ }
+
+ nodes.push({
+ id,
+ name: item.label,
+ kind: 'category',
+ label: item.label,
+ href,
+ item,
+ className: item.className,
+ children,
+ });
+ return;
+ }
+
+ if (item.type === 'link') {
+ nodes.push({
+ id: createNodeId(item, parentId, index),
+ name: item.label,
+ kind: 'link',
+ label: item.label,
+ href: item.href,
+ item,
+ className: item.className,
+ autoAddBaseUrl: item.autoAddBaseUrl,
+ });
+ return;
+ }
+
+ nodes.push({
+ id: createNodeId(item, parentId, index),
+ name: '',
+ kind: 'html',
+ label: '',
+ item,
+ className: item.className,
+ defaultStyle: item.defaultStyle,
+ html: item.value,
+ });
+ });
+
+ return nodes;
+}
+
+export function getInitialOpenState(
+ nodes: readonly SidebarNode[],
+ activePath: string,
+): OpenState {
+ const openState: OpenState = {};
+
+ const visit = (items: readonly SidebarNode[]) => {
+ items.forEach((node) => {
+ if (node.kind !== 'category') {
+ return;
+ }
+
+ const item = node.item as PropSidebarItemCategory;
+ if (
+ item.collapsible === false ||
+ isActiveSidebarItem(item, activePath) ||
+ !item.collapsed
+ ) {
+ openState[node.id] = true;
+ }
+
+ visit(node.children ?? []);
+ });
+ };
+
+ visit(nodes);
+ return openState;
+}
+
+export function findActiveNode(
+ nodes: readonly SidebarNode[],
+ activePath: string,
+): SidebarNode | undefined {
+ for (const node of nodes) {
+ if (node.kind === 'link' && isSamePath(node.href, activePath)) {
+ return node;
+ }
+
+ if (node.kind === 'category') {
+ const child = findActiveNode(node.children ?? [], activePath);
+ if (child) {
+ return child;
+ }
+ if (isSamePath(node.href, activePath)) {
+ return node;
+ }
+ }
+ }
+
+ return undefined;
+}
+
+export function normalizeFilter(value: string): string {
+ return value.toLocaleLowerCase();
+}
+
+/**
+ * Match a node's own label. react-arborist keeps ancestor rows as context, so
+ * matching a category does not pull its whole subtree into the results.
+ */
+export function matchesSidebarNode(
+ node: NodeApi,
+ searchTerm: string,
+): boolean {
+ const term = normalizeFilter(searchTerm.trim());
+ return !term || normalizeFilter(node.data.label).includes(term);
+}
+
+export function countMatchingNodes(
+ nodes: readonly SidebarNode[],
+ filter: string,
+): number {
+ const term = normalizeFilter(filter.trim());
+ if (!term) {
+ return 0;
+ }
+
+ const visit = (items: readonly SidebarNode[]): number =>
+ items.reduce(
+ (count, node) =>
+ count +
+ (normalizeFilter(node.label).includes(term) ? 1 : 0) +
+ (node.children ? visit(node.children) : 0),
+ 0,
+ );
+
+ return visit(nodes);
+}
+
+export function flattenVisibleNodes(
+ nodes: readonly SidebarNode[],
+ openState: OpenState,
+ level = 0,
+ rows: Array<{node: SidebarNode; level: number}> = [],
+) {
+ nodes.forEach((node) => {
+ rows.push({node, level});
+ if (node.kind === 'category' && openState[node.id]) {
+ flattenVisibleNodes(node.children ?? [], openState, level + 1, rows);
+ }
+ });
+
+ return rows;
+}
diff --git a/frontend/src/theme/DocSidebar/Tree/rows.tsx b/frontend/src/theme/DocSidebar/Tree/rows.tsx
new file mode 100644
index 00000000000..cc6fa87d54f
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Tree/rows.tsx
@@ -0,0 +1,382 @@
+import {
+ useMemo,
+ type MouseEventHandler,
+ type ReactNode,
+} from 'react';
+import clsx from 'clsx';
+import Link from '@docusaurus/Link';
+import isInternalUrl from '@docusaurus/isInternalUrl';
+import {translate} from '@docusaurus/Translate';
+import {ThemeClassNames} from '@docusaurus/theme-common';
+import {isSamePath} from '@docusaurus/theme-common/internal';
+import {
+ findFirstSidebarItemLink,
+ isActiveSidebarItem,
+} from '@docusaurus/plugin-content-docs/client';
+import IconExternalLink from '@theme/Icon/ExternalLink';
+import type {
+ NodeRendererProps,
+ RowRendererProps,
+} from 'react-arborist';
+import type {
+ PropSidebarItem,
+ PropSidebarItemCategory,
+ PropSidebarItemLink,
+} from '@docusaurus/plugin-content-docs';
+
+import {useSidebarContext} from './context';
+import {
+ DEFAULT_SIDEBAR_WIDTH,
+ INDENT,
+ STATIC_ROW_LIMIT,
+ flattenVisibleNodes,
+ getRowHeight,
+ normalizeFilter,
+ type FlattenedRow,
+ type OpenState,
+ type SidebarNode,
+} from './model';
+import styles from './styles.module.css';
+
+type SidebarRowContentProps = {
+ data: SidebarNode;
+ isOpen: boolean;
+ activePath: string;
+ filter: string;
+ onToggle: (id: string) => void;
+ onItemClick?: (item: PropSidebarItem) => void;
+ registerLink: (id: string, element: HTMLAnchorElement | null) => void;
+ staticMode?: boolean;
+};
+
+function getRowClassName(
+ node: SidebarNode,
+ level: number,
+ isOpen: boolean,
+): string {
+ return clsx(
+ 'menu__list-item',
+ node.kind === 'category'
+ ? [
+ ThemeClassNames.docs.docSidebarItemCategory,
+ ThemeClassNames.docs.docSidebarItemCategoryLevel(level + 1),
+ !isOpen && 'menu__list-item--collapsed',
+ ]
+ : [
+ ThemeClassNames.docs.docSidebarItemLink,
+ ThemeClassNames.docs.docSidebarItemLinkLevel(level + 1),
+ ],
+ node.className,
+ );
+}
+
+function HighlightedLabel({
+ label,
+ filter,
+ className,
+}: {
+ label: string;
+ filter: string;
+ className: string;
+}) {
+ const term = normalizeFilter(filter);
+ const index = term ? normalizeFilter(label).indexOf(term) : -1;
+
+ if (index < 0) {
+ return (
+
+ {label}
+
+ );
+ }
+
+ return (
+
+ {label.slice(0, index)}
+
+ {label.slice(index, index + filter.length)}
+
+ {label.slice(index + filter.length)}
+
+ );
+}
+
+function CategoryCollapseButton({
+ isOpen,
+ label,
+ onClick,
+}: {
+ isOpen: boolean;
+ label: string;
+ onClick: MouseEventHandler;
+}) {
+ const ariaLabel = isOpen
+ ? translate(
+ {
+ id: 'theme.DocSidebarItem.collapseCategoryAriaLabel',
+ message: "Collapse sidebar category '{label}'",
+ description: 'The ARIA label to collapse the sidebar category',
+ },
+ {label},
+ )
+ : translate(
+ {
+ id: 'theme.DocSidebarItem.expandCategoryAriaLabel',
+ message: "Expand sidebar category '{label}'",
+ description: 'The ARIA label to expand the sidebar category',
+ },
+ {label},
+ );
+
+ return (
+
+ );
+}
+
+function SidebarCategoryRow({
+ data,
+ isOpen,
+ activePath,
+ filter,
+ onToggle,
+ onItemClick,
+ registerLink,
+ staticMode,
+}: SidebarRowContentProps) {
+ const item = data.item as PropSidebarItemCategory;
+ const hasHref = Boolean(data.href);
+ const isActive = isActiveSidebarItem(item, activePath);
+ const isCurrentPage = isSamePath(data.href, activePath);
+ const linkHref =
+ data.href ??
+ (staticMode ? findFirstSidebarItemLink(item) : undefined);
+
+ const handleClick: MouseEventHandler = (event) => {
+ if (staticMode) {
+ return;
+ }
+
+ if (hasHref) {
+ onItemClick?.(item);
+ }
+
+ if (!item.collapsible) {
+ return;
+ }
+
+ if (!hasHref) {
+ event.preventDefault();
+ onToggle(data.id);
+ } else if (isCurrentPage) {
+ event.preventDefault();
+ onToggle(data.id);
+ } else if (!isOpen) {
+ onToggle(data.id);
+ }
+ };
+
+ return (
+
+ registerLink(data.id, element)}
+ className={clsx('menu__link', styles.categoryLink, {
+ 'menu__link--sublist': item.collapsible,
+ 'menu__link--sublist-caret': item.collapsible && !hasHref,
+ 'menu__link--active': isActive,
+ })}
+ onClick={handleClick}
+ aria-current={isCurrentPage ? 'page' : undefined}
+ role={item.collapsible && !hasHref ? 'button' : undefined}
+ aria-expanded={
+ item.collapsible && !hasHref ? isOpen : undefined
+ }
+ to={linkHref ?? '#'}
+ autoAddBaseUrl>
+
+
+ {hasHref && item.collapsible && (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!staticMode) {
+ onToggle(data.id);
+ }
+ }}
+ />
+ )}
+
+ );
+}
+
+function SidebarLinkRow({
+ data,
+ activePath,
+ filter,
+ onItemClick,
+ registerLink,
+}: SidebarRowContentProps) {
+ const item = data.item as PropSidebarItemLink;
+ const isActive = isSamePath(data.href, activePath);
+ const isInternal = isInternalUrl(data.href);
+
+ return (
+ registerLink(data.id, element)}
+ className={clsx('menu__link', !isInternal && styles.menuExternalLink, {
+ 'menu__link--active': isActive,
+ })}
+ autoAddBaseUrl={data.autoAddBaseUrl}
+ aria-current={isActive ? 'page' : undefined}
+ to={data.href}
+ {...(isInternal && onItemClick
+ ? {onClick: () => onItemClick(item)}
+ : {})}>
+
+ {!isInternal && }
+
+ );
+}
+
+function SidebarHtmlRow({data}: {data: SidebarNode}) {
+ return (
+
+ );
+}
+
+function SidebarRowContent(props: SidebarRowContentProps) {
+ if (props.data.kind === 'category') {
+ return ;
+ }
+ if (props.data.kind === 'html') {
+ return ;
+ }
+ return ;
+}
+
+export function SidebarNodeRenderer({
+ node,
+ style,
+}: NodeRendererProps): ReactNode {
+ const context = useSidebarContext();
+
+ return (
+
+
+
+ );
+}
+
+export function SidebarTreeRow({
+ innerRef,
+ attrs,
+ children,
+}: RowRendererProps): ReactNode {
+ return (
+
+ {children}
+
+ );
+}
+
+export function StaticSidebarRows({
+ nodes,
+ initialOpenState,
+ activePath,
+ onItemClick,
+ registerLink,
+}: {
+ nodes: readonly SidebarNode[];
+ initialOpenState: OpenState;
+ activePath: string;
+ onItemClick?: (item: PropSidebarItem) => void;
+ registerLink: (id: string, element: HTMLAnchorElement | null) => void;
+}): ReactNode {
+ const rows = useMemo(() => {
+ let top = 0;
+
+ return flattenVisibleNodes(nodes, initialOpenState)
+ .slice(0, STATIC_ROW_LIMIT)
+ .map(({node, level}) => {
+ const height = getRowHeight(node, level, DEFAULT_SIDEBAR_WIDTH);
+ const row = {
+ node,
+ level,
+ top,
+ height,
+ isOpen: Boolean(initialOpenState[node.id]),
+ };
+ top += height;
+ return row;
+ });
+ }, [initialOpenState, nodes]);
+
+ return (
+
+ {rows.map(({node, level, top, height, isOpen}) => (
+
+
+ undefined}
+ onItemClick={onItemClick}
+ registerLink={registerLink}
+ staticMode
+ />
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/theme/DocSidebar/Tree/scroll.ts b/frontend/src/theme/DocSidebar/Tree/scroll.ts
new file mode 100644
index 00000000000..9985b6152ed
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Tree/scroll.ts
@@ -0,0 +1,39 @@
+const MAX_DURATION = 700;
+
+/**
+ * react-window sets scrollTop directly, so tree.scrollTo() does not animate.
+ */
+export function animateScrollTo(element: HTMLElement, target: number): number {
+ const start = element.scrollTop;
+ const distance = target - start;
+ const reducedMotion =
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ if (Math.abs(distance) < 1 || reducedMotion) {
+ element.scrollTop = target;
+ return 0;
+ }
+
+ const duration = Math.min(MAX_DURATION, 220 + Math.abs(distance) * 0.015);
+ const startedAt = performance.now();
+ let frame = 0;
+
+ const step = (now: number) => {
+ const progress = Math.min(1, (now - startedAt) / duration);
+ if (progress >= 1) {
+ element.scrollTop = target;
+ return;
+ }
+
+ const eased =
+ progress < 0.5
+ ? 4 * progress ** 3
+ : 1 - (-2 * progress + 2) ** 3 / 2;
+ element.scrollTop = start + distance * eased;
+ frame = requestAnimationFrame(step);
+ };
+
+ frame = requestAnimationFrame(step);
+ return frame;
+}
diff --git a/frontend/src/theme/DocSidebar/Tree/styles.module.css b/frontend/src/theme/DocSidebar/Tree/styles.module.css
new file mode 100644
index 00000000000..9c2831034e8
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/Tree/styles.module.css
@@ -0,0 +1,353 @@
+.container {
+ position: relative;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: visible;
+}
+
+/* Keep the divider visible above the tree scrollbar. */
+@media (min-width: 997px) {
+ .container::after {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 2;
+ width: 1px;
+ background: var(--ifm-toc-border-color, var(--ifm-color-emphasis-200));
+ content: '';
+ pointer-events: none;
+ }
+}
+
+.filterBar {
+ position: relative;
+ z-index: 1;
+ flex: 0 0 auto;
+ padding: 0 0 0.5rem 0;
+}
+
+@media (min-width: 997px) {
+ .filterBar {
+ padding-right: 0.5rem;
+ }
+}
+
+.filterField {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.filterIcon {
+ position: absolute;
+ left: 0.7rem;
+ width: 1rem;
+ height: 1rem;
+ color: var(--ifm-color-emphasis-500);
+ pointer-events: none;
+}
+
+.filterInput {
+ width: 100%;
+ height: 2.25rem;
+ margin: 0;
+ padding: 0 2.5rem 0 2.2rem;
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: 0.5rem;
+ background: var(--ifm-background-surface-color);
+ color: var(--ifm-font-color-base);
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 400;
+ line-height: 1.25;
+ box-sizing: border-box;
+ appearance: none;
+ -webkit-appearance: none;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
+ transition:
+ border-color 150ms ease,
+ box-shadow 150ms ease,
+ background-color 150ms ease;
+}
+
+.filterInputWithActions {
+ padding-right: 5.25rem;
+}
+
+.filterInput::placeholder {
+ color: var(--ifm-color-emphasis-500);
+ opacity: 1;
+}
+
+.filterInput:hover {
+ border-color: var(--ifm-color-emphasis-300);
+}
+
+.filterInput:focus {
+ outline: none;
+ border-color: var(--ifm-color-primary);
+ box-shadow: 0 0 0 3px rgba(144, 131, 210, 0.18);
+ box-shadow: 0 0 0 3px
+ color-mix(in srgb, var(--ifm-color-primary) 18%, transparent);
+}
+
+.filterInput::-webkit-search-cancel-button,
+.filterInput::-webkit-search-decoration {
+ display: none;
+}
+
+.clearButton {
+ position: absolute;
+ right: 0.4rem;
+ top: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: var(--ifm-color-emphasis-100);
+ color: var(--ifm-color-emphasis-600);
+ cursor: pointer;
+ transform: translateY(-50%);
+ transition:
+ background-color 150ms ease,
+ color 150ms ease,
+ transform 150ms ease;
+}
+
+.filterCount {
+ position: absolute;
+ right: 2.5rem;
+ top: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.25rem;
+ height: 1.25rem;
+ padding: 0 0.35rem;
+ border-radius: 999px;
+ background: var(--ifm-color-emphasis-200);
+ color: var(--ifm-color-emphasis-800);
+ font-size: 0.75rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+ white-space: nowrap;
+ pointer-events: none;
+ transform: translateY(-50%);
+}
+
+.clearButton svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+.clearButton:hover {
+ background: var(--ifm-color-emphasis-200);
+ color: var(--ifm-color-emphasis-800);
+}
+
+.clearButton:active {
+ transform: translateY(-50%) scale(0.92);
+}
+
+.clearButton:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 2px;
+}
+
+.treeViewport {
+ position: relative;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.treeViewport:focus {
+ outline: none;
+}
+
+.highlight {
+ padding: 0;
+ border-radius: 0.2em;
+ background: rgba(144, 131, 210, 0.22);
+ background: color-mix(
+ in srgb,
+ var(--ifm-color-primary) 22%,
+ transparent
+ );
+ color: inherit;
+}
+
+.emptyState {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 2.25rem 1rem 1rem;
+ color: var(--ifm-color-emphasis-600);
+ text-align: center;
+ pointer-events: none;
+}
+
+.emptyIcon {
+ width: 1.6rem;
+ height: 1.6rem;
+ margin-bottom: 0.6rem;
+ color: var(--ifm-color-emphasis-400);
+}
+
+.emptyTitle {
+ color: var(--ifm-heading-color);
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.emptyQuery {
+ max-width: 100%;
+ margin-top: 0.2rem;
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.8rem;
+ overflow-wrap: anywhere;
+}
+
+.emptyHint {
+ margin-top: 0.6rem;
+ color: var(--ifm-color-emphasis-500);
+ font-size: 0.78rem;
+}
+
+.backToTop {
+ --back-to-top-size: 2.25rem;
+ --back-to-top-icon-size: 1rem;
+
+ position: absolute;
+ right: 2rem;
+ bottom: 1.25rem;
+ z-index: 4;
+}
+
+@media (max-width: 996px) {
+ .filterInput {
+ /* Avoid the automatic zoom-on-focus in mobile Safari. */
+ font-size: 1rem;
+ }
+}
+
+.tree {
+ outline: none;
+ overflow-x: hidden !important;
+ scrollbar-gutter: stable;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .animated [role='treeitem'] {
+ transition:
+ top 240ms var(--ifm-transition-timing-default, ease-in-out),
+ height 240ms var(--ifm-transition-timing-default, ease-in-out),
+ opacity 120ms ease-out 120ms;
+ }
+
+ /* Fade in new rows after the rows below make room. */
+ @starting-style {
+ .animated [role='treeitem'] {
+ opacity: 0;
+ }
+ }
+}
+
+/* Disable row transitions while scrolling. */
+.noTransition [role='treeitem'] {
+ transition: none !important;
+ opacity: 1 !important;
+}
+
+.treeRow {
+ width: 100%;
+ min-width: 0 !important;
+ box-sizing: border-box;
+}
+
+.treeRow:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: -2px;
+ border-radius: 4px;
+}
+
+.staticList {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+}
+
+.staticRow {
+ position: absolute;
+ left: 0;
+ width: 100%;
+}
+
+.rowInner {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ /* Virtual rows include the gap that .menu__list used to add. */
+ padding-top: 4px;
+ margin-top: 0 !important;
+}
+
+/* Prevent links from overflowing their virtual row. */
+.rowInner :global(.menu__link) {
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.categoryWrapper {
+ width: 100%;
+ min-width: 0;
+}
+
+.categoryLink {
+ overflow: hidden;
+ min-width: 0;
+ max-width: 100%;
+}
+
+/* Two-line clamp; flex items blockify -webkit-box, so use max-height. */
+.categoryLabel,
+.linkLabel {
+ display: block;
+ flex: 1 1 0;
+ min-width: 0;
+ max-width: 100%;
+ max-height: 2.5rem; /* two lines at the 1.25 line-height */
+ overflow: hidden;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.menuExternalLink {
+ align-items: center;
+}
+
+.htmlRow {
+ width: 100%;
+ margin: 0 !important;
+}
+
+:global(.menu__link--sublist-caret)::after {
+ margin-left: var(--ifm-menu-link-padding-vertical);
+}
diff --git a/frontend/src/theme/DocSidebar/index.tsx b/frontend/src/theme/DocSidebar/index.tsx
new file mode 100644
index 00000000000..c2d62cc94de
--- /dev/null
+++ b/frontend/src/theme/DocSidebar/index.tsx
@@ -0,0 +1,23 @@
+import {type ReactNode} from 'react';
+import {useWindowSize} from '@docusaurus/theme-common';
+import DocSidebarDesktop from '@theme/DocSidebar/Desktop';
+import DocSidebarMobile from '@theme/DocSidebar/Mobile';
+import type {Props} from '@theme/DocSidebar';
+
+export default function DocSidebar(props: Props): ReactNode {
+ const windowSize = useWindowSize();
+
+ // Desktop sidebar visible on hydration: need SSR rendering
+ const shouldRenderSidebarDesktop =
+ windowSize === 'desktop' || windowSize === 'ssr';
+
+ // Mobile sidebar not visible on hydration: can avoid SSR rendering
+ const shouldRenderSidebarMobile = windowSize === 'mobile';
+
+ return (
+ <>
+ {shouldRenderSidebarDesktop && }
+ {shouldRenderSidebarMobile && }
+ >
+ );
+}