feat: responsive side menu

This commit is contained in:
Roman Hotsiy 2018-01-30 15:36:21 +02:00
parent a29c3ccc2d
commit 3aab2d97d3
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
7 changed files with 216 additions and 81 deletions

View File

@ -3,7 +3,7 @@ import * as React from 'react';
import { AppStore } from '../../services/AppStore'; import { AppStore } from '../../services/AppStore';
import { MiddlePanel, Row, EmptyDarkRightPanel } from '../../common-elements/'; import { EmptyDarkRightPanel, MiddlePanel, Row } from '../../common-elements/';
import { Markdown } from '../Markdown/Markdown'; import { Markdown } from '../Markdown/Markdown';
import { SecurityDefs } from '../SecuritySchemes/SecuritySchemes'; import { SecurityDefs } from '../SecuritySchemes/SecuritySchemes';

View File

@ -9,7 +9,7 @@ import { ApiLogo } from '../ApiLogo/ApiLogo';
import { ContentItems } from '../ContentItems/ContentItems'; import { ContentItems } from '../ContentItems/ContentItems';
import { OptionsProvider } from '../OptionsProvider'; import { OptionsProvider } from '../OptionsProvider';
import { SideMenu } from '../SideMenu/SideMenu'; import { SideMenu } from '../SideMenu/SideMenu';
import { StickySidebar } from '../StickySidebar/StickySidebar'; import { StickyResponsiveSidebar } from '../StickySidebar/StickyResponsiveSidebar';
import { ApiContent, RedocWrap } from './elements'; import { ApiContent, RedocWrap } from './elements';
export interface RedocProps { export interface RedocProps {
@ -36,10 +36,10 @@ export class Redoc extends React.Component<RedocProps> {
<ThemeProvider theme={options.theme}> <ThemeProvider theme={options.theme}>
<OptionsProvider options={options}> <OptionsProvider options={options}>
<RedocWrap className="redoc-wrap"> <RedocWrap className="redoc-wrap">
<StickySidebar className="menu-content"> <StickyResponsiveSidebar menu={menu} className="menu-content">
<ApiLogo info={spec.info} /> <ApiLogo info={spec.info} />
<SideMenu menu={menu} /> <SideMenu menu={menu} />
</StickySidebar> </StickyResponsiveSidebar>
<ApiContent className="api-content"> <ApiContent className="api-content">
<ApiInfo store={store} /> <ApiInfo store={store} />
<ContentItems items={menu.items as any} /> <ContentItems items={menu.items as any} />

View File

@ -0,0 +1,66 @@
import * as React from 'react';
import styled from '../../styled-components';
export const AnimatedChevronButton = ({ open }: { open: boolean }) => {
const iconOffset = open ? 8 : -4;
return (
<ChevronContainer>
<ChevronSvg
size={15}
style={{
transform: `translate(2px, ${iconOffset}px) rotate(180deg)`,
transition: 'transform 0.2s ease',
}}
/>
<ChevronSvg
size={15}
style={{
transform: `translate(2px, ${0 - iconOffset}px)`,
transition: 'transform 0.2s ease',
}}
/>
</ChevronContainer>
);
};
// adapted from reactjs.org
const ChevronSvg = ({ size = 10, className = '', style = {} }) => (
<svg
className={className}
style={style}
viewBox="0 0 926.23699 573.74994"
version="1.1"
x="0px"
y="0px"
width={size}
height={size}
>
<g transform="translate(904.92214,-879.1482)">
<path
d={`
m -673.67664,1221.6502 -231.2455,-231.24803 55.6165,
-55.627 c 30.5891,-30.59485 56.1806,-55.627 56.8701,-55.627 0.6894,
0 79.8637,78.60862 175.9427,174.68583 l 174.6892,174.6858 174.6892,
-174.6858 c 96.079,-96.07721 175.253196,-174.68583 175.942696,
-174.68583 0.6895,0 26.281,25.03215 56.8701,
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
`}
fill="currentColor"
/>
</g>
</svg>
);
const ChevronContainer = styled.div`
user-select: none;
width: 20px;
height: 20px;
align-self: center;
display: flex;
flex-direction: column;
color: ${props => props.theme.colors.main};
`;

View File

@ -0,0 +1,127 @@
import { observer } from 'mobx-react';
import * as React from 'react';
import { MenuStore } from '../../services/MenuStore';
import { RedocNormalizedOptions, RedocRawOptions } from '../../services/RedocNormalizedOptions';
import styled, { media, withProps } from '../../styled-components';
import { ComponentWithOptions } from '../OptionsProvider';
import { AnimatedChevronButton } from './ChevronSvg';
let Stickyfill;
if (typeof window !== 'undefined') {
Stickyfill = require('stickyfill').default;
}
export interface StickySidebarProps {
className?: string;
scrollYOffset?: RedocRawOptions['scrollYOffset']; // passed directly or via context
menu: MenuStore;
}
const stickyfill = Stickyfill && Stickyfill();
const StyledStickySidebar = withProps<{ open?: boolean }>(styled.div)`
width: ${props => props.theme.menu.width};
background-color: ${props => props.theme.menu.backgroundColor};
overflow: hidden;
display: flex;
flex-direction: column;
transform: translateZ(0);
height: 100vh;
position: sticky;
position: -webkit-sticky;
top: 0;
${media.lessThan('small')`
position: fixed;
z-index: 20;
width: 100%;
background: #ffffff;
display: ${props => (props.open ? 'flex' : 'none')};
`};
`;
const FloatingButton = styled.div`
outline: none;
user-select: none;
background-color: #f2f2f2;
color: ${props => props.theme.colors.main};
display: none;
cursor: pointer;
position: fixed;
right: 20px;
z-index: 100;
border-radius: 50%;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
${media.lessThan('small')`
display: flex;
`};
bottom: 44px;
width: 60px;
height: 60px;
padding: 0 20px;
`;
@observer
export class StickyResponsiveSidebar extends ComponentWithOptions<StickySidebarProps> {
stickyElement: Element;
componentDidMount() {
if (stickyfill) {
stickyfill.add(this.stickyElement);
}
}
componentWillUnmount() {
if (stickyfill) {
stickyfill.remove(this.stickyElement);
}
}
get scrollYOffset() {
let top;
if (this.props.scrollYOffset !== undefined) {
top = RedocNormalizedOptions.normalizeScrollYOffset(this.props.scrollYOffset)();
} else {
top = this.options.scrollYOffset();
}
return top + 'px';
}
render() {
const top = this.scrollYOffset;
const open = this.props.menu.sideBarOpened;
const height = `calc(100vh - ${top})`;
return (
<>
<StyledStickySidebar
open={open}
className={this.props.className}
style={{ top, height }}
// tslint:disable-next-line
innerRef={el => {
this.stickyElement = el;
}}
>
{this.props.children}
</StyledStickySidebar>
<FloatingButton onClick={this.toggleNavMenu}>
<AnimatedChevronButton open={open} />
</FloatingButton>
</>
);
}
private toggleNavMenu = () => {
this.props.menu.toggleSidebar();
};
// private closeNavMenu = () => {
// this.setState({ open: false });
// };
}

View File

@ -1,76 +0,0 @@
import * as React from 'react';
import { RedocNormalizedOptions, RedocRawOptions } from '../../services/RedocNormalizedOptions';
import styled from '../../styled-components';
import { ComponentWithOptions } from '../OptionsProvider';
let Stickyfill;
if (typeof window !== 'undefined') {
Stickyfill = require('stickyfill').default;
}
export interface StickySidebarProps {
className?: string;
scrollYOffset?: RedocRawOptions['scrollYOffset']; // passed directly or via context
}
const stickyfill = Stickyfill && Stickyfill();
const StyledStickySidebar = styled.div`
width: ${props => props.theme.menu.width};
background-color: ${props => props.theme.menu.backgroundColor};
overflow: hidden;
display: flex;
flex-direction: column;
transform: translateZ(0);
height: 100vh;
position: sticky;
position: -webkit-sticky;
top: 0;
`;
export class StickySidebar extends ComponentWithOptions<StickySidebarProps> {
stickyElement: Element;
componentDidMount() {
if (stickyfill) {
stickyfill.add(this.stickyElement);
}
}
componentWillUnmount() {
if (stickyfill) {
stickyfill.remove(this.stickyElement);
}
}
get scrollYOffset() {
let top;
if (this.props.scrollYOffset !== undefined) {
top = RedocNormalizedOptions.normalizeScrollYOffset(this.props.scrollYOffset)();
} else {
top = this.options.scrollYOffset();
}
return top + 'px';
}
render() {
const top = this.scrollYOffset;
const height = `calc(100vh - ${top})`;
return (
<StyledStickySidebar
className={this.props.className}
style={{ top, height }}
// tslint:disable-next-line
innerRef={el => {
this.stickyElement = el;
}}
>
{this.props.children}
</StyledStickySidebar>
);
}
}

View File

@ -39,6 +39,11 @@ export class MenuStore {
*/ */
activeItemIdx: number = -1; activeItemIdx: number = -1;
/**
* whether sidebar with menu is opened or not
*/
@observable sideBarOpened: boolean = false;
/** /**
* cached flattened menu items to support absolute indexing * cached flattened menu items to support absolute indexing
*/ */
@ -56,6 +61,16 @@ export class MenuStore {
this._hashUnsubscribe = HistoryService.subscribe(this.updateOnHash); this._hashUnsubscribe = HistoryService.subscribe(this.updateOnHash);
} }
@action
toggleSidebar() {
this.sideBarOpened = this.sideBarOpened ? false : true;
}
@action
closeSidebar() {
this.sideBarOpened = false;
}
/** /**
* top level menu items (not flattened) * top level menu items (not flattened)
*/ */
@ -224,6 +239,9 @@ export class MenuStore {
activateAndScroll(item: IMenuItem | undefined, updateHash: boolean, rewriteHistory?: boolean) { activateAndScroll(item: IMenuItem | undefined, updateHash: boolean, rewriteHistory?: boolean) {
this.activate(item, updateHash, rewriteHistory); this.activate(item, updateHash, rewriteHistory);
this.scrollToActive(); this.scrollToActive();
if (!item || !item.items.length) {
this.closeSidebar();
}
} }
/** /**