From 93e6f96fe780de337ddd9a92b7297278e0f22b43 Mon Sep 17 00:00:00 2001 From: dockfries Date: Tue, 10 Nov 2026 14:18:21 +0800 Subject: [PATCH 1/4] rebuild the docs sidebar with react-arborist Virtualize the docs tree while keeping the Docusaurus menu styling. Add filtering, active-item scrolling, and a shared back-to-top control sized and placed by each context. Add translations to the existing locale code.json files. --- frontend/i18n/fa/code.json | 16 + frontend/i18n/id/code.json | 16 + frontend/i18n/pt-BR/code.json | 16 + frontend/i18n/th/code.json | 16 + frontend/i18n/tr/code.json | 16 + frontend/i18n/uk/code.json | 16 + frontend/i18n/zh-CN/code.json | 16 + frontend/package-lock.json | 138 +++++ frontend/package.json | 3 + .../BackToTopButton/ArrowUpIcon.tsx | 15 + .../src/components/BackToTopButton/index.tsx | 37 ++ .../BackToTopButton/styles.module.css | 66 +++ frontend/src/theme/BackToTopButton/index.tsx | 70 +++ .../theme/BackToTopButton/styles.module.css | 6 + .../Desktop/CollapseButton/index.tsx | 31 + .../Desktop/CollapseButton/styles.module.css | 40 ++ .../DocSidebar/Desktop/Content/index.tsx | 51 ++ .../Desktop/Content/styles.module.css | 20 + .../src/theme/DocSidebar/Desktop/index.tsx | 33 ++ .../DocSidebar/Desktop/styles.module.css | 37 ++ .../src/theme/DocSidebar/Mobile/index.tsx | 46 ++ .../theme/DocSidebar/Mobile/styles.module.css | 4 + frontend/src/theme/DocSidebar/Tree/context.ts | 20 + frontend/src/theme/DocSidebar/Tree/icons.tsx | 38 ++ frontend/src/theme/DocSidebar/Tree/index.tsx | 542 ++++++++++++++++++ frontend/src/theme/DocSidebar/Tree/model.ts | 338 +++++++++++ frontend/src/theme/DocSidebar/Tree/rows.tsx | 382 ++++++++++++ frontend/src/theme/DocSidebar/Tree/scroll.ts | 39 ++ .../theme/DocSidebar/Tree/styles.module.css | 331 +++++++++++ frontend/src/theme/DocSidebar/index.tsx | 23 + 30 files changed, 2422 insertions(+) create mode 100644 frontend/src/components/BackToTopButton/ArrowUpIcon.tsx create mode 100644 frontend/src/components/BackToTopButton/index.tsx create mode 100644 frontend/src/components/BackToTopButton/styles.module.css create mode 100644 frontend/src/theme/BackToTopButton/index.tsx create mode 100644 frontend/src/theme/BackToTopButton/styles.module.css create mode 100644 frontend/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx create mode 100644 frontend/src/theme/DocSidebar/Desktop/CollapseButton/styles.module.css create mode 100644 frontend/src/theme/DocSidebar/Desktop/Content/index.tsx create mode 100644 frontend/src/theme/DocSidebar/Desktop/Content/styles.module.css create mode 100644 frontend/src/theme/DocSidebar/Desktop/index.tsx create mode 100644 frontend/src/theme/DocSidebar/Desktop/styles.module.css create mode 100644 frontend/src/theme/DocSidebar/Mobile/index.tsx create mode 100644 frontend/src/theme/DocSidebar/Mobile/styles.module.css create mode 100644 frontend/src/theme/DocSidebar/Tree/context.ts create mode 100644 frontend/src/theme/DocSidebar/Tree/icons.tsx create mode 100644 frontend/src/theme/DocSidebar/Tree/index.tsx create mode 100644 frontend/src/theme/DocSidebar/Tree/model.ts create mode 100644 frontend/src/theme/DocSidebar/Tree/rows.tsx create mode 100644 frontend/src/theme/DocSidebar/Tree/scroll.ts create mode 100644 frontend/src/theme/DocSidebar/Tree/styles.module.css create mode 100644 frontend/src/theme/DocSidebar/index.tsx 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..de1da9551f1 --- /dev/null +++ b/frontend/src/theme/DocSidebar/Mobile/index.tsx @@ -0,0 +1,46 @@ +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..221478f06f0 --- /dev/null +++ b/frontend/src/theme/DocSidebar/Mobile/styles.module.css @@ -0,0 +1,4 @@ +.mobileMenu { + height: 100%; + min-height: 0; +} 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..25173e0b466 --- /dev/null +++ b/frontend/src/theme/DocSidebar/Tree/index.tsx @@ -0,0 +1,542 @@ +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, + findActiveNode, + getInitialOpenState, + getRowHeight, + hasMatchingNodes, + 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; + variant?: 'desktop' | 'mobile'; + onItemClick?: (item: PropSidebarItem) => void; +}; + +export default function DocsSidebarTree({ + sidebar, + path, + variant = 'desktop', + 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 hasMatches = useMemo( + () => !hasFilter || hasMatchingNodes(nodes, deferredFilter), + [deferredFilter, hasFilter, nodes], + ); + const isEmpty = hasFilter && !hasMatches; + + 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 = + variant === 'mobile' + ? Math.max(0, Math.floor(window.innerHeight - rect.top - 8)) + : 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); + }; + }, [variant]); + + 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 ( +
+
+
+ + setFilter(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape' && filter) { + event.preventDefault(); + clearFilter(); + } + }} + placeholder={filterPlaceholder} + aria-label={filterPlaceholder} + autoComplete="off" + spellCheck={false} + /> + {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..932dd7ce551 --- /dev/null +++ b/frontend/src/theme/DocSidebar/Tree/model.ts @@ -0,0 +1,338 @@ +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 by its own label or an ancestor category label, so matching a + * category keeps its subtree visible. + */ +export function matchesSidebarNode( + node: NodeApi, + searchTerm: string, +): boolean { + const term = normalizeFilter(searchTerm.trim()); + if (!term) { + return true; + } + + if (normalizeFilter(node.data.label).includes(term)) { + return true; + } + + let parent = node.parent; + while (parent && !parent.isRoot) { + if (normalizeFilter(parent.data.label).includes(term)) { + return true; + } + parent = parent.parent; + } + + return false; +} + +export function hasMatchingNodes( + nodes: readonly SidebarNode[], + filter: string, +): boolean { + const term = normalizeFilter(filter.trim()); + if (!term) { + return true; + } + + const visit = (items: readonly SidebarNode[]): boolean => + items.some( + (node) => + normalizeFilter(node.label).includes(term) || + (node.children ? visit(node.children) : false), + ); + + 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 ( +