diff --git a/docusaurus.config.js b/docusaurus.config.js index e20b400..9eb1801 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -59,7 +59,7 @@ const config = { ({ docs: { sidebar: { hideable: true } }, navbar: { - title: 'My Site', + title: '', logo: { alt: 'My Site Logo', src: 'img/logo.svg', @@ -69,12 +69,10 @@ const config = { type: 'doc', docId: 'intro', position: 'left', - label: 'Tutorial', + label: 'Docs', }, - { to: '/blog', label: 'Blog', position: 'left' }, { - href: 'https://github.com/facebook/docusaurus', - label: 'GitHub', + type: 'localeDropdown', position: 'right', }, ], diff --git a/package.json b/package.json index b696e54..575fbbc 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@docusaurus/preset-classic": "2.1.0", "@mdx-js/react": "^1.6.22", "clsx": "^1.2.1", + "copy-to-clipboard": "^3.3.2", "prism-react-renderer": "^1.3.5", "react": "^17.0.2", "react-dom": "^17.0.2" diff --git a/src/components/IconButton/IconButton.module.scss b/src/components/IconButton/IconButton.module.scss new file mode 100644 index 0000000..9ce3a4d --- /dev/null +++ b/src/components/IconButton/IconButton.module.scss @@ -0,0 +1,17 @@ +.root { + -webkit-tap-highlight-color: transparent; + align-items: center; + display: flex; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 50%; + transition: background var(--ifm-transition-fast); + + width: 2rem; + height: 2rem; +} + +.root:hover { + background: var(--ifm-color-emphasis-200); +} diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx new file mode 100644 index 0000000..ed96c23 --- /dev/null +++ b/src/components/IconButton/IconButton.tsx @@ -0,0 +1,20 @@ +import clsx from 'clsx' +import React from 'react' +import styles from './IconButton.module.scss' + +export const IconButton: React.FC< + React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement + > & {} +> = ({ children, className, ...props }) => { + return ( + + ) +} diff --git a/src/components/IconButton/index.ts b/src/components/IconButton/index.ts new file mode 100644 index 0000000..a37a7fc --- /dev/null +++ b/src/components/IconButton/index.ts @@ -0,0 +1 @@ +export * from './IconButton' diff --git a/src/containers/GlobalStore/GlobalStore.tsx b/src/containers/GlobalStore/GlobalStore.tsx index 1be0710..f725281 100644 --- a/src/containers/GlobalStore/GlobalStore.tsx +++ b/src/containers/GlobalStore/GlobalStore.tsx @@ -6,6 +6,7 @@ export namespace GlobalStore { } export type Actions = { + toggleSidebar: () => void setHiddenSidebar: (payload: any) => void } @@ -14,6 +15,9 @@ export namespace GlobalStore { export const globalStore = new GlobalStore.Store(() => { return { + toggleSidebar: (payload, state, setState, dispatch) => { + setState((state) => void (state.hiddenSidebar = !state.hiddenSidebar)) + }, setHiddenSidebar: (payload, state, setState, dispatch) => { setState((state) => { state.hiddenSidebar = diff --git a/src/css/custom.css b/src/css/custom.css index fd1682f..c41bbbd 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -351,7 +351,11 @@ --ifm-hero-text-color: var(--ifm-color-black); } +/* sidebar */ :root { --doc-sidebar-width: 16.66vw !important; --doc-sidebar-max-width: 320px; + + --ifm-navbar-item-padding-vertical: 8px; + --ifm-navbar-item-padding-horizontal: 12px; } diff --git a/src/theme/ColorModeToggle/index.tsx b/src/theme/ColorModeToggle/index.tsx new file mode 100644 index 0000000..7531f4d --- /dev/null +++ b/src/theme/ColorModeToggle/index.tsx @@ -0,0 +1,56 @@ +import { translate } from '@docusaurus/Translate' +import useIsBrowser from '@docusaurus/useIsBrowser' +import { IconButton } from '@site/src/components/IconButton' +import type { Props } from '@theme/ColorModeToggle' +import IconDarkMode from '@theme/Icon/DarkMode' +import IconLightMode from '@theme/Icon/LightMode' +import clsx from 'clsx' +import React from 'react' +import styles from './styles.module.css' + +function ColorModeToggle({ className, value, onChange }: Props): JSX.Element { + const isBrowser = useIsBrowser() + + const title = translate( + { + message: 'Switch between dark and light mode (currently {mode})', + id: 'theme.colorToggle.ariaLabel', + description: 'The ARIA label for the navbar color mode toggle', + }, + { + mode: + value === 'dark' + ? translate({ + message: 'dark mode', + id: 'theme.colorToggle.ariaLabel.mode.dark', + description: 'The name for the dark color mode', + }) + : translate({ + message: 'light mode', + id: 'theme.colorToggle.ariaLabel.mode.light', + description: 'The name for the light color mode', + }), + }, + ) + + return ( +
+ onChange(value === 'dark' ? 'light' : 'dark')} + > + + + +
+ ) +} + +export default React.memo(ColorModeToggle) diff --git a/src/theme/ColorModeToggle/styles.module.css b/src/theme/ColorModeToggle/styles.module.css new file mode 100644 index 0000000..d94cb13 --- /dev/null +++ b/src/theme/ColorModeToggle/styles.module.css @@ -0,0 +1,8 @@ +[data-theme='light'] .darkToggleIcon, +[data-theme='dark'] .lightToggleIcon { + display: none; +} + +.toggleButtonDisabled { + cursor: not-allowed; +} diff --git a/src/theme/Icon/LightMode/index.tsx b/src/theme/Icon/LightMode/index.tsx new file mode 100644 index 0000000..da7fb65 --- /dev/null +++ b/src/theme/Icon/LightMode/index.tsx @@ -0,0 +1,7 @@ +import React from 'react' +import type { Props } from '@theme/Icon/LightMode' +import Icon from '@site/static/icons/day.svg' + +export default function IconLightMode(props: Props): JSX.Element { + return +} diff --git a/src/theme/Navbar/Content/ShareButton.tsx b/src/theme/Navbar/Content/ShareButton.tsx new file mode 100644 index 0000000..588a7ca --- /dev/null +++ b/src/theme/Navbar/Content/ShareButton.tsx @@ -0,0 +1,21 @@ +import { IconButton } from '@site/src/components/IconButton' +import React from 'react' +import Icon from '@site/static/icons/share.svg' +import styles from './styles.module.scss' +import copy2clipboard from 'copy-to-clipboard' + +export const ShareButton: React.FC<{}> = ({}) => { + return ( + { + if (typeof navigator?.share === 'function') navigator.share() + else copy2clipboard(window.location.href) + }} + className={styles.shareButton} + > + + + ) +} diff --git a/src/theme/Navbar/Content/index.tsx b/src/theme/Navbar/Content/index.tsx new file mode 100644 index 0000000..0757600 --- /dev/null +++ b/src/theme/Navbar/Content/index.tsx @@ -0,0 +1,97 @@ +import { useThemeConfig } from '@docusaurus/theme-common' +import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal' +import { + globalStore, + selectHiddenSidebar, +} from '@site/src/containers/GlobalStore' +import ChevronIcon from '@site/static/icons/chevron-left.svg' +import HamburgerIcon from '@site/static/icons/hamburger-menu.svg' +import NavbarColorModeToggle from '@theme/Navbar/ColorModeToggle' +import NavbarLogo from '@theme/Navbar/Logo' +import NavbarSearch from '@theme/Navbar/Search' +import NavbarItem, { Props as NavbarItemConfig } from '@theme/NavbarItem' +import SearchBar from '@theme/SearchBar' +import clsx from 'clsx' +import React from 'react' +import { ShareButton } from './ShareButton' +import styles from './styles.module.scss' + +function useNavbarItems() { + // TODO temporary casting until ThemeConfig type is improved + return useThemeConfig().navbar.items as NavbarItemConfig[] +} + +function NavbarItems({ items }: { items: NavbarItemConfig[] }): JSX.Element { + return ( + <> + {items.map((item, i) => ( + + ))} + + ) +} + +export default function NavbarContent(): JSX.Element { + const mobileSidebar = useNavbarMobileSidebar() + + const items = useNavbarItems() + + const dispatch = globalStore.useDispatch() + const hiddenDesktopSidebar = globalStore.useSelector(selectHiddenSidebar) + + const localeDropdown = items.find((item) => item.type === 'localeDropdown') + const links = items.filter( + (item) => item.type === 'doc' || !!(item as any).to, + ) + + return ( +
+
+ +
+ +
+
+
+ +
+
+ + + +
+
+
+ +
+
+ +
+ + + {localeDropdown && ( + <> +
+ + + )} +
+
+ ) +} diff --git a/src/theme/Navbar/Content/styles.module.scss b/src/theme/Navbar/Content/styles.module.scss new file mode 100644 index 0000000..13f775c --- /dev/null +++ b/src/theme/Navbar/Content/styles.module.scss @@ -0,0 +1,147 @@ +@use '@site/src/css/utils'; + +.root { + width: 100%; + margin: 0; + + & > div { + display: flex; + flex-direction: row; + align-items: center; + flex-grow: 0; + } + + & > div:first-child { + max-width: var(--doc-sidebar-max-width); + } + + & > .headerMiddle { + justify-content: space-between; + flex-grow: 1; + } +} + +.leftContainer { + display: flex; + flex-direction: row; + align-items: center; + + transition: 0.2s; + transform: translateX(0); + + &.shifted { + transform: translateX( + calc(-1 * min(var(--doc-sidebar-max-width), 16.66vw) + 64px) + ); + } +} + +.headerRight { + justify-content: flex-end; +} + +.navbarLogoWrapper { + & > * { + margin: 0; + } +} + +.menu { + @extend %menu-common; + + display: flex; + flex-direction: row; +} + +.sidebarButton { + border: none; + background: none; + + cursor: pointer; + + @extend %menu-common; + + width: 52px; + height: 46px; + + &.expand { + svg, + svg * { + fill: currentColor; + } + } + + &:not(.expand) { + svg, + svg * { + stroke: currentColor; + } + } +} + +%menu-common { + padding: 6px; + background-color: var(--ifm-background-surface-color); + border-radius: 1rem; +} + +.headerRight { + & > *:not(:last-child) { + margin-right: 34px; + } +} + +.divider { + height: 11px; + width: 1px; + + background-color: #d9d9d9; // TODO: use infima variables +} + +.shareButton { + svg, + svg * { + fill: currentColor; + stroke: currentColor; + } +} + +@include utils.responsive('lg', 'down') { + .root { + padding: 0 var(--ifm-spacing-horizontal); + } + + .root > * { + width: auto; + flex: 0 0 auto !important; + padding: 0; + } + + .headerLeft { + } + + .headerMiddle { + margin-left: 20px; + + .leftContainer.shifted { + transform: none; + } + + nav { + display: none; + } + } + + .headerRight { + display: none !important; + } +} + +/* +Hide color mode toggle in small viewports + */ +@media (max-width: 996px) { + .colorModeToggle { + display: none; + } +} diff --git a/src/theme/Navbar/index.scss b/src/theme/Navbar/index.scss new file mode 100644 index 0000000..2a282f6 --- /dev/null +++ b/src/theme/Navbar/index.scss @@ -0,0 +1,10 @@ +.navbar { + --ifm-navbar-height: 3.875rem; + --ifm-navbar-background-color: none; + --ifm-navbar-shadow: none; + --ifm-navbar-padding-horizontal: 0px; + --ifm-navbar-padding-vertical: 8px; +} + +:root { +} diff --git a/src/theme/Navbar/index.tsx b/src/theme/Navbar/index.tsx new file mode 100644 index 0000000..05ffc9b --- /dev/null +++ b/src/theme/Navbar/index.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import Navbar from '@theme-original/Navbar' +import type NavbarType from '@theme/Navbar' +import type { WrapperProps } from '@docusaurus/types' +import './index.scss' + +type Props = WrapperProps + +export default function NavbarWrapper(props: Props): JSX.Element { + return ( + <> + + + ) +} diff --git a/src/theme/NavbarItem/DropdownNavbarItem.scss b/src/theme/NavbarItem/DropdownNavbarItem.scss new file mode 100644 index 0000000..693c37a --- /dev/null +++ b/src/theme/NavbarItem/DropdownNavbarItem.scss @@ -0,0 +1,24 @@ +.navbar__item.dropdown { + background-color: #eeeef1; + border-radius: 0.5rem; + + & > .navbar__link { + &::after { + display: none; + } + + & + svg { + margin-left: 0.75rem; + } + } + + ul { + margin-top: 5px; + } +} + +[data-theme='dark'] { + .navbar__item.dropdown { + background-color: var(--ifm-background-color); + } +} diff --git a/src/theme/NavbarItem/DropdownNavbarItem.tsx b/src/theme/NavbarItem/DropdownNavbarItem.tsx new file mode 100644 index 0000000..1eb484a --- /dev/null +++ b/src/theme/NavbarItem/DropdownNavbarItem.tsx @@ -0,0 +1,190 @@ +import React, { useState, useRef, useEffect } from 'react' +import clsx from 'clsx' +import { + isRegexpStringMatch, + useCollapsible, + Collapsible, +} from '@docusaurus/theme-common' +import { isSamePath, useLocalPathname } from '@docusaurus/theme-common/internal' +import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink' +import NavbarItem, { LinkLikeNavbarItemProps } from '@theme/NavbarItem' +import type { + DesktopOrMobileNavBarItemProps, + Props, +} from '@theme/NavbarItem/DropdownNavbarItem' +import './DropdownNavbarItem.scss' +import DropdownIcon from '@site/static/icons/dropdown.svg' + +function isItemActive( + item: LinkLikeNavbarItemProps, + localPathname: string, +): boolean { + if (isSamePath(item.to, localPathname)) { + return true + } + if (isRegexpStringMatch(item.activeBaseRegex, localPathname)) { + return true + } + if (item.activeBasePath && localPathname.startsWith(item.activeBasePath)) { + return true + } + return false +} + +function containsActiveItems( + items: readonly LinkLikeNavbarItemProps[], + localPathname: string, +): boolean { + return items.some((item) => isItemActive(item, localPathname)) +} + +function DropdownNavbarItemDesktop({ + items, + position, + className, + onClick, + ...props +}: DesktopOrMobileNavBarItemProps) { + const dropdownRef = useRef(null) + const [showDropdown, setShowDropdown] = useState(false) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + !dropdownRef.current || + dropdownRef.current.contains(event.target as Node) + ) { + return + } + setShowDropdown(false) + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('touchstart', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('touchstart', handleClickOutside) + } + }, [dropdownRef]) + + return ( +
+ e.preventDefault()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + setShowDropdown(!showDropdown) + } + }} + > + {props.children ?? props.label} + + +
    + {items.map((childItemProps, i) => ( + { + if (i === items.length - 1 && e.key === 'Tab') { + e.preventDefault() + setShowDropdown(false) + const nextNavbarItem = dropdownRef.current!.nextElementSibling + if (nextNavbarItem) { + const targetItem = + nextNavbarItem instanceof HTMLAnchorElement + ? nextNavbarItem + : // Next item is another dropdown; focus on the inner + // anchor element instead so there's outline + nextNavbarItem.querySelector('a')! + targetItem.focus() + } + } + }} + activeClassName="dropdown__link--active" + {...childItemProps} + key={i} + /> + ))} +
+
+ ) +} + +function DropdownNavbarItemMobile({ + items, + className, + position, // Need to destructure position from props so that it doesn't get passed on. + onClick, + ...props +}: DesktopOrMobileNavBarItemProps) { + const localPathname = useLocalPathname() + const containsActive = containsActiveItems(items, localPathname) + + const { collapsed, toggleCollapsed, setCollapsed } = useCollapsible({ + initialState: () => !containsActive, + }) + + // Expand/collapse if any item active after a navigation + useEffect(() => { + if (containsActive) { + setCollapsed(!containsActive) + } + }, [localPathname, containsActive, setCollapsed]) + + return ( +
  • + { + e.preventDefault() + toggleCollapsed() + }} + > + {props.children ?? props.label} + + + {items.map((childItemProps, i) => ( + + ))} + +
  • + ) +} + +export default function DropdownNavbarItem({ + mobile = false, + ...props +}: Props): JSX.Element { + const Comp = mobile ? DropdownNavbarItemMobile : DropdownNavbarItemDesktop + return +} diff --git a/src/theme/NavbarItem/LocaleDropdownNavbarItem/index.tsx b/src/theme/NavbarItem/LocaleDropdownNavbarItem/index.tsx new file mode 100644 index 0000000..7d1229f --- /dev/null +++ b/src/theme/NavbarItem/LocaleDropdownNavbarItem/index.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import useDocusaurusContext from '@docusaurus/useDocusaurusContext' +import { useAlternatePageUtils } from '@docusaurus/theme-common/internal' +import { translate } from '@docusaurus/Translate' +import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem' +import IconLanguage from '@theme/Icon/Language' +import type { LinkLikeNavbarItemProps } from '@theme/NavbarItem' +import type { Props } from '@theme/NavbarItem/LocaleDropdownNavbarItem' + +import styles from './styles.module.css' + +export default function LocaleDropdownNavbarItem({ + mobile, + dropdownItemsBefore, + dropdownItemsAfter, + ...props +}: Props): JSX.Element { + const { + i18n: { currentLocale, locales, localeConfigs }, + } = useDocusaurusContext() + const alternatePageUtils = useAlternatePageUtils() + + const localeItems = locales.map((locale): LinkLikeNavbarItemProps => { + const to = `pathname://${alternatePageUtils.createUrl({ + locale, + fullyQualified: false, + })}` + return { + label: localeConfigs[locale]!.label, + lang: localeConfigs[locale]!.htmlLang, + to, + target: '_self', + autoAddBaseUrl: false, + className: + // eslint-disable-next-line no-nested-ternary + locale === currentLocale + ? // Similar idea as DefaultNavbarItem: select the right Infima active + // class name. This cannot be substituted with isActive, because the + // target URLs contain `pathname://` and therefore are not NavLinks! + mobile + ? 'menu__link--active' + : 'dropdown__link--active' + : '', + } + }) + + const items = [...dropdownItemsBefore, ...localeItems, ...dropdownItemsAfter] + + // Mobile is handled a bit differently + const dropdownLabel = mobile + ? translate({ + message: 'Languages', + id: 'theme.navbar.mobileLanguageDropdown.label', + description: 'The label for the mobile language switcher dropdown', + }) + : localeConfigs[currentLocale]!.label + + return ( + {dropdownLabel}} + items={items} + /> + ) +} diff --git a/src/theme/NavbarItem/LocaleDropdownNavbarItem/styles.module.css b/src/theme/NavbarItem/LocaleDropdownNavbarItem/styles.module.css new file mode 100644 index 0000000..8804a08 --- /dev/null +++ b/src/theme/NavbarItem/LocaleDropdownNavbarItem/styles.module.css @@ -0,0 +1,4 @@ +.iconLanguage { + vertical-align: text-bottom; + margin-right: 5px; +} diff --git a/static/icons/chevron-left.svg b/static/icons/chevron-left.svg new file mode 100644 index 0000000..f1cdcbd --- /dev/null +++ b/static/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/day.svg b/static/icons/day.svg new file mode 100644 index 0000000..62fa63f --- /dev/null +++ b/static/icons/day.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/dropdown.svg b/static/icons/dropdown.svg new file mode 100644 index 0000000..f583120 --- /dev/null +++ b/static/icons/dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/hamburger-menu.svg b/static/icons/hamburger-menu.svg new file mode 100644 index 0000000..fee9118 --- /dev/null +++ b/static/icons/hamburger-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/share.svg b/static/icons/share.svg new file mode 100644 index 0000000..fe0cd06 --- /dev/null +++ b/static/icons/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/logo.svg b/static/img/logo.svg index 9db6d0d..2263d40 100644 --- a/static/img/logo.svg +++ b/static/img/logo.svg @@ -1 +1,10 @@ - \ No newline at end of file + + + + + + + + + + diff --git a/yarn.lock b/yarn.lock index 228afe1..436e060 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3082,6 +3082,13 @@ copy-text-to-clipboard@^3.0.1: resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz#8cbf8f90e0a47f12e4a24743736265d157bce69c" integrity sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q== +copy-to-clipboard@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.2.tgz#5b263ec2366224b100181dded7ce0579b340c107" + integrity sha512-Vme1Z6RUDzrb6xAI7EZlVZ5uvOk2F//GaxKUxajDqm9LhOVM1inxNAD2vy+UZDYsd0uyA9s7b3/FVZPSxqrCfg== + dependencies: + toggle-selection "^1.0.6" + copy-webpack-plugin@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz#96d4dbdb5f73d02dd72d0528d1958721ab72e04a" @@ -7065,6 +7072,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toggle-selection@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"