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 (
+
+ );
+ }
+}
+
+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();
+ });
+ });
+ });
+});