diff --git a/demo/playground/hmr-playground.tsx b/demo/playground/hmr-playground.tsx
index efd843abcd..560633cb49 100644
--- a/demo/playground/hmr-playground.tsx
+++ b/demo/playground/hmr-playground.tsx
@@ -11,6 +11,10 @@ const userUrl = window.location.search.match(/url=(.*)$/);
const specUrl =
(userUrl && userUrl[1]) || (swagger ? 'swagger.yaml' : big ? 'big-openapi.json' : 'openapi.yaml');
-const options: RedocRawOptions = { nativeScrollbars: false, maxDisplayedEnumValues: 3 };
+const options: RedocRawOptions = {
+ nativeScrollbars: false,
+ maxDisplayedEnumValues: 3,
+ theme: { sidebar: { collapseBtn: { active: true } } },
+};
render(, document.getElementById('example'));
diff --git a/src/components/Operation/Operation.tsx b/src/components/Operation/Operation.tsx
index b6904f62cc..a56b87801b 100644
--- a/src/components/Operation/Operation.tsx
+++ b/src/components/Operation/Operation.tsx
@@ -35,7 +35,7 @@ export const Operation = observer(({ operation }: OperationProps): JSX.Element =
{options => (
-
+
{summary} {deprecated && Deprecated }
diff --git a/src/components/Redoc/Redoc.tsx b/src/components/Redoc/Redoc.tsx
index a3d0eef7cd..66fdac3d5c 100644
--- a/src/components/Redoc/Redoc.tsx
+++ b/src/components/Redoc/Redoc.tsx
@@ -10,6 +10,7 @@ import { ApiLogo } from '../ApiLogo/ApiLogo';
import { ContentItems } from '../ContentItems/ContentItems';
import { SideMenu } from '../SideMenu/SideMenu';
import { StickyResponsiveSidebar } from '../StickySidebar/StickyResponsiveSidebar';
+import { SidebarCollapseButton, SidebarExpandButton } from '../StickySidebar/SidebarCollapseSvg';
import { ApiContentWrap, BackgroundStub, RedocWrap } from './styled.elements';
import { SearchBox } from '../SearchBox/SearchBox';
@@ -55,11 +56,19 @@ export class Redoc extends React.Component {
null}
+ {(options.theme.sidebar.collapseBtn.active && (
+
+ )) ||
+ null}
+ {(options.theme.sidebar.collapseBtn.active && (
+
+ )) ||
+ null}
-
+
diff --git a/src/components/StickySidebar/SidebarCollapseSvg.tsx b/src/components/StickySidebar/SidebarCollapseSvg.tsx
new file mode 100644
index 0000000000..2cc6bd5c48
--- /dev/null
+++ b/src/components/StickySidebar/SidebarCollapseSvg.tsx
@@ -0,0 +1,264 @@
+import * as React from 'react';
+
+import styled from '../../styled-components';
+import { ResolvedThemeInterface } from '../../theme';
+
+export const SidebarCollapseButton = ({ options }: { options: ResolvedThemeInterface }) => {
+ return (
+
+ );
+};
+
+export const SidebarExpandButton = ({ options }: { options: ResolvedThemeInterface }) => {
+ return (
+
+
+
+ );
+};
+
+let sidebarWidth: string;
+let rightPanelWidth: string;
+
+const SidebarCollapseContainer = styled.div`
+ ${function ({ options }: { options: ResolvedThemeInterface }) {
+ sidebarWidth = options.sidebar.width;
+ rightPanelWidth = options.rightPanel.width;
+
+ return `
+ margin-left: calc(${options.sidebar.width} - ${options.sidebar.collapseBtn.size} / 2);
+ top: ${options.sidebar.collapseBtn.top};
+ z-index: 10;
+ width: ${options.sidebar.collapseBtn.size};
+ height: ${options.sidebar.collapseBtn.size};
+ background: #fff;
+ border: 1px solid #e9ebf0;
+ border-radius: 50%;
+ box-shadow: 0 0 5px 0 rgba(0,0,0,.06);
+ cursor: pointer;
+ position: fixed;
+
+ @media screen and (max-width: 768px) {
+ display: none;
+ }
+
+ @media print {
+ display: none;
+ }
+ `;
+ }};
+`;
+
+const SidebarExpandContainer = styled.div`
+ ${({ options }: { options: ResolvedThemeInterface }) => `
+ margin-left: 10px;
+ top: ${options.sidebar.collapseBtn.top};
+ z-index: 10;
+ width: ${options.sidebar.collapseBtn.size};
+ height: ${options.sidebar.collapseBtn.size};
+ background: #fff;
+ border: 1px solid #e9ebf0;
+ border-radius: 50%;
+ box-shadow: 0 0 5px 0 rgba(0,0,0,.06);
+ cursor: pointer;
+ position: fixed;
+ display: none;
+
+ @media screen and (max-width: 768px) {
+ display: none;
+ }
+
+ @media print {
+ display: none;
+ }
+ `};
+`;
+
+const SidebarCollapseSvg = ({ options }: { options: ResolvedThemeInterface }) => (
+
+);
+
+const SidebarExpandSvg = ({ options }: { options: ResolvedThemeInterface }) => (
+
+);
+
+const collapse = () => {
+ const sidebarExpand = document.getElementById('sidebar-expand') as HTMLElement;
+ const sidebarCollapase = document.getElementById('sidebar-callapse') as HTMLElement;
+
+ const sidebar = document.getElementsByClassName('menu-content')[0] as HTMLElement;
+ const main = document.getElementsByClassName('api-content')[0] as HTMLElement;
+ const backgroundStub = document.getElementsByClassName('background-stub')[0] as HTMLElement;
+
+ const firstVisibleElement: Element | null = findFirstVisibleElement(main);
+
+ if (rightPanelWidth.endsWith('%')) {
+ backgroundStub.style.width = rightPanelWidth;
+ } else {
+ const middleWidth = parseFloat(
+ window.getComputedStyle(document.getElementsByClassName('middle-panel')[0] as HTMLElement)
+ .width,
+ );
+ const rightWidth = parseFloat(rightPanelWidth);
+ const percents = rightWidth / (middleWidth + rightWidth);
+ backgroundStub.style.width = `calc(${rightPanelWidth} + ${percents} * ${sidebarWidth})`;
+ }
+
+ // hide sidebar, and set the width of api-content to 100%
+ sidebarCollapase.style.display = 'none';
+ sidebarExpand.style.display = 'inline';
+ sidebar.style.display = 'none';
+ main.style.width = '100%';
+
+ // scrool to the first visible element
+ if (firstVisibleElement) {
+ const collapseOffset =
+ (firstVisibleElement.getBoundingClientRect() as DOMRect).top + window.scrollY;
+ window.scrollTo({ top: collapseOffset - 10 });
+ }
+};
+
+const expand = () => {
+ const sidebarExpand = document.getElementById('sidebar-expand') as HTMLElement;
+ const sidebarCollapase = document.getElementById('sidebar-callapse') as HTMLElement;
+
+ const sidebar = document.getElementsByClassName('menu-content')[0] as HTMLElement;
+ const main = document.getElementsByClassName('api-content')[0] as HTMLElement;
+ const backgroundStub = document.getElementsByClassName('background-stub')[0] as HTMLElement;
+
+ const firstVisibleElement: Element | null = findFirstVisibleElement(main);
+
+ if (rightPanelWidth.endsWith('%')) {
+ const percents = parseInt(rightPanelWidth, 10);
+ backgroundStub.style.width = `calc((100% - ${sidebarWidth}) * ${percents / 100})`;
+ } else {
+ backgroundStub.style.width = rightPanelWidth;
+ }
+
+ // show sidebar, and restore the width of api-content
+ sidebarCollapase.style.display = 'inline';
+ sidebarExpand.style.display = 'none';
+ sidebar.style.display = 'flex';
+ main.style.width = `calc(100% - ${sidebarWidth})`;
+
+ // scroll to the first visible element
+ if (firstVisibleElement) {
+ const expandOffset =
+ (firstVisibleElement.getBoundingClientRect() as DOMRect).top + window.scrollY;
+ window.scrollTo({ top: expandOffset - 10 });
+ }
+};
+
+// Try to find the first visible element in the current section or operation.
+// Use the DOM content corresponding to the location hash instead of searching
+// the entire document content to reduce the scope of the search.
+// If current location hash is empty(in the page beginning), then find the first
+// visible element in the whole container(api-content middle pannel).
+function findFirstVisibleElement(container: Element): Element | null {
+ const currentHash = decodeURI(window.location.hash);
+ let sectionOrOperation: Element | null;
+ if (currentHash === '') {
+ sectionOrOperation = container;
+ } else {
+ sectionOrOperation = document.getElementById(currentHash.substring(1));
+ }
+ if (!sectionOrOperation) {
+ console.error('Failed to find target with location hash: ' + currentHash.substring(1));
+ return null;
+ }
+
+ const visibleEle = search(sectionOrOperation);
+ if (visibleEle) {
+ return visibleEle;
+ }
+
+ // Handling Cases:
+ // The current location hash corresponds to a DOM content that has almost
+ // entirely been scrolled above the viewport, except for the operation's
+ // ending delimiter line at the bottom.
+ const nextTarget = sectionOrOperation.nextElementSibling as Element;
+ if (!nextTarget) {
+ return visibleEle;
+ }
+ const nextVisibleEle = search(nextTarget);
+ if (nextVisibleEle) {
+ return nextVisibleEle;
+ }
+ return null;
+}
+
+// dfs search the first visible element in the section or operation
+function search(parent: Element): Element | null {
+ const children = Array.from(parent.children);
+ let result: Element | null = null;
+
+ // binary seach left boundary to find the first visible element
+ function binarySearch(low: number, high: number): void {
+ if (low > high) {
+ return;
+ }
+
+ const mid = Math.floor((low + high) / 2);
+ const child = children[mid] as Element;
+ const rect: DOMRect = child.getBoundingClientRect();
+
+ if (rect.bottom <= 0) {
+ binarySearch(mid + 1, high);
+ } else if (rect.top >= window.innerHeight) {
+ binarySearch(low, mid - 1);
+ } else {
+ result = child;
+ binarySearch(low, mid - 1);
+
+ if (result === child && child.children.length > 0) {
+ const closerChild = search(child);
+ if (closerChild) result = closerChild;
+ }
+ }
+ }
+
+ binarySearch(0, children.length - 1);
+ return result;
+}
diff --git a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap
index 31e0327d9e..db151ec7a2 100644
--- a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap
@@ -241,6 +241,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -513,6 +518,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -772,6 +782,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -1093,6 +1108,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -1377,6 +1397,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -1632,6 +1657,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -1912,6 +1942,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -2222,6 +2257,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -2494,6 +2534,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
@@ -2753,6 +2798,11 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"size": "1.5em",
},
"backgroundColor": "#fafafa",
+ "collapseBtn": Object {
+ "active": false,
+ "size": "24px",
+ "top": "50%",
+ },
"groupItems": Object {
"activeBackgroundColor": "#e1e1e1",
"activeTextColor": "#32329f",
diff --git a/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap b/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap
index f98b667a4f..fd50c7348c 100644
--- a/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap
@@ -159,7 +159,7 @@ exports[`FieldDetailsComponent renders correctly when field items have string ty
[
theme.sidebar.textColor,
},
+ collapseBtn: {
+ active: false,
+ top: '50%',
+ size: '24px',
+ },
},
logo: {
maxHeight: ({ sidebar }) => sidebar.width,
@@ -359,6 +364,11 @@ export interface ResolvedThemeInterface {
size: string;
color: string;
};
+ collapseBtn: {
+ active: boolean;
+ top: string;
+ size: string;
+ };
};
logo: {
maxHeight: string;