diff --git a/examples/Affix.js b/examples/Affix.js new file mode 100644 index 00000000..07d8e692 --- /dev/null +++ b/examples/Affix.js @@ -0,0 +1,20 @@ +import React from 'react'; +import AutoAffix from 'react-overlays/lib/AutoAffix'; + +class AffixExample extends React.Component { + render() { + return ( +
+ +
+
+ I am an affixed element +
+
+
+
+ ); + } +} + +export default AffixExample; diff --git a/examples/App.js b/examples/App.js index 8f54c06c..734a7da6 100644 --- a/examples/App.js +++ b/examples/App.js @@ -5,12 +5,15 @@ import Editor from '@jquense/component-playground'; import PropTable from './PropTable'; +import AffixSource from '../webpack/example-loader!./Affix'; import ModalExample from '../webpack/example-loader!./Modal'; import OverlaySource from '../webpack/example-loader!./Overlay'; import PortalSource from '../webpack/example-loader!./Portal'; import PositionSource from '../webpack/example-loader!./Position'; import TransitionSource from '../webpack/example-loader!./Transition'; +import AffixMetadata from '../webpack/metadata-loader!react-overlays/Affix'; +import AutoAffixMetadata from '../webpack/metadata-loader!react-overlays/AutoAffix'; import PortalMetadata from '../webpack/metadata-loader!react-overlays/Portal'; import PositionMetadata from '../webpack/metadata-loader!react-overlays/Position'; import OverlayMetadata from '../webpack/metadata-loader!react-overlays/Overlay'; @@ -18,11 +21,14 @@ import ModalMetadata from '../webpack/metadata-loader!react-overlays/Modal'; import TransitionMetadata from '../webpack/metadata-loader!react-overlays/Transition'; import * as ReactOverlays from 'react-overlays'; +import getOffset from 'dom-helpers/query/offset'; import './styles.less'; import injectCss from './injectCss'; -let scope = { React, findDOMNode, Button, injectCss, ...ReactOverlays }; +let scope = { + React, findDOMNode, Button, injectCss, ...ReactOverlays, getOffset +}; const Anchor = React.createClass({ propTypes: { @@ -72,6 +78,7 @@ const Example = React.createClass({
  • Modals
  • Position
  • Overlay
  • +
  • Affixes
  • @@ -130,6 +137,22 @@ const Example = React.createClass({ metadata={OverlayMetadata} /> +
    +

    + Affixes +

    +

    +

    + + + +

    ); diff --git a/examples/PropTable.js b/examples/PropTable.js index bbb427a4..92fed67a 100644 --- a/examples/PropTable.js +++ b/examples/PropTable.js @@ -40,16 +40,17 @@ const PropTable = React.createClass({ render(){ let propsData = this.propsData; - let composes = this.props.metadata[this.props.component].composes || []; - if ( !Object.keys(propsData).length ){ return ; } + let {component, metadata} = this.props; + let composes = metadata[component].composes || []; + return (

    - Props + {component} Props { !!composes.length && [
    , {'Also accepts the same props as: '} diff --git a/examples/styles.less b/examples/styles.less index 74c71413..a0bb4f54 100644 --- a/examples/styles.less +++ b/examples/styles.less @@ -187,4 +187,8 @@ h4 a:focus .anchor-icon { button { margin-bottom: 10px; } -} \ No newline at end of file +} + +.affix-example { + height: 500px; +} diff --git a/package.json b/package.json index c57ad126..0ccafde6 100644 --- a/package.json +++ b/package.json @@ -76,9 +76,9 @@ "mt-changelog": "^0.6.1", "node-libs-browser": "^0.5.2", "raw-loader": "^0.5.1", - "react": "0.14.0", + "react": "^0.14.0", "react-addons-test-utils": "^0.14.0", - "react-bootstrap": "0.24.5-react-pre.0", + "react-bootstrap": "^0.27.3", "react-component-metadata": "^1.2.2", "react-dom": "^0.14.0", "react-hot-loader": "^1.2.7", diff --git a/src/Affix.js b/src/Affix.js new file mode 100644 index 00000000..f1e6b5cc --- /dev/null +++ b/src/Affix.js @@ -0,0 +1,213 @@ +import classNames from 'classnames'; +import getHeight from 'dom-helpers/query/height'; +import getOffset from 'dom-helpers/query/offset'; +import getOffsetParent from 'dom-helpers/query/offsetParent'; +import getScrollTop from 'dom-helpers/query/scrollTop'; +import requestAnimationFrame from 'dom-helpers/util/requestAnimationFrame'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +import addEventListener from './utils/addEventListener'; +import getDocumentHeight from './utils/getDocumentHeight'; +import ownerDocument from './utils/ownerDocument'; +import ownerWindow from './utils/ownerWindow'; + +/** + * The `` component toggles `position: fixed;` on and off, emulating + * the effect found with `position: sticky;`. + */ +class Affix extends React.Component { + constructor(props, context) { + super(props, context); + + this.state = { + affixed: 'top', + position: null, + top: null + }; + + this._needPositionUpdate = false; + } + + componentDidMount() { + this._isMounted = true; + + this._windowScrollListener = addEventListener( + ownerWindow(this), 'scroll', () => this.onWindowScroll() + ); + this._documentClickListener = addEventListener( + ownerDocument(this), 'click', () => this.onDocumentClick() + ); + + this.onUpdate(); + } + + componentWillReceiveProps() { + this._needPositionUpdate = true; + } + + componentDidUpdate() { + if (this._needPositionUpdate) { + this._needPositionUpdate = false; + this.onUpdate(); + } + } + + componentWillUnmount() { + this._isMounted = false; + + if (this._windowScrollListener) { + this._windowScrollListener.remove(); + } + if (this._documentClickListener) { + this._documentClickListener.remove(); + } + } + + onWindowScroll() { + this.onUpdate(); + } + + onDocumentClick() { + requestAnimationFrame(() => this.onUpdate()); + } + + onUpdate() { + if (!this._isMounted) { + return; + } + + const {offsetTop, viewportOffsetTop} = this.props; + const scrollTop = getScrollTop(ownerWindow(this)); + const positionTopMin = scrollTop + (viewportOffsetTop || 0); + + if (positionTopMin <= offsetTop) { + this.updateState('top', null, null); + return; + } + + if (positionTopMin > this.getPositionTopMax()) { + if (this.state.affixed === 'bottom') { + this.updateStateAtBottom(); + } else { + // Setting position away from `fixed` can change the offset parent of + // the affix, so we can't calculate the correct position until after + // we've updated its position. + this.setState({ + affixed: 'bottom', + position: 'absolute', + top: null + }, () => { + if (!this._isMounted) { + return; + } + + this.updateStateAtBottom(); + }); + } + return; + } + + this.updateState('affix', 'fixed', viewportOffsetTop); + } + + getPositionTopMax() { + const documentHeight = getDocumentHeight(ownerDocument(this)); + const height = getHeight(ReactDOM.findDOMNode(this)); + + return documentHeight - height - this.props.offsetBottom; + } + + updateState(affixed, position, top) { + if ( + affixed === this.state.affixed && + position === this.state.position && + top === this.state.top + ) { + return; + } + + this.setState({affixed, position, top}); + } + + updateStateAtBottom() { + const positionTopMax = this.getPositionTopMax(); + const offsetParent = getOffsetParent(ReactDOM.findDOMNode(this)); + const parentTop = getOffset(offsetParent).top; + + this.updateState('bottom', 'absolute', positionTopMax - parentTop); + } + + render() { + const child = React.Children.only(this.props.children); + const {className, style} = child.props; + + const {affixed, position, top} = this.state; + const positionStyle = {position, top}; + + let affixClassName; + let affixStyle; + if (affixed === 'top') { + affixClassName = this.props.topClassName; + affixStyle = this.props.topStyle; + } else if (affixed === 'bottom') { + affixClassName = this.props.bottomClassName; + affixStyle = this.props.bottomStyle; + } else { + affixClassName = this.props.affixClassName; + affixStyle = this.props.affixStyle; + } + + return React.cloneElement(child, { + className: classNames(affixClassName, className), + style: {...positionStyle, ...affixStyle, ...style} + }); + } +} + +Affix.propTypes = { + /** + * Pixels to offset from top of screen when calculating position + */ + offsetTop: React.PropTypes.number, + /** + * When affixed, pixels to offset from top of viewport + */ + viewportOffsetTop: React.PropTypes.number, + /** + * Pixels to offset from bottom of screen when calculating position + */ + offsetBottom: React.PropTypes.number, + /** + * CSS class or classes to apply when at top + */ + topClassName: React.PropTypes.string, + /** + * Style to apply when at top + */ + topStyle: React.PropTypes.object, + /** + * CSS class or classes to apply when affixed + */ + affixClassName: React.PropTypes.string, + /** + * Style to apply when affixed + */ + affixStyle: React.PropTypes.object, + /** + * CSS class or classes to apply when at bottom + */ + bottomClassName: React.PropTypes.string, + /** + * Style to apply when at bottom + */ + bottomStyle: React.PropTypes.object +}; + +Affix.defaultProps = { + offsetTop: 0, + viewportOffsetTop: null, + offsetBottom: 0 +}; + +export default Affix; diff --git a/src/AutoAffix.js b/src/AutoAffix.js new file mode 100644 index 00000000..9e59a01b --- /dev/null +++ b/src/AutoAffix.js @@ -0,0 +1,158 @@ +import getOffset from 'dom-helpers/query/offset'; +import requestAnimationFrame from 'dom-helpers/util/requestAnimationFrame'; +import React from 'react'; +import mountable from 'react-prop-types/lib/mountable'; + +import Affix from './Affix'; +import addEventListener from './utils/addEventListener'; +import getContainer from './utils/getContainer'; +import getDocumentHeight from './utils/getDocumentHeight'; +import ownerDocument from './utils/ownerDocument'; +import ownerWindow from './utils/ownerWindow'; + +/** + * The `` component wraps `` to automatically calculate + * offsets in many common cases. + */ +class AutoAffix extends React.Component { + constructor(props, context) { + super(props, context); + + this.state = { + offsetTop: null, + offsetBottom: null, + width: null + }; + } + + + componentDidMount() { + this._isMounted = true; + + this._windowScrollListener = addEventListener( + ownerWindow(this), 'scroll', () => this.onWindowScroll() + ); + this._documentClickListener = addEventListener( + ownerDocument(this), 'click', () => this.onDocumentClick() + ); + + this.onUpdate(); + } + + componentWillReceiveProps() { + this._needPositionUpdate = true; + } + + componentDidUpdate() { + if (this._needPositionUpdate) { + this._needPositionUpdate = false; + this.onUpdate(); + } + } + + componentWillUnmount() { + this._isMounted = false; + + if (this._windowScrollListener) { + this._windowScrollListener.remove(); + } + if (this._documentClickListener) { + this._documentClickListener.remove(); + } + } + + onWindowScroll() { + this.onUpdate(); + } + + onDocumentClick() { + requestAnimationFrame(() => this.onUpdate()); + } + + onUpdate() { + if (!this._isMounted) { + return; + } + + const {top: offsetTop, width} = getOffset(this.refs.positioner); + + const container = getContainer(this.props.container); + let offsetBottom; + if (container) { + const documentHeight = getDocumentHeight(ownerDocument(this)); + const {top, height} = getOffset(container); + offsetBottom = documentHeight - top - height; + } else { + offsetBottom = null; + } + + this.updateState(offsetTop, offsetBottom, width); + } + + updateState(offsetTop, offsetBottom, width) { + if ( + offsetTop === this.state.offsetTop && + offsetBottom === this.state.offsetBottom && + width === this.state.width + ) { + return; + } + + this.setState({offsetTop, offsetBottom, width}); + } + + render() { + const {container, autoWidth, viewportOffsetTop, children, ...props} = + this.props; + const {offsetTop, offsetBottom, width} = this.state; + + const effectiveOffsetTop = Math.max(offsetTop, viewportOffsetTop || 0); + + let {affixStyle, bottomStyle} = this.props; + if (autoWidth) { + affixStyle = {width, ...affixStyle}; + bottomStyle = {width, ...bottomStyle}; + } + + return ( +
    +
    + + + {children} + +
    + ); + } +} + +AutoAffix.propTypes = { + ...Affix.propTypes, + /** + * The logical container node or component for determining offset from bottom + * of viewport, or a function that returns it + */ + container: React.PropTypes.oneOfType([ + mountable, + React.PropTypes.func + ]), + /** + * Automatically set width when affixed + */ + autoWidth: React.PropTypes.bool +}; + +// This intentionally doesn't inherit default props from ``, so that the +// auto-calculated offsets can apply. +AutoAffix.defaultProps = { + autoWidth: true +}; + +export default AutoAffix; diff --git a/src/index.js b/src/index.js index 9d15b793..b7751ac2 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ - +export Affix from './Affix'; +export AutoAffix from './AutoAffix'; export Modal from './Modal'; export Overlay from './Overlay'; export Portal from './Portal'; diff --git a/src/utils/getDocumentHeight.js b/src/utils/getDocumentHeight.js new file mode 100644 index 00000000..850d809e --- /dev/null +++ b/src/utils/getDocumentHeight.js @@ -0,0 +1,13 @@ +/** + * Get the height of the document + * + * @returns {documentHeight: number} + */ +export default function (doc) { + return Math.max( + doc.documentElement.offsetHeight || 0, + doc.height || 0, + doc.body.scrollHeight || 0, + doc.body.offsetHeight || 0 + ); +} diff --git a/test/AffixSpec.js b/test/AffixSpec.js new file mode 100644 index 00000000..e4250db3 --- /dev/null +++ b/test/AffixSpec.js @@ -0,0 +1,219 @@ +import React from 'react'; +import ReactTestUtils from 'react-addons-test-utils'; +import ReactDOM from 'react-dom'; + +import Affix from '../src/Affix'; + +import { render } from './helpers'; + +describe('', () => { + let mountPoint; + + // This makes the window very tall; hopefully enough to exhibit the affix + // behavior. If this is insufficient, we should modify the Karma config to + // fix the browser window size. + class Container extends React.Component { + render() { + return ( +
    + Placeholder + + {this.props.children} +
    + ); + } + } + + class Content extends React.Component { + static renderCount; + + render() { + ++Content.renderCount; + return
    Content
    ; + } + } + + beforeEach(() => { + Content.renderCount = 0; + mountPoint = document.createElement('div'); + document.body.appendChild(mountPoint); + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(mountPoint); + document.body.removeChild(mountPoint); + window.scrollTo(0, 0); + }); + + it('should render the affix content', () => { + let instance = render(( + + + + ), mountPoint); + + const content = + ReactTestUtils.findRenderedComponentWithType(instance, Content); + expect(content).to.exist; + }); + + describe('no viewportOffsetTop', () => { + let node; + + beforeEach(() => { + const container = render(( + + + + + + ), mountPoint); + + node = ReactDOM.findDOMNode(ReactTestUtils.findRenderedComponentWithType( + container, Content + )); + }); + + it('should render correctly at top', (done) => { + window.scrollTo(0, 99); + + requestAnimationFrame(() => { + expect(node.className).to.equal('affix-top'); + expect(node.style.position).to.not.be.ok; + expect(node.style.top).to.not.be.ok; + expect(node.style.color).to.equal('red'); + done(); + }); + }); + + it('should affix correctly', (done) => { + window.scrollTo(0, 101); + requestAnimationFrame(() => { + expect(node.className).to.equal('affix'); + expect(node.style.position).to.equal('fixed'); + expect(node.style.top).to.not.be.ok; + expect(node.style.color).to.equal('white'); + done(); + }); + }); + + it('should render correctly at bottom', (done) => { + window.scrollTo(0, 20000); + requestAnimationFrame(() => { + expect(node.className).to.equal('affix-bottom'); + expect(node.style.position).to.equal('absolute'); + expect(node.style.top).to.equal('9900px'); + expect(node.style.color).to.equal('blue'); + done(); + }); + }); + }); + + describe('with viewportOffsetTop', () => { + let node; + + beforeEach(() => { + const container = render(( + + + + + + ), mountPoint); + + node = ReactDOM.findDOMNode(ReactTestUtils.findRenderedComponentWithType( + container, Content + )); + }); + + it('should render correctly at top', (done) => { + window.scrollTo(0, 49); + + requestAnimationFrame(() => { + expect(node.style.position).to.not.be.ok; + expect(node.style.top).to.not.be.ok; + done(); + }); + }); + + it('should affix correctly', (done) => { + window.scrollTo(0, 51); + requestAnimationFrame(() => { + expect(node.style.position).to.equal('fixed'); + expect(node.style.top).to.equal('50px'); + done(); + }); + }); + }); + + describe('re-rendering optimizations', () => { + beforeEach(() => { + render(( + + + + + + ), mountPoint); + }); + + it('should avoid re-rendering at top', (done) => { + expect(Content.renderCount).to.equal(1); + + window.scrollTo(0, 50); + requestAnimationFrame(() => { + expect(Content.renderCount).to.equal(1); + done(); + }); + }); + + it('should avoid re-rendering when affixed', (done) => { + expect(Content.renderCount).to.equal(1); + + window.scrollTo(0, 1000); + requestAnimationFrame(() => { + expect(Content.renderCount).to.equal(2); + + window.scrollTo(0, 2000); + requestAnimationFrame(() => { + expect(Content.renderCount).to.equal(2); + done(); + }); + }); + }); + + it('should avoid re-rendering at bottom', (done) => { + expect(Content.renderCount).to.equal(1); + + window.scrollTo(0, 15000); + requestAnimationFrame(() => { + expect(Content.renderCount).to.equal(3); + + window.scrollTo(0, 16000); + requestAnimationFrame(() => { + expect(Content.renderCount).to.equal(3); + done(); + }); + }); + }); + }); +}); diff --git a/test/AutoAffixSpec.js b/test/AutoAffixSpec.js new file mode 100644 index 00000000..cd529ab9 --- /dev/null +++ b/test/AutoAffixSpec.js @@ -0,0 +1,107 @@ +import React from 'react'; +import ReactTestUtils from 'react-addons-test-utils'; +import ReactDOM from 'react-dom'; + +import AutoAffix from '../src/AutoAffix'; + +import { render } from './helpers'; + +describe('', () => { + let mountPoint; + + // This makes the window very tall; hopefully enough to exhibit the affix + // behavior. If this is insufficient, we should modify the Karma config to + // fix the browser window size. + class Container extends React.Component { + render() { + return ( +
    + {this.props.children} +
    + ); + } + } + + class Content extends React.Component { + render() { + return
    Content
    ; + } + } + + class Fixture extends React.Component { + render() { + return ( +
    +
    + + + + +
    + ); + } + } + + beforeEach(() => { + mountPoint = document.createElement('div'); + document.body.appendChild(mountPoint); + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(mountPoint); + document.body.removeChild(mountPoint); + window.scrollTo(0, 0); + }); + + describe('affix behavior', () => { + let node; + + beforeEach(() => { + const container = render(( + + + + ), mountPoint); + + node = ReactDOM.findDOMNode(ReactTestUtils.findRenderedComponentWithType( + container, Content + )); + }); + + it('should render correctly at top', (done) => { + window.scrollTo(0, 99); + + requestAnimationFrame(() => { + expect(node.style.position).to.not.be.ok; + expect(node.style.top).to.not.be.ok; + expect(node.style.width).to.not.be.ok; + done(); + }); + }); + + it('should affix correctly', (done) => { + window.scrollTo(0, 101); + requestAnimationFrame(() => { + expect(node.style.position).to.equal('fixed'); + expect(node.style.top).to.equal('0px'); + expect(node.style.width).to.equal('200px'); + done(); + }); + }); + + it('should render correctly at bottom', (done) => { + window.scrollTo(0, 9901); + + requestAnimationFrame(() => { + expect(node.style.position).to.equal('absolute'); + expect(node.style.top).to.equal('9900px'); + expect(node.style.width).to.equal('200px'); + done(); + }); + }); + }); +});