Skip to content

Commit 03211db

Browse files
author
Jimmy Jia
committed
[fixed] Fit overlay within viewport boundary
For react-bootstrap#173
1 parent cb1cc99 commit 03211db

7 files changed

+241
-53
lines changed

docs/assets/style.css

+10-2
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,20 @@ body {
9898
height: 200px;
9999
}
100100

101-
.bs-example-scroll {
101+
.bs-example-popover-contained {
102+
height: 200px;
103+
}
104+
105+
.bs-example-popover-contained > div {
106+
position: relative;
107+
}
108+
109+
.bs-example-popover-scroll {
102110
overflow: scroll;
103111
height: 200px;
104112
}
105113

106-
.bs-example-scroll > div {
114+
.bs-example-popover-scroll > div {
107115
position: relative;
108116
padding: 100px 0;
109117
}

docs/examples/PopoverContained.js

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const positionerInstance = (
2+
<ButtonToolbar>
3+
<OverlayTrigger
4+
container={mountNode} containerPadding={20}
5+
trigger='click' placement='bottom'
6+
overlay={<Popover title='Popover bottom'><strong>Holy guacamole!</strong> Check this info.</Popover>}
7+
>
8+
<Button bsStyle='default'>Holy guacamole!</Button>
9+
</OverlayTrigger>
10+
</ButtonToolbar>
11+
);
12+
13+
React.render(positionerInstance, mountNode);

docs/src/ComponentsPage.js

+7-4
Original file line numberDiff line numberDiff line change
@@ -258,14 +258,17 @@ const ComponentsPage = React.createClass({
258258
<h1 id='popovers' className='page-header'>Popovers <small>Popover</small></h1>
259259
<h2 id='popovers-examples'>Example popovers</h2>
260260

261-
<p>Popovers component.</p>
261+
<p>Popover component.</p>
262262
<ReactPlayground codeText={Samples.PopoverBasic} />
263263

264-
<p>Popovers component.</p>
264+
<p>Positioned popover component.</p>
265265
<ReactPlayground codeText={Samples.PopoverPositioned} />
266266

267-
<p>Popovers scrolling.</p>
268-
<ReactPlayground codeText={Samples.PopoverPositionedContained} exampleClassName='bs-example-scroll' />
267+
<p>Popover component in container.</p>
268+
<ReactPlayground codeText={Samples.PopoverContained} exampleClassName='bs-example-popover-contained' />
269+
270+
<p>Positioned popover components in scrolling container.</p>
271+
<ReactPlayground codeText={Samples.PopoverPositionedScrolling} exampleClassName='bs-example-popover-scroll' />
269272
</div>
270273

271274
{/* Progress Bar */}

docs/src/Samples.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export default {
3838
TooltipInCopy: require('fs').readFileSync(__dirname + '/../examples/TooltipInCopy.js', 'utf8'),
3939
PopoverBasic: require('fs').readFileSync(__dirname + '/../examples/PopoverBasic.js', 'utf8'),
4040
PopoverPositioned: require('fs').readFileSync(__dirname + '/../examples/PopoverPositioned.js', 'utf8'),
41-
PopoverPositionedContained: require('fs').readFileSync(__dirname + '/../examples/PopoverPositionedContained.js', 'utf8'),
41+
PopoverContained: require('fs').readFileSync(__dirname + '/../examples/PopoverContained.js', 'utf8'),
42+
PopoverPositionedScrolling: require('fs').readFileSync(__dirname + '/../examples/PopoverPositionedScrolling.js', 'utf8'),
4243
ProgressBarBasic: require('fs').readFileSync(__dirname + '/../examples/ProgressBarBasic.js', 'utf8'),
4344
ProgressBarWithLabel: require('fs').readFileSync(__dirname + '/../examples/ProgressBarWithLabel.js', 'utf8'),
4445
ProgressBarScreenreaderLabel: require('fs').readFileSync(__dirname + '/../examples/ProgressBarScreenreaderLabel.js', 'utf8'),

src/OverlayTrigger.js

+112-46
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ const OverlayTrigger = React.createClass({
3333
delayShow: React.PropTypes.number,
3434
delayHide: React.PropTypes.number,
3535
defaultOverlayShown: React.PropTypes.bool,
36-
overlay: React.PropTypes.node.isRequired
36+
overlay: React.PropTypes.node.isRequired,
37+
containerPadding: React.PropTypes.number
3738
},
3839

3940
getDefaultProps() {
4041
return {
4142
placement: 'right',
42-
trigger: ['hover', 'focus']
43+
trigger: ['hover', 'focus'],
44+
containerPadding: 0
4345
};
4446
},
4547

@@ -48,7 +50,9 @@ const OverlayTrigger = React.createClass({
4850
isOverlayShown: this.props.defaultOverlayShown == null ?
4951
false : this.props.defaultOverlayShown,
5052
overlayLeft: null,
51-
overlayTop: null
53+
overlayTop: null,
54+
arrowOffsetLeft: null,
55+
arrowOffsetTop: null
5256
};
5357
},
5458

@@ -85,18 +89,20 @@ const OverlayTrigger = React.createClass({
8589
onRequestHide: this.hide,
8690
placement: this.props.placement,
8791
positionLeft: this.state.overlayLeft,
88-
positionTop: this.state.overlayTop
92+
positionTop: this.state.overlayTop,
93+
arrowOffsetLeft: this.state.arrowOffsetLeft,
94+
arrowOffsetTop: this.state.arrowOffsetTop
8995
}
9096
);
9197
},
9298

9399
render() {
94-
let child = React.Children.only(this.props.children);
100+
const child = React.Children.only(this.props.children);
95101
if (this.props.trigger === 'manual') {
96102
return child;
97103
}
98104

99-
let props = {};
105+
const props = {};
100106

101107
props.onClick = createChainedFunction(child.props.onClick, this.props.onClick);
102108
if (isOneOf('click', this.props.trigger)) {
@@ -136,7 +142,7 @@ const OverlayTrigger = React.createClass({
136142
return;
137143
}
138144

139-
let delay = this.props.delayShow != null ?
145+
const delay = this.props.delayShow != null ?
140146
this.props.delayShow : this.props.delay;
141147

142148
if (!delay) {
@@ -157,7 +163,7 @@ const OverlayTrigger = React.createClass({
157163
return;
158164
}
159165

160-
let delay = this.props.delayHide != null ?
166+
const delay = this.props.delayHide != null ?
161167
this.props.delayHide : this.props.delay;
162168

163169
if (!delay) {
@@ -176,52 +182,112 @@ const OverlayTrigger = React.createClass({
176182
return;
177183
}
178184

179-
let pos = this.calcOverlayPosition();
180-
181-
this.setState({
182-
overlayLeft: pos.left,
183-
overlayTop: pos.top
184-
});
185+
this.setState(this.calcOverlayPosition());
185186
},
186187

187188
calcOverlayPosition() {
188-
let childOffset = this.getPosition();
189-
190-
let overlayNode = this.getOverlayDOMNode();
191-
let overlayHeight = overlayNode.offsetHeight;
192-
let overlayWidth = overlayNode.offsetWidth;
193-
194-
switch (this.props.placement) {
195-
case 'right':
196-
return {
197-
top: childOffset.top + childOffset.height / 2 - overlayHeight / 2,
198-
left: childOffset.left + childOffset.width
199-
};
200-
case 'left':
201-
return {
202-
top: childOffset.top + childOffset.height / 2 - overlayHeight / 2,
203-
left: childOffset.left - overlayWidth
204-
};
205-
case 'top':
206-
return {
207-
top: childOffset.top - overlayHeight,
208-
left: childOffset.left + childOffset.width / 2 - overlayWidth / 2
209-
};
210-
case 'bottom':
211-
return {
212-
top: childOffset.top + childOffset.height,
213-
left: childOffset.left + childOffset.width / 2 - overlayWidth / 2
214-
};
215-
default:
216-
throw new Error('calcOverlayPosition(): No such placement of "' + this.props.placement + '" found.');
189+
const childOffset = this.getPosition();
190+
191+
const overlayNode = this.getOverlayDOMNode();
192+
const overlayHeight = overlayNode.offsetHeight;
193+
const overlayWidth = overlayNode.offsetWidth;
194+
195+
const placement = this.props.placement;
196+
let overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop;
197+
198+
if (placement === 'left' || placement === 'right') {
199+
overlayTop = childOffset.top + (childOffset.height - overlayHeight) / 2;
200+
201+
if (placement === 'left') {
202+
overlayLeft = childOffset.left - overlayWidth;
203+
} else {
204+
overlayLeft = childOffset.left + childOffset.width;
205+
}
206+
207+
const topDelta = this._getTopDelta(overlayTop, overlayHeight);
208+
overlayTop += topDelta;
209+
arrowOffsetTop = 50 * (1 - 2 * topDelta / overlayHeight) + '%';
210+
arrowOffsetLeft = null;
211+
} else if (placement === 'top' || placement === 'bottom') {
212+
overlayLeft = childOffset.left + (childOffset.width - overlayWidth) / 2;
213+
214+
if (placement === 'top') {
215+
overlayTop = childOffset.top - overlayHeight;
216+
} else {
217+
overlayTop = childOffset.top + childOffset.height;
218+
}
219+
220+
const leftDelta = this._getLeftDelta(overlayLeft, overlayWidth);
221+
overlayLeft += leftDelta;
222+
arrowOffsetLeft = 50 * (1 - 2 * leftDelta / overlayWidth) + '%';
223+
arrowOffsetTop = null;
224+
} else {
225+
throw new Error(
226+
'calcOverlayPosition(): No such placement of "' +
227+
this.props.placement + '" found.'
228+
);
229+
}
230+
231+
return {overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop};
232+
},
233+
234+
_getTopDelta(top, overlayHeight) {
235+
const containerDimensions = this._getContainerDimensions();
236+
const containerScroll = containerDimensions.scroll;
237+
const containerHeight = containerDimensions.height;
238+
239+
const padding = this.props.containerPadding;
240+
const topEdgeOffset = top - padding - containerScroll;
241+
const bottomEdgeOffset = top + padding - containerScroll + overlayHeight;
242+
243+
if (topEdgeOffset < 0) {
244+
return -topEdgeOffset;
245+
} else if (bottomEdgeOffset > containerHeight) {
246+
return containerHeight - bottomEdgeOffset;
247+
} else {
248+
return 0;
249+
}
250+
},
251+
252+
_getLeftDelta(left, overlayWidth) {
253+
const containerDimensions = this._getContainerDimensions();
254+
const containerWidth = containerDimensions.width;
255+
256+
const padding = this.props.containerPadding;
257+
const leftEdgeOffset = left - padding;
258+
const rightEdgeOffset = left + padding + overlayWidth;
259+
260+
if (leftEdgeOffset < 0) {
261+
return -leftEdgeOffset;
262+
} else if (rightEdgeOffset > containerWidth) {
263+
return containerWidth - rightEdgeOffset;
264+
} else {
265+
return 0;
266+
}
267+
},
268+
269+
_getContainerDimensions() {
270+
const containerNode = this.getContainerDOMNode();
271+
let width, height;
272+
if (containerNode.tagName === 'BODY') {
273+
width = window.innerWidth;
274+
height = window.innerHeight;
275+
} else {
276+
width = containerNode.offsetWidth;
277+
height = containerNode.offsetHeight;
217278
}
279+
280+
return {
281+
width, height,
282+
scroll: containerNode.scrollTop
283+
};
218284
},
219285

220286
getPosition() {
221-
let node = React.findDOMNode(this);
222-
let container = this.getContainerDOMNode();
287+
const node = React.findDOMNode(this);
288+
const container = this.getContainerDOMNode();
223289

224-
let offset = container.tagName === 'BODY' ?
290+
const offset = container.tagName === 'BODY' ?
225291
domUtils.getOffset(node) : domUtils.getPosition(node, container);
226292

227293
return assign({}, offset, {

test/OverlayTriggerSpec.js

+97
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,101 @@ describe('OverlayTrigger', function() {
6464

6565
contextSpy.calledWith('value').should.be.true;
6666
});
67+
68+
describe('#calcOverlayPosition()', function() {
69+
[
70+
{
71+
placement: 'left',
72+
noOffset: [50, 300, null, '50%'],
73+
offsetBefore: [-200, 150, null, '0%'],
74+
offsetAfter: [300, 450, null, '100%']
75+
},
76+
{
77+
placement: 'top',
78+
noOffset: [200, 150, '50%', null],
79+
offsetBefore: [50, -100, '0%', null],
80+
offsetAfter: [350, 400, '100%', null]
81+
},
82+
{
83+
placement: 'bottom',
84+
noOffset: [200, 450, '50%', null],
85+
offsetBefore: [50, 200, '0%', null],
86+
offsetAfter: [350, 700, '100%', null]
87+
},
88+
{
89+
placement: 'right',
90+
noOffset: [350, 300, null, '50%'],
91+
offsetBefore: [100, 150, null, '0%'],
92+
offsetAfter: [600, 450, null, '100%']
93+
}
94+
].forEach(function(testCase) {
95+
describe(`placement = ${testCase.placement}`, function() {
96+
let instance;
97+
98+
beforeEach(function() {
99+
instance = ReactTestUtils.renderIntoDocument(
100+
<OverlayTrigger
101+
placement={testCase.placement}
102+
containerPadding={50}
103+
overlay={<div>test</div>}
104+
>
105+
<button>button</button>
106+
</OverlayTrigger>
107+
);
108+
109+
instance.getOverlayDOMNode = sinon.stub().returns({
110+
offsetHeight: 200, offsetWidth: 200
111+
});
112+
instance._getContainerDimensions = sinon.stub().returns({
113+
width: 600, height: 600, scroll: 100
114+
});
115+
});
116+
117+
function checkPosition(expected) {
118+
const [
119+
overlayLeft,
120+
overlayTop,
121+
arrowOffsetLeft,
122+
arrowOffsetTop
123+
] = expected;
124+
125+
it('Should calculate the correct position', function() {
126+
instance.calcOverlayPosition().should.eql(
127+
{overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop}
128+
);
129+
});
130+
}
131+
132+
describe('no viewport offset', function() {
133+
beforeEach(function() {
134+
instance.getPosition = sinon.stub().returns({
135+
left: 250, top: 350, width: 100, height: 100
136+
});
137+
});
138+
139+
checkPosition(testCase.noOffset);
140+
});
141+
142+
describe('viewport offset before', function() {
143+
beforeEach(function() {
144+
instance.getPosition = sinon.stub().returns({
145+
left: 0, top: 100, width: 100, height: 100
146+
});
147+
});
148+
149+
checkPosition(testCase.offsetBefore);
150+
});
151+
152+
describe('viewport offset after', function() {
153+
beforeEach(function() {
154+
instance.getPosition = sinon.stub().returns({
155+
left: 500, top: 600, width: 100, height: 100
156+
});
157+
});
158+
159+
checkPosition(testCase.offsetAfter);
160+
});
161+
});
162+
});
163+
});
67164
});

0 commit comments

Comments
 (0)