Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Force popover contents to stay in the same position side once open #1199

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## [`master`](https://github.com/elastic/eui/tree/master)

- Added `1` as a valid value for the `columns` prop in `EuiFlexGrid` ([#1210](https://github.com/elastic/eui/pull/1210))
- Force `EuiPopover` contents to stick to its initial position when the content changes ([#1199](https://github.com/elastic/eui/pull/1199))

## [`4.2.0`](https://github.com/elastic/eui/tree/v4.2.0)

Expand Down
96 changes: 72 additions & 24 deletions src/components/popover/popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ function getElementFromInitialFocus(initialFocus) {
return initialFocus;
}

function getTransitionTimings(element) {
const computedStyle = window.getComputedStyle(element);

const computedDuration = computedStyle.getPropertyValue('transition-duration');
let durationMatch = computedDuration.match(GROUP_NUMERIC);
durationMatch = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0;

const computedDelay = computedStyle.getPropertyValue('transition-delay');
let delayMatch = computedDelay.match(GROUP_NUMERIC);
delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0;

return { durationMatch, delayMatch };
}

export class EuiPopover extends Component {
static getDerivedStateFromProps(nextProps, prevState) {
if (prevState.prevProps.isOpen && !nextProps.isOpen) {
Expand Down Expand Up @@ -122,6 +136,8 @@ export class EuiPopover extends Component {
popoverStyles: DEFAULT_POPOVER_STYLES,
arrowStyles: {},
arrowPosition: null,
openPosition: null, // once a stable position has been found, keep the contents on that side
isOpenStable: false, // wait for any initial opening transitions to finish before marking as stable
};
}

Expand Down Expand Up @@ -176,7 +192,7 @@ export class EuiPopover extends Component {
}

if (this.props.repositionOnScroll) {
window.addEventListener('scroll', this.positionPopover);
window.addEventListener('scroll', this.positionPopoverFixed);
}

this.updateFocus();
Expand All @@ -193,14 +209,37 @@ export class EuiPopover extends Component {
isOpening: true,
});
});

// for each child element of `this.panel`, find any transition duration we should wait for before stabilizing
const { durationMatch, delayMatch } = Array.prototype.slice.call(this.panel.children).reduce(
({ durationMatch, delayMatch }, element) => {
const transitionTimings = getTransitionTimings(element);

return {
durationMatch: Math.max(durationMatch, transitionTimings.durationMatch),
delayMatch: Math.max(delayMatch, transitionTimings.delayMatch),
};
},
{ durationMatch: 0, delayMatch: 0 }
);

setTimeout(
() => {
this.setState(
{ isOpenStable: true },
this.positionPopoverFixed
);
},
(durationMatch + delayMatch)
);
}

// update scroll listener
if (prevProps.repositionOnScroll !== this.props.repositionOnScroll) {
if (this.props.repositionOnScroll) {
window.addEventListener('scroll', this.positionPopover);
window.addEventListener('scroll', this.positionPopoverFixed);
} else {
window.removeEventListener('scroll', this.positionPopover);
window.removeEventListener('scroll', this.positionPopoverFixed);
}
}

Expand All @@ -219,7 +258,7 @@ export class EuiPopover extends Component {
}

componentWillUnmount() {
window.removeEventListener('scroll', this.positionPopover);
window.removeEventListener('scroll', this.positionPopoverFixed);
clearTimeout(this.closingTransitionTimeout);
}

Expand All @@ -228,31 +267,22 @@ export class EuiPopover extends Component {
(waitDuration, record) => {
// only check for CSS transition values for ELEMENT nodes
if (record.target.nodeType === document.ELEMENT_NODE) {
const computedStyle = window.getComputedStyle(record.target);

const computedDuration = computedStyle.getPropertyValue('transition-duration');
let durationMatch = computedDuration.match(GROUP_NUMERIC);
durationMatch = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0;

const computedDelay = computedStyle.getPropertyValue('transition-delay');
let delayMatch = computedDelay.match(GROUP_NUMERIC);
delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0;

const { durationMatch, delayMatch } = getTransitionTimings(record.target);
waitDuration = Math.max(waitDuration, durationMatch + delayMatch);
}

return waitDuration;
},
0
);
this.positionPopover();
this.positionPopoverFixed();

if (waitDuration > 0) {
const startTime = Date.now();
const endTime = startTime + waitDuration;

const onFrame = () => {
this.positionPopover();
this.positionPopoverFixed();

if (endTime > Date.now()) {
requestAnimationFrame(onFrame);
Expand All @@ -263,12 +293,20 @@ export class EuiPopover extends Component {
}
}

positionPopover = () => {
positionPopover = allowEnforcePosition => {
if (this.button == null || this.panel == null) return;

const { top, left, position, arrow } = findPopoverPosition({
let position = getPopoverPositionFromAnchorPosition(this.props.anchorPosition);
let forcePosition = null;
if (allowEnforcePosition && this.state.isOpenStable && this.state.openPosition != null) {
position = this.state.openPosition;
forcePosition = true;
}

const { top, left, position: foundPosition, arrow } = findPopoverPosition({
container: this.props.container,
position: getPopoverPositionFromAnchorPosition(this.props.anchorPosition),
position,
forcePosition,
align: getPopoverAlignFromAnchorPosition(this.props.anchorPosition),
anchor: this.button,
popover: this.panel,
Expand All @@ -292,9 +330,17 @@ export class EuiPopover extends Component {
};

const arrowStyles = this.props.hasArrow ? arrow : null;
const arrowPosition = position;
const arrowPosition = foundPosition;

this.setState({ popoverStyles, arrowStyles, arrowPosition, openPosition: foundPosition });
}

positionPopoverFixed = () => {
this.positionPopover(true);
}

this.setState({ popoverStyles, arrowStyles, arrowPosition });
positionPopoverFluid = () => {
this.positionPopover(false);
}

panelRef = node => {
Expand All @@ -306,12 +352,14 @@ export class EuiPopover extends Component {
popoverStyles: DEFAULT_POPOVER_STYLES,
arrowStyles: {},
arrowPosition: null,
openPosition: null,
isOpenStable: false,
});
window.removeEventListener('resize', this.positionPopover);
window.removeEventListener('resize', this.positionPopoverFluid);
} else {
// panel is coming into existence
this.positionPopover();
window.addEventListener('resize', this.positionPopover);
this.positionPopoverFluid();
window.addEventListener('resize', this.positionPopoverFluid);
}
};

Expand Down
34 changes: 23 additions & 11 deletions src/services/popover/popover_positioning.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const positionSubstitutes = {
* @param anchor {HTMLElement|React.Component} Element to anchor the popover to
* @param popover {HTMLElement|React.Component} Element containing the popover content
* @param position {string} Position the user wants. One of ["top", "right", "bottom", "left"]
* @param [forcePosition] {boolean} If true, use only the provided `position` value and don't try any other position
* @param [align] {string} Cross-axis alignment. One of ["top", "right", "bottom", "left"]
* @param [buffer=16] {number} Minimum distance between the popover and the bounding container
* @param [offset=0] {number} Distance between the popover and the anchor
Expand All @@ -49,6 +50,7 @@ export function findPopoverPosition({
popover,
align,
position,
forcePosition,
buffer = 16,
offset = 0,
allowCrossAxis = true,
Expand Down Expand Up @@ -95,17 +97,27 @@ export function findPopoverPosition({
* if position = "right" the order is right, left, top, bottom
*/

const iterationPositions = [
position, // Try the user-desired position first.
positionComplements[position], // Try the complementary position.
];
const iterationAlignments = [align, align]; // keep user-defined alignment in the original and complementary positions
if (allowCrossAxis) {
iterationPositions.push(
positionSubstitutes[position], // Switch to the cross axis.
positionComplements[positionSubstitutes[position]] // Try the complementary position on the cross axis.
);
iterationAlignments.push(null, null); // discard desired alignment on cross-axis
const iterationPositions = [position]; // Try the user-desired position first.
const iterationAlignments = [align]; // keep user-defined alignment in the original positions.

if (forcePosition !== true) {
iterationPositions.push(positionComplements[position]); // Try the complementary position.
iterationAlignments.push(align); // keep user-defined alignment in the complementary position.

if (allowCrossAxis) {
iterationPositions.push(
positionSubstitutes[position], // Switch to the cross axis.
positionComplements[positionSubstitutes[position]] // Try the complementary position on the cross axis.
);
iterationAlignments.push(null, null); // discard desired alignment on cross-axis
}
} else {
// position is forced, if it conficts with the alignment then reset align to `null`
// e.g. original placement request for `downLeft` is moved to the `left` side, future calls
// will position and align `left`, and `leftLeft` is not a valid placement
if (position === align || position === positionComplements[align]) {
iterationAlignments[0] = null;
}
}

const {
Expand Down
26 changes: 26 additions & 0 deletions src/services/popover/popover_positioning.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,32 @@ describe('popover_positioning', () => {
left: 85
});
});

it('respects forcePosition value', () => {
const anchor = document.createElement('div');
anchor.getBoundingClientRect = () => makeBB(100, 150, 120, 50);

const popover = document.createElement('div');
popover.getBoundingClientRect = () => makeBB(0, 30, 50, 0);

// give the container limited space on both left and right, forcing to top
const container = document.createElement('div');
container.getBoundingClientRect = () => makeBB(0, 160, 768, 40);

expect(findPopoverPosition({
position: 'right',
forcePosition: true,
anchor,
popover,
container,
offset: 5
})).toEqual({
fit: 0,
position: 'right',
top: 85,
left: 155
});
});
});

describe('placement falls back to second complementary position', () => {
Expand Down