Skip to content

Commit

Permalink
feat(MountNode): add component (#2407)
Browse files Browse the repository at this point in the history
* feat(MountNode): add component

* restore Responsive test

* test(Modal): fix test
  • Loading branch information
layershifter authored and levithomason committed Feb 18, 2018
1 parent 3f26bef commit 0c5e2f9
Show file tree
Hide file tree
Showing 25 changed files with 703 additions and 143 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { Component } from 'react'
import { Form, Grid, MountNode, Segment } from 'semantic-ui-react'

export default class MountNodeExampleMountNode extends Component {
state = { className: '' }

handleChange = (e, { value }) => this.setState({ className: value })

handleRef = node => this.setState({ node })

render() {
const { className, node } = this.state

return (
<Grid columns={2}>
<Grid.Column>
<Form>
<Form.Input
placeholder='Enter any className to apply...'
onChange={this.handleChange}
value={className}
/>
</Form>
</Grid.Column>
<Grid.Column>
<Segment>
{node && <MountNode className={className} node={node} />}
<div ref={this.handleRef}>An example node</div>
</Segment>
</Grid.Column>
</Grid>
)
}
}
16 changes: 16 additions & 0 deletions docs/app/Examples/addons/MountNode/Types/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react'

import ComponentExample from 'docs/app/Components/ComponentDoc/ComponentExample'
import ExampleSection from 'docs/app/Components/ComponentDoc/ExampleSection'

const MountNodeTypesExamples = () => (
<ExampleSection title='Types'>
<ComponentExample
title='MountNode'
description={<span>A component allows to set <code>className</code> to a DOM node.</span>}
examplePath='addons/MountNode/Types/MountNodeExampleMountNode'
/>
</ExampleSection>
)

export default MountNodeTypesExamples
10 changes: 10 additions & 0 deletions docs/app/Examples/addons/MountNode/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react'
import Types from './Types'

const MountNodeExamples = () => (
<div>
<Types />
</div>
)

export default MountNodeExamples
12 changes: 6 additions & 6 deletions gulp/tasks/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ task('clean:docs', (cb) => {
// ----------------------------------------

task('build:docs:docgen', () => src([
`${config.paths.src()}/addons/**/*.js`,
`${config.paths.src()}/behaviors/**/*.js`,
`${config.paths.src()}/elements/**/*.js`,
`${config.paths.src()}/collections/**/*.js`,
`${config.paths.src()}/modules/**/*.js`,
`${config.paths.src()}/views/**/*.js`,
`${config.paths.src()}/addons/*/*.js`,
`${config.paths.src()}/behaviors/*/*.js`,
`${config.paths.src()}/elements/*/*.js`,
`${config.paths.src()}/collections/*/*.js`,
`${config.paths.src()}/modules/*/*.js`,
`${config.paths.src()}/views/*/*.js`,
'!**/index.js',
])
// do not remove the function keyword
Expand Down
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Addons
export { default as Confirm, ConfirmProps } from './dist/commonjs/addons/Confirm';
export { default as MountNode, MountNodeProps } from './dist/commonjs/addons/MountNode';
export { default as Pagination, PaginationProps } from './dist/commonjs/addons/Pagination';
export { default as PaginationItem, PaginationItemProps } from './dist/commonjs/addons/Pagination/PaginationItem';
export { default as Portal, PortalProps } from './dist/commonjs/addons/Portal';
Expand Down
16 changes: 16 additions & 0 deletions src/addons/MountNode/MountNode.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as React from 'react';

export interface MountNodeProps {
[key: string]: any;

/** Additional classes. */
className?: string;

/** The DOM node where we will apply class names. Defaults to document.body. */
node?: HTMLElement;
}

declare class MountNode extends React.Component<MountNodeProps, {}> {
}

export default MountNode;
61 changes: 61 additions & 0 deletions src/addons/MountNode/MountNode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import PropTypes from 'prop-types'
import { Component } from 'react'

import { customPropTypes, META } from '../../lib'
import getNodeFromProps from './lib/getNodeFromProps'
import handleClassNamesChange from './lib/handleClassNamesChange'
import NodeRegistry from './lib/NodeRegistry'

const nodeRegistry = new NodeRegistry()

/**
* A component that allows to manage classNames on a DOM node in declarative manner.
*/
export default class MountNode extends Component {
static propTypes = {
/** Additional classes. */
className: PropTypes.string,

/** The DOM node where we will apply class names. Defaults to document.body. */
node: customPropTypes.domNode,
}

static _meta = {
name: 'MountNode',
type: META.TYPES.ADDON,
}

shouldComponentUpdate({ className: nextClassName }) {
const { className: currentClassName } = this.props

return nextClassName !== currentClassName
}

componentWillMount() {
const node = getNodeFromProps(this.props)

if (node) {
nodeRegistry.add(node, this)
nodeRegistry.emit(node, handleClassNamesChange)
}
}

componentDidUpdate() {
const node = getNodeFromProps(this.props)

if (node) nodeRegistry.emit(node, handleClassNamesChange)
}

componentWillUnmount() {
const node = getNodeFromProps(this.props)

if (node) {
nodeRegistry.del(node, this)
nodeRegistry.emit(node, handleClassNamesChange)
}
}

render() {
return null
}
}
1 change: 1 addition & 0 deletions src/addons/MountNode/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default, MountNodeProps } from './MountNode';
1 change: 1 addition & 0 deletions src/addons/MountNode/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default from './MountNode'
33 changes: 33 additions & 0 deletions src/addons/MountNode/lib/NodeRegistry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export default class NodeRegistry {
constructor() {
this.nodes = new Map()
}

add = (node, component) => {
if (this.nodes.has(node)) {
const set = this.nodes.get(node)

set.add(component)
return
}

this.nodes.set(node, new Set([component]))
}

del = (node, component) => {
if (!this.nodes.has(node)) return

const set = this.nodes.get(node)

if (set.size === 1) {
this.nodes.delete(node)
return
}

set.delete(component)
}

emit = (node, callback) => {
callback(node, this.nodes.get(node))
}
}
11 changes: 11 additions & 0 deletions src/addons/MountNode/lib/computeClassNames.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import _ from 'lodash/fp'

const computeClassNames = _.flow(
_.toArray,
_.map('props.className'),
_.flatMap(_.split(/\s+/)),
_.filter(_.identity),
_.uniq,
)

export default computeClassNames
8 changes: 8 additions & 0 deletions src/addons/MountNode/lib/computeClassNamesDifference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import _ from 'lodash'

const computeClassNamesDifference = (prevClassNames, currentClassNames) => [
_.difference(currentClassNames, prevClassNames),
_.difference(prevClassNames, currentClassNames),
]

export default computeClassNamesDifference
19 changes: 19 additions & 0 deletions src/addons/MountNode/lib/getNodeFromProps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import _ from 'lodash'
import { isBrowser } from '../../../lib'

/**
* Given `this.props`, return a `node` value or undefined.
*
* @param {object} props Component's props
* @return {HTMLElement|undefined}
*/
const getNodeFromProps = (props) => {
const { node } = props

if (isBrowser()) {
if (_.isNil(node)) return document.body
return node
}
}

export default getNodeFromProps
18 changes: 18 additions & 0 deletions src/addons/MountNode/lib/handleClassNamesChange.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import _ from 'lodash'

import computeClassNames from './computeClassNames'
import computeClassNamesDifference from './computeClassNamesDifference'

const prevClassNames = new Map()

const handleClassNamesChange = (node, components) => {
const currentClassNames = computeClassNames(components)
const [forAdd, forRemoval] = computeClassNamesDifference(prevClassNames.get(node), currentClassNames)

_.forEach(forAdd, className => node.classList.add(className))
_.forEach(forRemoval, className => node.classList.remove(className))

prevClassNames.set(node, currentClassNames)
}

export default handleClassNamesChange
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Addons
export { default as Confirm } from './addons/Confirm'
export { default as MountNode } from './addons/MountNode'
export { default as Pagination } from './addons/Pagination'
export { default as PaginationItem } from './addons/Pagination/PaginationItem'
export { default as Portal } from './addons/Portal'
Expand Down
12 changes: 12 additions & 0 deletions src/lib/customPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ export const as = (...args) => PropTypes.oneOfType([
PropTypes.symbol,
])(...args)

/**
* Ensure a prop is a valid DOM node.
*/
export const domNode = (props, propName) => {
// skip if prop is undefined
if (props[propName] === undefined) return
// skip if prop is valid
if (props[propName] instanceof Element) return

throw new Error(`Invalid prop "${propName}" supplied, expected a DOM node.`)
}

/**
* Similar to PropTypes.oneOf but shows closest matches.
* Word order is ignored allowing `left chevron` to match `chevron left`.
Expand Down
Loading

0 comments on commit 0c5e2f9

Please sign in to comment.