Skip to content

Commit

Permalink
Merge pull request react-bootstrap#659 from taion/overlay-viewport
Browse files Browse the repository at this point in the history
Fit overlay within viewport boundary
  • Loading branch information
taion committed May 14, 2015
2 parents 6f73149 + 03211db commit 83d6220
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 53 deletions.
12 changes: 10 additions & 2 deletions docs/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,20 @@ body {
height: 200px;
}

.bs-example-scroll {
.bs-example-popover-contained {
height: 200px;
}

.bs-example-popover-contained > div {
position: relative;
}

.bs-example-popover-scroll {
overflow: scroll;
height: 200px;
}

.bs-example-scroll > div {
.bs-example-popover-scroll > div {
position: relative;
padding: 100px 0;
}
Expand Down
13 changes: 13 additions & 0 deletions docs/examples/PopoverContained.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const positionerInstance = (
<ButtonToolbar>
<OverlayTrigger
container={mountNode} containerPadding={20}
trigger='click' placement='bottom'
overlay={<Popover title='Popover bottom'><strong>Holy guacamole!</strong> Check this info.</Popover>}
>
<Button bsStyle='default'>Holy guacamole!</Button>
</OverlayTrigger>
</ButtonToolbar>
);

React.render(positionerInstance, mountNode);
File renamed without changes.
11 changes: 7 additions & 4 deletions docs/src/ComponentsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,14 +258,17 @@ const ComponentsPage = React.createClass({
<h1 id='popovers' className='page-header'>Popovers <small>Popover</small></h1>
<h2 id='popovers-examples'>Example popovers</h2>

<p>Popovers component.</p>
<p>Popover component.</p>
<ReactPlayground codeText={Samples.PopoverBasic} />

<p>Popovers component.</p>
<p>Positioned popover component.</p>
<ReactPlayground codeText={Samples.PopoverPositioned} />

<p>Popovers scrolling.</p>
<ReactPlayground codeText={Samples.PopoverPositionedContained} exampleClassName='bs-example-scroll' />
<p>Popover component in container.</p>
<ReactPlayground codeText={Samples.PopoverContained} exampleClassName='bs-example-popover-contained' />

<p>Positioned popover components in scrolling container.</p>
<ReactPlayground codeText={Samples.PopoverPositionedScrolling} exampleClassName='bs-example-popover-scroll' />
</div>

{/* Progress Bar */}
Expand Down
3 changes: 2 additions & 1 deletion docs/src/Samples.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export default {
TooltipInCopy: require('fs').readFileSync(__dirname + '/../examples/TooltipInCopy.js', 'utf8'),
PopoverBasic: require('fs').readFileSync(__dirname + '/../examples/PopoverBasic.js', 'utf8'),
PopoverPositioned: require('fs').readFileSync(__dirname + '/../examples/PopoverPositioned.js', 'utf8'),
PopoverPositionedContained: require('fs').readFileSync(__dirname + '/../examples/PopoverPositionedContained.js', 'utf8'),
PopoverContained: require('fs').readFileSync(__dirname + '/../examples/PopoverContained.js', 'utf8'),
PopoverPositionedScrolling: require('fs').readFileSync(__dirname + '/../examples/PopoverPositionedScrolling.js', 'utf8'),
ProgressBarBasic: require('fs').readFileSync(__dirname + '/../examples/ProgressBarBasic.js', 'utf8'),
ProgressBarWithLabel: require('fs').readFileSync(__dirname + '/../examples/ProgressBarWithLabel.js', 'utf8'),
ProgressBarScreenreaderLabel: require('fs').readFileSync(__dirname + '/../examples/ProgressBarScreenreaderLabel.js', 'utf8'),
Expand Down
158 changes: 112 additions & 46 deletions src/OverlayTrigger.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ const OverlayTrigger = React.createClass({
delayShow: React.PropTypes.number,
delayHide: React.PropTypes.number,
defaultOverlayShown: React.PropTypes.bool,
overlay: React.PropTypes.node.isRequired
overlay: React.PropTypes.node.isRequired,
containerPadding: React.PropTypes.number
},

getDefaultProps() {
return {
placement: 'right',
trigger: ['hover', 'focus']
trigger: ['hover', 'focus'],
containerPadding: 0
};
},

Expand All @@ -48,7 +50,9 @@ const OverlayTrigger = React.createClass({
isOverlayShown: this.props.defaultOverlayShown == null ?
false : this.props.defaultOverlayShown,
overlayLeft: null,
overlayTop: null
overlayTop: null,
arrowOffsetLeft: null,
arrowOffsetTop: null
};
},

Expand Down Expand Up @@ -85,18 +89,20 @@ const OverlayTrigger = React.createClass({
onRequestHide: this.hide,
placement: this.props.placement,
positionLeft: this.state.overlayLeft,
positionTop: this.state.overlayTop
positionTop: this.state.overlayTop,
arrowOffsetLeft: this.state.arrowOffsetLeft,
arrowOffsetTop: this.state.arrowOffsetTop
}
);
},

render() {
let child = React.Children.only(this.props.children);
const child = React.Children.only(this.props.children);
if (this.props.trigger === 'manual') {
return child;
}

let props = {};
const props = {};

props.onClick = createChainedFunction(child.props.onClick, this.props.onClick);
if (isOneOf('click', this.props.trigger)) {
Expand Down Expand Up @@ -136,7 +142,7 @@ const OverlayTrigger = React.createClass({
return;
}

let delay = this.props.delayShow != null ?
const delay = this.props.delayShow != null ?
this.props.delayShow : this.props.delay;

if (!delay) {
Expand All @@ -157,7 +163,7 @@ const OverlayTrigger = React.createClass({
return;
}

let delay = this.props.delayHide != null ?
const delay = this.props.delayHide != null ?
this.props.delayHide : this.props.delay;

if (!delay) {
Expand All @@ -176,52 +182,112 @@ const OverlayTrigger = React.createClass({
return;
}

let pos = this.calcOverlayPosition();

this.setState({
overlayLeft: pos.left,
overlayTop: pos.top
});
this.setState(this.calcOverlayPosition());
},

calcOverlayPosition() {
let childOffset = this.getPosition();

let overlayNode = this.getOverlayDOMNode();
let overlayHeight = overlayNode.offsetHeight;
let overlayWidth = overlayNode.offsetWidth;

switch (this.props.placement) {
case 'right':
return {
top: childOffset.top + childOffset.height / 2 - overlayHeight / 2,
left: childOffset.left + childOffset.width
};
case 'left':
return {
top: childOffset.top + childOffset.height / 2 - overlayHeight / 2,
left: childOffset.left - overlayWidth
};
case 'top':
return {
top: childOffset.top - overlayHeight,
left: childOffset.left + childOffset.width / 2 - overlayWidth / 2
};
case 'bottom':
return {
top: childOffset.top + childOffset.height,
left: childOffset.left + childOffset.width / 2 - overlayWidth / 2
};
default:
throw new Error('calcOverlayPosition(): No such placement of "' + this.props.placement + '" found.');
const childOffset = this.getPosition();

const overlayNode = this.getOverlayDOMNode();
const overlayHeight = overlayNode.offsetHeight;
const overlayWidth = overlayNode.offsetWidth;

const placement = this.props.placement;
let overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop;

if (placement === 'left' || placement === 'right') {
overlayTop = childOffset.top + (childOffset.height - overlayHeight) / 2;

if (placement === 'left') {
overlayLeft = childOffset.left - overlayWidth;
} else {
overlayLeft = childOffset.left + childOffset.width;
}

const topDelta = this._getTopDelta(overlayTop, overlayHeight);
overlayTop += topDelta;
arrowOffsetTop = 50 * (1 - 2 * topDelta / overlayHeight) + '%';
arrowOffsetLeft = null;
} else if (placement === 'top' || placement === 'bottom') {
overlayLeft = childOffset.left + (childOffset.width - overlayWidth) / 2;

if (placement === 'top') {
overlayTop = childOffset.top - overlayHeight;
} else {
overlayTop = childOffset.top + childOffset.height;
}

const leftDelta = this._getLeftDelta(overlayLeft, overlayWidth);
overlayLeft += leftDelta;
arrowOffsetLeft = 50 * (1 - 2 * leftDelta / overlayWidth) + '%';
arrowOffsetTop = null;
} else {
throw new Error(
'calcOverlayPosition(): No such placement of "' +
this.props.placement + '" found.'
);
}

return {overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop};
},

_getTopDelta(top, overlayHeight) {
const containerDimensions = this._getContainerDimensions();
const containerScroll = containerDimensions.scroll;
const containerHeight = containerDimensions.height;

const padding = this.props.containerPadding;
const topEdgeOffset = top - padding - containerScroll;
const bottomEdgeOffset = top + padding - containerScroll + overlayHeight;

if (topEdgeOffset < 0) {
return -topEdgeOffset;
} else if (bottomEdgeOffset > containerHeight) {
return containerHeight - bottomEdgeOffset;
} else {
return 0;
}
},

_getLeftDelta(left, overlayWidth) {
const containerDimensions = this._getContainerDimensions();
const containerWidth = containerDimensions.width;

const padding = this.props.containerPadding;
const leftEdgeOffset = left - padding;
const rightEdgeOffset = left + padding + overlayWidth;

if (leftEdgeOffset < 0) {
return -leftEdgeOffset;
} else if (rightEdgeOffset > containerWidth) {
return containerWidth - rightEdgeOffset;
} else {
return 0;
}
},

_getContainerDimensions() {
const containerNode = this.getContainerDOMNode();
let width, height;
if (containerNode.tagName === 'BODY') {
width = window.innerWidth;
height = window.innerHeight;
} else {
width = containerNode.offsetWidth;
height = containerNode.offsetHeight;
}

return {
width, height,
scroll: containerNode.scrollTop
};
},

getPosition() {
let node = React.findDOMNode(this);
let container = this.getContainerDOMNode();
const node = React.findDOMNode(this);
const container = this.getContainerDOMNode();

let offset = container.tagName === 'BODY' ?
const offset = container.tagName === 'BODY' ?
domUtils.getOffset(node) : domUtils.getPosition(node, container);

return assign({}, offset, {
Expand Down
97 changes: 97 additions & 0 deletions test/OverlayTriggerSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,101 @@ describe('OverlayTrigger', function() {

contextSpy.calledWith('value').should.be.true;
});

describe('#calcOverlayPosition()', function() {
[
{
placement: 'left',
noOffset: [50, 300, null, '50%'],
offsetBefore: [-200, 150, null, '0%'],
offsetAfter: [300, 450, null, '100%']
},
{
placement: 'top',
noOffset: [200, 150, '50%', null],
offsetBefore: [50, -100, '0%', null],
offsetAfter: [350, 400, '100%', null]
},
{
placement: 'bottom',
noOffset: [200, 450, '50%', null],
offsetBefore: [50, 200, '0%', null],
offsetAfter: [350, 700, '100%', null]
},
{
placement: 'right',
noOffset: [350, 300, null, '50%'],
offsetBefore: [100, 150, null, '0%'],
offsetAfter: [600, 450, null, '100%']
}
].forEach(function(testCase) {
describe(`placement = ${testCase.placement}`, function() {
let instance;

beforeEach(function() {
instance = ReactTestUtils.renderIntoDocument(
<OverlayTrigger
placement={testCase.placement}
containerPadding={50}
overlay={<div>test</div>}
>
<button>button</button>
</OverlayTrigger>
);

instance.getOverlayDOMNode = sinon.stub().returns({
offsetHeight: 200, offsetWidth: 200
});
instance._getContainerDimensions = sinon.stub().returns({
width: 600, height: 600, scroll: 100
});
});

function checkPosition(expected) {
const [
overlayLeft,
overlayTop,
arrowOffsetLeft,
arrowOffsetTop
] = expected;

it('Should calculate the correct position', function() {
instance.calcOverlayPosition().should.eql(
{overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop}
);
});
}

describe('no viewport offset', function() {
beforeEach(function() {
instance.getPosition = sinon.stub().returns({
left: 250, top: 350, width: 100, height: 100
});
});

checkPosition(testCase.noOffset);
});

describe('viewport offset before', function() {
beforeEach(function() {
instance.getPosition = sinon.stub().returns({
left: 0, top: 100, width: 100, height: 100
});
});

checkPosition(testCase.offsetBefore);
});

describe('viewport offset after', function() {
beforeEach(function() {
instance.getPosition = sinon.stub().returns({
left: 500, top: 600, width: 100, height: 100
});
});

checkPosition(testCase.offsetAfter);
});
});
});
});
});

0 comments on commit 83d6220

Please sign in to comment.