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;