diff --git a/.gitignore b/.gitignore index b59358a20f..bba62d154c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage/ dist/ docs/app/docgenInfo.json +docs/app/menuInfo.json docs/build/ dll/ node_modules/ diff --git a/docs/app/Components/ComponentDoc/ComponentControls/ComponentControls.js b/docs/app/Components/ComponentDoc/ComponentControls/ComponentControls.js index 66b72c5b5b..77f8bef4af 100644 --- a/docs/app/Components/ComponentDoc/ComponentControls/ComponentControls.js +++ b/docs/app/Components/ComponentDoc/ComponentControls/ComponentControls.js @@ -9,21 +9,19 @@ import ComponentControlsMaximize from './ComponentControlsMaximize' import ComponentControlsShowHtml from './ComponentControlsShowHtml' const ComponentControls = (props) => { - const { - anchorName, showHTML, showCode, - onCopyLink, onShowHTML, onShowCode, - visible, - } = props + const { anchorName, showHTML, showCode, onCopyLink, onShowHTML, onShowCode, visible } = props return ( - {/* Heads up! Don't remove this `div`, visible Transition applies `display: block`, - while Menu should have `display: inline-flex` - */} + {/* + Heads up! Don't remove this `div`, visible Transition applies `display: block`, + while Menu should have `display: inline-flex` + */}
( - -
- - - - - - - - - - -
-
-) - -ComponentDoc.propTypes = { - componentGroup: PropTypes.objectOf( - PropTypes.shape({ - description: PropTypes.arrayOf(PropTypes.string), - props: PropTypes.array, - }), - ), - componentName: PropTypes.string.isRequired, - description: PropTypes.arrayOf(PropTypes.string), - ghLink: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - seeItems: PropTypes.arrayOf( - PropTypes.shape({ - description: PropTypes.string, - name: PropTypes.string, - type: PropTypes.string, - }), - ).isRequired, - suiLink: PropTypes.string, +const gridStyle = { paddingBottom: '10em' } +const topRowStyle = { margin: '1em' } + +class ComponentDoc extends Component { + static childContextTypes = { + onPassed: PropTypes.func, + } + + static propTypes = { + componentGroup: PropTypes.objectOf( + PropTypes.shape({ + description: PropTypes.arrayOf(PropTypes.string), + props: PropTypes.array, + }), + ), + componentName: PropTypes.string.isRequired, + description: PropTypes.arrayOf(PropTypes.string), + ghLink: PropTypes.string.isRequired, + history: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, + seeItems: PropTypes.arrayOf( + PropTypes.shape({ + description: PropTypes.string, + name: PropTypes.string, + type: PropTypes.string, + }), + ).isRequired, + suiLink: PropTypes.string, + } + + state = {} + + getChildContext() { + return { + onPassed: this.handleExamplePassed, + } + } + + componentWillReceiveProps({ componentName: next }) { + const { componentName: current } = this.props + + if (current !== next) this.setState({ activePath: undefined }) + } + + handleExamplePassed = (e, { examplePath }) => this.setState({ activePath: examplePath }) + + handleExamplesRef = examplesRef => this.setState({ examplesRef }) + + handleSidebarItemClick = (e, { path }) => { + const { history } = this.props + const aPath = _.kebabCase(_.last(path.split('/'))) + + history.replace(`${location.pathname}#${aPath}`) + scrollToAnchor() + } + + render() { + const { componentGroup, componentName, description, ghLink, path, seeItems, suiLink } = this.props + const { activePath, examplesRef } = this.state + + return ( + + + + + + + + + + + + + + +
+ +
+
+ + + +
+
+
+ ) + } } -export default withDocInfo(ComponentDoc) +export default withDocInfo(withRouter(ComponentDoc)) diff --git a/docs/app/Components/ComponentDoc/ComponentExample/ComponentExample.js b/docs/app/Components/ComponentDoc/ComponentExample/ComponentExample.js index 2ac3448878..3a2a092bd1 100644 --- a/docs/app/Components/ComponentDoc/ComponentExample/ComponentExample.js +++ b/docs/app/Components/ComponentDoc/ComponentExample/ComponentExample.js @@ -8,7 +8,8 @@ import { html } from 'js-beautify' import copyToClipboard from 'copy-to-clipboard' import { exampleContext, repoURL, scrollToAnchor } from 'docs/app/utils' -import { Divider, Grid, Menu } from 'src' +import { Divider, Grid, Menu, Visibility } from 'src' +import { shallowEqual } from 'src/lib' import Editor from 'docs/app/Components/Editor/Editor' import ComponentControls from '../ComponentControls' import ComponentExampleTitle from './ComponentExampleTitle' @@ -40,6 +41,10 @@ const errorStyle = { * Allows toggling the the raw `code` code block. */ class ComponentExample extends Component { + static contextTypes = { + onPassed: PropTypes.func, + } + static propTypes = { children: PropTypes.node, description: PropTypes.node, @@ -52,7 +57,7 @@ class ComponentExample extends Component { } componentWillMount() { - const { examplePath } = this.props + const { examplePath, location } = this.props const sourceCode = this.getOriginalSourceCode() this.anchorName = _.kebabCase(_.last(examplePath.split('/'))) @@ -70,44 +75,69 @@ class ComponentExample extends Component { }) } + componentWillReceiveProps(nextProps) { + const isActive = nextProps.location.hash === `#${this.anchorName}` + + this.setState(() => ({ isActive })) + } + + shouldComponentUpdate(nextProps, nextState) { + return !shallowEqual(this.state, nextState) + } + setHashAndScroll = () => { - const { history } = this.props + const { history, location } = this.props + history.replace(`${location.pathname}#${this.anchorName}`) scrollToAnchor() } removeHash = () => { - const { history } = this.props + const { history, location } = this.props history.replace(location.pathname) } handleDirectLinkClick = () => { + const { location } = this.props this.setHashAndScroll() copyToClipboard(location.href) } - handleMouseEnter = () => this.setState({ controlsVisible: true }) + handleMouseMove = _.throttle(() => { + const { controlsVisible } = this.state + if (controlsVisible) return + + this.setState({ controlsVisible: true }) + }, 200, { trailing: false }) handleMouseLeave = () => this.setState({ controlsVisible: false }) handleShowCodeClick = (e) => { e.preventDefault() - const { showCode } = this.state + const { showCode, showHTML } = this.state + this.setState({ showCode: !showCode }) if (!showCode) this.setHashAndScroll() - else this.removeHash() + else if (!showHTML) this.removeHash() } handleShowHTMLClick = (e) => { e.preventDefault() - const { showHTML } = this.state + const { showCode, showHTML } = this.state + this.setState({ showHTML: !showHTML }) if (!showHTML) this.setHashAndScroll() - else this.removeHash() + else if (!showCode) this.removeHash() + } + + handlePass = () => { + const { title } = this.props + + if (title) _.invoke(this.context, 'onPassed', null, this.props) } copyJSX = () => { @@ -240,7 +270,7 @@ class ComponentExample extends Component { } setGitHubHrefs = () => { - const { examplePath } = this.props + const { examplePath, location } = this.props if (this.ghEditHref && this.ghBugHref) return @@ -271,7 +301,7 @@ class ComponentExample extends Component { `The ${componentName} does not do this`, '', '**Testcase**', - `If the docs show the issue, use: ${window.location.href}`, + `If the docs show the issue, use: ${location.href}`, 'Otherwise, fork this to get started: http://codepen.io/levithomason/pen/ZpBaJX', ].join('\n'), }, (val, key) => `${key}=${encodeURIComponent(val)}`).join('&'), @@ -373,60 +403,66 @@ class ComponentExample extends Component { render() { const { children, description, suiVersion, title } = this.props - const { controlsVisible, exampleElement, showCode, showHTML } = this.state - const exampleStyle = {} + const { controlsVisible, exampleElement, isActive, showCode, showHTML } = this.state - if (showCode || showHTML || location.hash === `#${this.anchorName}`) { - exampleStyle.boxShadow = '0 0 30px #ccc' + const exampleStyle = { + marginBottom: '1em', + boxShadow: isActive && '0 0 30px #ccc', } return ( - - - - - - - - - - - - {children && ( - - {children} + + + + + + + + + + + + {children && ( + + {children} + + )} + + + + + {exampleElement} + + + {this.renderJSX()} + {this.renderHTML()} - )} - - - - - {exampleElement} - - - {this.renderJSX()} - {this.renderHTML()} - - - + + + ) } } diff --git a/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebar.js b/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebar.js new file mode 100644 index 0000000000..7238b57a3e --- /dev/null +++ b/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebar.js @@ -0,0 +1,69 @@ +import _ from 'lodash' +import PropTypes from 'prop-types' +import React, { Component } from 'react' +import { Accordion, Menu, Sticky } from 'semantic-ui-react' + +import menuInfo from 'docs/app/menuInfo.json' +import ComponentSideBarSection from './ComponentSidebarSection' + +const sidebarStyle = { + background: '#fff', + boxShadow: '0 2px 2px rgba(0, 0, 0, 0.1)', + paddingLeft: '1em', + paddingBottom: '0.1em', + paddingTop: '0.1em', +} + +class ComponentSidebar extends Component { + static propTypes = { + activePath: PropTypes.string, + componentName: PropTypes.string, + examplesRef: PropTypes.func, + onItemClick: PropTypes.func, + } + + state = {} + + constructor(props) { + super(props) + + this.state = { sections: this.computeSections(props) } + } + + componentWillReceiveProps(nextProps) { + this.setState({ sections: this.computeSections(nextProps) }) + } + + computeSections = ({ componentName }) => _.get(menuInfo, componentName) + + handleItemClick = (e, { path }) => _.invoke(this.props, 'onItemClick', e, { path }) + + render() { + const { activePath, examplesRef } = this.props + const { sections } = this.state + + return ( + + + {_.map(sections, ({ examples, name }) => ( + + ))} + + + ) + } +} + +export default ComponentSidebar diff --git a/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebarItem.js b/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebarItem.js new file mode 100644 index 0000000000..7d61cddf02 --- /dev/null +++ b/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebarItem.js @@ -0,0 +1,28 @@ +import _ from 'lodash' +import PropTypes from 'prop-types' +import React, { Component } from 'react' +import { Menu } from 'semantic-ui-react' + +export default class ComponentSidebarItem extends Component { + static propTypes = { + active: PropTypes.bool, + onClick: PropTypes.func, + path: PropTypes.string, + title: PropTypes.string, + } + + handleClick = e => _.invoke(this.props, 'onClick', e, this.props) + + render() { + const { active, path, title } = this.props + + return ( + + ) + } +} diff --git a/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebarSection.js b/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebarSection.js new file mode 100644 index 0000000000..364f52d112 --- /dev/null +++ b/docs/app/Components/ComponentDoc/ComponentSidebar/ComponentSidebarSection.js @@ -0,0 +1,65 @@ +import _ from 'lodash' +import PropTypes from 'prop-types' +import React, { Component } from 'react' +import { Accordion, Icon, Menu } from 'semantic-ui-react' + +import { pure } from 'docs/app/HOC' +import ComponentSidebarItem from './ComponentSidebarItem' + +class ComponentSidebarSection extends Component { + static propTypes = { + activePath: PropTypes.string, + examples: PropTypes.object, + name: PropTypes.string, + onItemClick: PropTypes.func, + onTitleClick: PropTypes.func, + } + + state = {} + + componentWillReceiveProps(nextProps) { + const { activePath, examples } = nextProps + const isActiveByProps = _.find(examples, { path: activePath }) + + const didCloseByProps = this.state.isActiveByProps && !isActiveByProps + + // We allow the user to open accordions, but we close them when we scroll passed them + this.setState(prevState => ({ + isActiveByProps, + isActiveByUser: didCloseByProps ? false : prevState.isActiveByUser, + })) + } + + handleItemClick = (e, itemProps) => _.invoke(this.props, 'onItemClick', e, itemProps) + + handleTitleClick = () => this.setState(prevState => ({ isActiveByUser: !prevState.isActiveByUser })) + + render() { + const { activePath, examples, name } = this.props + const { isActiveByProps, isActiveByUser } = this.state + + const active = isActiveByUser || isActiveByProps + + return ( + + + {name} + + + + {_.map(examples, ({ title, path }) => ( + + ))} + + + ) + } +} + +export default pure(ComponentSidebarSection) diff --git a/docs/app/Components/ComponentDoc/ComponentSidebar/index.js b/docs/app/Components/ComponentDoc/ComponentSidebar/index.js new file mode 100644 index 0000000000..ecc2658e70 --- /dev/null +++ b/docs/app/Components/ComponentDoc/ComponentSidebar/index.js @@ -0,0 +1 @@ +export default from './ComponentSidebar' diff --git a/docs/app/Components/ComponentDoc/ExampleSection.js b/docs/app/Components/ComponentDoc/ExampleSection.js index 9e0141e96c..97a61909b9 100644 --- a/docs/app/Components/ComponentDoc/ExampleSection.js +++ b/docs/app/Components/ComponentDoc/ExampleSection.js @@ -4,7 +4,11 @@ import React from 'react' import { Grid, Header } from 'src' const headerStyle = { marginBottom: '1.5em' } -const sectionStyle = { background: '#fff', boxShadow: '0 2px 2px rgba(0, 0, 0, 0.1)', paddingBottom: '5em' } +const sectionStyle = { + background: '#fff', + boxShadow: '0 2px 2px rgba(0, 0, 0, 0.1)', + paddingBottom: '5em', +} const ExampleSection = ({ title, children, ...rest }) => ( diff --git a/docs/app/Components/DocsLayout.js b/docs/app/Components/DocsLayout.js index f1406a10cb..08dce22239 100644 --- a/docs/app/Components/DocsLayout.js +++ b/docs/app/Components/DocsLayout.js @@ -1,6 +1,7 @@ import AnchorJS from 'anchor-js' import PropTypes from 'prop-types' import React, { Component } from 'react' +import { withRouter } from 'react-router' import { Route } from 'react-router-dom' import Sidebar from 'docs/app/Components/Sidebar/Sidebar' @@ -12,10 +13,14 @@ const anchors = new AnchorJS({ icon: '#', }) -export default class DocsLayout extends Component { +class DocsLayout extends Component { static propTypes = { component: PropTypes.func, + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + match: PropTypes.object.isRequired, render: PropTypes.func, + sidebar: PropTypes.bool, } componentDidMount() { @@ -31,6 +36,7 @@ export default class DocsLayout extends Component { } resetPage = () => { + const { location } = this.props // only reset the page when changing routes if (this.pathname === location.pathname) return @@ -47,13 +53,14 @@ export default class DocsLayout extends Component { } renderChildren = (props) => { - const { component: Children, render } = this.props + const { component: Children, render, sidebar } = this.props + const mainStyle = sidebar ? style.sidebarMain : style.main if (render) return render() return (
-
+
@@ -66,3 +73,5 @@ export default class DocsLayout extends Component { return } } + +export default withRouter(DocsLayout) diff --git a/docs/app/Style.js b/docs/app/Style.js index 5403c89b2d..972ade39a3 100644 --- a/docs/app/Style.js +++ b/docs/app/Style.js @@ -17,9 +17,13 @@ style.menu = { overflowY: 'scroll', } -style.main = { +style.sidebarMain = { marginLeft: sidebarWidth, minWidth: parseInt(sidebarWidth, 10) + 300, +} + +style.main = { + ...style.sidebarMain, maxWidth: parseInt(sidebarWidth, 10) + 900, } diff --git a/docs/app/routes.js b/docs/app/routes.js index aebde0caa4..de1b8067d1 100644 --- a/docs/app/routes.js +++ b/docs/app/routes.js @@ -27,9 +27,9 @@ const Router = () => ( - + - + diff --git a/docs/app/utils/scrollToAnchor.js b/docs/app/utils/scrollToAnchor.js index 0704dc5df3..71b0d7e010 100644 --- a/docs/app/utils/scrollToAnchor.js +++ b/docs/app/utils/scrollToAnchor.js @@ -5,7 +5,7 @@ const mathSign = Math.sign || function (x) { return val > 0 ? 1 : -1 } -const scrollToAnchor = () => { +const scrollToAnchor = (lastOffsetY) => { const anchor = location.hash && document.querySelector(location.hash) const offsetY = window.scrollY || window.pageYOffset @@ -13,20 +13,15 @@ const scrollToAnchor = () => { if (!anchor) return const elementTop = Math.round(anchor.getBoundingClientRect().top) - - // scrolled to element, stop - if (elementTop === 0) return - - // hit max scroll boundaries, stop - const isScrolledToTop = offsetY === 0 - const isScrolledToBottom = offsetY + document.body.clientHeight === document.body.scrollHeight const scrollStep = Math.ceil((Math.abs(elementTop / 8))) * mathSign(elementTop) - if ((isScrolledToBottom && scrollStep > 0) || (isScrolledToTop && scrollStep < 0)) return + // if our last step was not applied, stop + // we've either hit the top, bottom, or arrived at the element + if (lastOffsetY === offsetY) return // more scrolling to do! scrollBy(0, scrollStep) - requestAnimationFrame(scrollToAnchor) + requestAnimationFrame(() => scrollToAnchor(offsetY)) } export default scrollToAnchor diff --git a/gulp/plugins/gulp-menugen.js b/gulp/plugins/gulp-menugen.js new file mode 100644 index 0000000000..4c9047a48e --- /dev/null +++ b/gulp/plugins/gulp-menugen.js @@ -0,0 +1,70 @@ +import gutil from 'gulp-util' +import _ from 'lodash' +import path from 'path' +import through from 'through2' + +import config from '../../config' +import { parseDocExample, parseDocSection } from './util' + +const examplesPath = `${config.paths.docsSrc()}/Examples/` + +const normalizeResult = result => JSON.stringify(_.mapValues( + result, + sections => _.map( + _.sortBy(sections, 'position'), + ({ examples, name }) => ({ examples, name }), + ), +), null, 2) + +export default (filename) => { + const defaultFilename = 'menuInfo.json' + const result = {} + const pluginName = 'gulp-menugen' + let finalFile + let latestFile + + function bufferContents(file, enc, cb) { + latestFile = file + + if (file.isNull()) { + cb(null, file) + return + } + + if (file.isStream()) { + cb(new gutil.PluginError(pluginName, 'Streaming is not supported')) + return + } + + try { + const relativePath = file.path.replace(examplesPath, '') + const [, component, section] = _.split(relativePath, '/') + + if (section === 'index.js') { + result[component] = parseDocExample(file.contents) + cb() + return + } + + result[component][section] = { + ...result[component][section], + ...parseDocSection(file.contents), + } + cb() + } catch (err) { + const pluginError = new gutil.PluginError(pluginName, err) + pluginError.message += `\nFile: ${file.path}.` + this.emit('error', pluginError) + } + } + + function endStream(cb) { + finalFile = latestFile.clone({ contents: false }) + finalFile.path = path.join(latestFile.base, (filename || defaultFilename)) + finalFile.contents = new Buffer(normalizeResult(result)) + this.push(finalFile) + cb() + } + + return through.obj(bufferContents, endStream) +} diff --git a/gulp/plugins/util/index.js b/gulp/plugins/util/index.js index 79f893274e..488465c50c 100644 --- a/gulp/plugins/util/index.js +++ b/gulp/plugins/util/index.js @@ -1,4 +1,6 @@ export parseDefaultValue from './parseDefaultValue' export parseDocBlock from './parseDocBlock' +export parseDocExample from './parseDocExample' +export parseDocSection from './parseDocSection' export parserCustomHandler from './parserCustomHandler' export parseType from './parseType' diff --git a/gulp/plugins/util/parseBuffer.js b/gulp/plugins/util/parseBuffer.js new file mode 100644 index 0000000000..549a678fed --- /dev/null +++ b/gulp/plugins/util/parseBuffer.js @@ -0,0 +1,11 @@ +import { parse } from 'babylon' + +const parseBuffer = buffer => parse(buffer.toString(), { + plugins: [ + 'classProperties', + 'jsx', + ], + sourceType: 'module', +}) + +export default parseBuffer diff --git a/gulp/plugins/util/parseDocExample.js b/gulp/plugins/util/parseDocExample.js new file mode 100644 index 0000000000..7add8c7246 --- /dev/null +++ b/gulp/plugins/util/parseDocExample.js @@ -0,0 +1,41 @@ +import traverse from 'babel-traverse' +import _ from 'lodash' + +import parseBuffer from './parseBuffer' + +/** + * Parses the root view of component examples and builds an object with order of sections. + * + * @param {buffer} buffer The content of a view + * @return {object} + */ +const parseDocExample = (buffer) => { + const ast = parseBuffer(buffer) + const sections = {} + let position = 0 + + traverse(ast, { + ImportDeclaration: (path) => { + const specifier = _.first(path.get('specifiers')) + + if (!specifier.isImportDefaultSpecifier()) return + + const name = _.get(specifier, 'node.local.name') + const source = _.get(path, 'node.source.value') + + if (_.startsWith(source, './')) sections[name] = {} + }, + JSXIdentifier: (path) => { + const name = _.get(path, 'node.name') + + if (_.has(sections, name)) { + position += 1 + sections[name] = { position } + } + }, + }) + + return sections +} + +export default parseDocExample diff --git a/gulp/plugins/util/parseDocSection.js b/gulp/plugins/util/parseDocSection.js new file mode 100644 index 0000000000..573680d0a0 --- /dev/null +++ b/gulp/plugins/util/parseDocSection.js @@ -0,0 +1,58 @@ +import _ from 'lodash' +import traverse from 'babel-traverse' + +import parseBuffer from './parseBuffer' + +const getJSXAttributes = jsxPath => _.map( + _.get(jsxPath, 'node.attributes'), + attr => ({ + name: _.get(attr, 'name.name'), + value: _.get(attr, 'value.value'), + }), +) + +const getAttributeValue = (attributes, name) => _.get( + _.find(attributes, { name }), + 'value', +) + +/** + * Parses the section view of component examples and builds an object with examples titles and paths. + * + * @param {buffer} buffer The content of a view + * @return {object} + */ +const parseDocSection = (buffer) => { + const ast = parseBuffer(buffer) + const examples = [] + let sectionName + + traverse(ast, { + JSXOpeningElement: (path) => { + const attributes = getJSXAttributes(path) + const name = _.get(path, 'node.name.name') + + const title = getAttributeValue(attributes, 'title') + const examplePath = getAttributeValue(attributes, 'examplePath') + + if (name === 'ExampleSection') { + sectionName = title + return + } + + if (name === 'ComponentExample' && title) { + examples.push({ + title, + path: examplePath, + }) + } + }, + }) + + return { + examples, + name: sectionName, + } +} + +export default parseDocSection diff --git a/gulp/tasks/docs.js b/gulp/tasks/docs.js index 62377068b1..f39d3883a2 100644 --- a/gulp/tasks/docs.js +++ b/gulp/tasks/docs.js @@ -8,6 +8,7 @@ import WebpackDevMiddleware from 'webpack-dev-middleware' import WebpackHotMiddleware from 'webpack-hot-middleware' import config from '../../config' +import gulpMenuGen from '../plugins/gulp-menugen' import gulpReactDocgen from '../plugins/gulp-react-docgen' const g = loadPlugins() @@ -45,6 +46,16 @@ task('build:docs:docgen', () => src([ .pipe(gulpReactDocgen()) .pipe(dest(config.paths.docsSrc()))) +task('build:docs:menugen', () => src(`${config.paths.docsSrc()}/Examples/**/index.js`) + // do not remove the function keyword + // we need 'this' scope here + .pipe(g.plumber(function handleError(err) { + log(err.toString()) + this.emit('end') + })) + .pipe(gulpMenuGen()) + .pipe(dest(config.paths.docsSrc()))) + task('build:docs:html', () => src(config.paths.docsSrc('404.html')) .pipe(dest(config.paths.docsDist()))) @@ -83,6 +94,7 @@ task('build:docs', series( 'clean:docs', parallel( 'build:docs:docgen', + 'build:docs:menugen', 'build:docs:html', 'build:docs:images', ),