Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eui/changelogs/upcoming/9152.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added `EuiPopover` and `EuiToolTip`'s `repositionOnScroll` to `componentDefaults`
1 change: 1 addition & 0 deletions packages/eui/cypress/support/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import './copy/select_and_copy';
import './setup/mount';
import './setup/realMount';
import './css/cssVar';
import './helpers/wait_for_position_to_settle';

// @see https://github.com/quasarframework/quasar/issues/2233#issuecomment-492975745
// @see also https://github.com/cypress-io/cypress/issues/20341
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

/// <reference path="../index.d.ts" />

/**
* A recursive helper function that polls an element's position.
*
* @param subject The Cypress subject (the DOM element).
* @param stabilityCount The number of times the position has been stable.
* @param retries The number of remaining retries.
* @param prevRect The element's rect from the previous check.
*/
export function waitForPositionToSettle(
subject: JQuery<HTMLElement>,
stabilityCount = 0,
retries = 40, // 40 * 50ms = 2s timeout
prevRect?: DOMRect
): Cypress.Chainable<JQuery<HTMLElement>> {
const STABILITY_THRESHOLD = 3; // require 3 consecutive stable checks

if (retries < 0) {
throw new Error('Position did not settle in time after 2s');
}

const currentRect = subject[0].getBoundingClientRect();

let nextStabilityCount = stabilityCount;
if (
prevRect &&
currentRect.top === prevRect.top &&
currentRect.left === prevRect.left
) {
nextStabilityCount++;
} else {
// Position changed, reset counter
nextStabilityCount = 0;
}

if (nextStabilityCount >= STABILITY_THRESHOLD) {
cy.log('Position settled');
return cy.wrap(subject);
}

return cy.wait(50, { log: false }).then(() => {
return waitForPositionToSettle(
subject,
nextStabilityCount,
retries - 1,
currentRect
);
});
}

Cypress.Commands.add(
'waitForPositionToSettle',
{ prevSubject: 'element' },
(subject: JQuery<HTMLElement>) => {
return waitForPositionToSettle(subject);
}
);
6 changes: 6 additions & 0 deletions packages/eui/cypress/support/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ declare global {
* Params: variableName - the name of the CSS variable (e.g. '--euiColorPrimary')
*/
cssVar(variableName: string): Chainable<string | null>;

/**
* Waits for an element's position to remain stable for a few consecutive checks.
* This is useful for ensuring that repositioning logic has completed after an event like a scroll.
*/
waitForPositionToSettle(): Chainable<JQuery<HTMLElement>>;
}
}
}
64 changes: 64 additions & 0 deletions packages/eui/src/components/popover/popover.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import React, {

import { EuiButton, EuiConfirmModal } from '../../components';
import { EuiPopover, EuiPopoverProps } from './popover';
import { testRepositionOnScroll } from '../../test/cypress/test_reposition_on_scroll';

const PopoverToggle: FC<{ onClick: MouseEventHandler<HTMLButtonElement> }> = ({
onClick,
Expand Down Expand Up @@ -215,6 +216,69 @@ describe('EuiPopover', () => {
});
});

describe('repositionOnScroll', () => {
const renderPopover = (props: { repositionOnScroll?: boolean }) => (
<PopoverComponent {...props}>Test popover</PopoverComponent>
);

const config = {
renderComponent: renderPopover,
componentName: 'EuiPopover' as const,
triggerSelector: '[data-test-subj="togglePopover"]',
panelSelector: '[data-test-subj="popoverPanel"]',
};

describe('is repositioned', () => {
it('when `repositionOnScroll=true`', () => {
testRepositionOnScroll({
...config,
shouldReposition: true,
propValue: true,
});
});

it('when `componentDefaults` has `repositionOnScroll=true`', () => {
testRepositionOnScroll({
...config,
shouldReposition: true,
componentDefaultValue: true,
});
});

it('when `repositionOnScroll=true` even if `componentDefaults` has `repositionOnScroll=false`', () => {
testRepositionOnScroll({
...config,
shouldReposition: true,
propValue: true,
componentDefaultValue: false,
});
});
});

describe('is not repositioned', () => {
it('when `repositionOnScroll=false` (default value)', () => {
testRepositionOnScroll({ ...config, shouldReposition: false });
});

it('when `componentDefaults` has `repositionOnScroll=false`', () => {
testRepositionOnScroll({
...config,
shouldReposition: false,
componentDefaultValue: false,
});
});

it('when `repositionOnScroll=false` even if `componentDefaults` has `repositionOnScroll=true`', () => {
testRepositionOnScroll({
...config,
shouldReposition: false,
propValue: false,
componentDefaultValue: true,
});
});
});
});

describe('repositionToCrossAxis', () => {
beforeEach(() => {
// Set a forced viewport with not enough room to render the popover vertically
Expand Down
38 changes: 23 additions & 15 deletions packages/eui/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import React, {
Ref,
RefCallback,
PropsWithChildren,
ContextType,
} from 'react';
import classNames from 'classnames';
import { focusable } from 'tabbable';
Expand Down Expand Up @@ -42,6 +43,10 @@ import {
getElementZIndex,
EuiPopoverPosition,
} from '../../services/popover';
import {
createRepositionOnScroll,
type CreateRepositionOnScrollReturnType,
} from '../../services/popover/reposition_on_scroll';

import { EuiI18n } from '../i18n';
import { EuiOutsideClickDetector } from '../outside_click_detector';
Expand All @@ -50,6 +55,7 @@ import { euiPopoverStyles } from './popover.styles';
import { EuiPopoverPanel } from './popover_panel';
import { EuiPopoverPanelProps } from './popover_panel/_popover_panel';
import { EuiPaddingSize } from '../../global_styling';
import { EuiComponentDefaultsContext } from '../provider/component_defaults';

export const popoverAnchorPosition = [
'upCenter',
Expand Down Expand Up @@ -284,6 +290,10 @@ type PropsWithDefaults = Props & {
};

export class EuiPopover extends Component<Props, State> {
static contextType = EuiComponentDefaultsContext;
declare context: ContextType<typeof EuiComponentDefaultsContext>;
private repositionOnScroll: CreateRepositionOnScrollReturnType;

static defaultProps: Partial<PropsWithDefaults> = {
isOpen: false,
ownFocus: true,
Expand Down Expand Up @@ -319,7 +329,7 @@ export class EuiPopover extends Component<Props, State> {
return null;
}

private respositionTimeout: number | undefined;
private repositionTimeout: number | undefined;
private strandedFocusTimeout: number | undefined;
private closingTransitionTimeout: number | undefined;
private closingTransitionAnimationFrame: number | undefined;
Expand All @@ -343,6 +353,12 @@ export class EuiPopover extends Component<Props, State> {
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
};

this.repositionOnScroll = createRepositionOnScroll(() => ({
repositionOnScroll: this.props.repositionOnScroll,
componentDefaults: this.context.EuiPopover,
repositionFn: this.positionPopoverFixed,
}));
}

closePopover = () => {
Expand Down Expand Up @@ -428,8 +444,8 @@ export class EuiPopover extends Component<Props, State> {
{ durationMatch: 0, delayMatch: 0 }
);

clearTimeout(this.respositionTimeout);
this.respositionTimeout = window.setTimeout(() => {
clearTimeout(this.repositionTimeout);
this.repositionTimeout = window.setTimeout(() => {
this.setState({ isOpenStable: true }, () => {
this.positionPopoverFixed();
});
Expand All @@ -445,9 +461,7 @@ export class EuiPopover extends Component<Props, State> {
});
}

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

componentDidUpdate(prevProps: Props) {
Expand All @@ -468,13 +482,7 @@ export class EuiPopover extends Component<Props, State> {
}

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

// The popover is being closed.
if (prevProps.isOpen && !this.props.isOpen) {
Expand All @@ -489,8 +497,8 @@ export class EuiPopover extends Component<Props, State> {
}

componentWillUnmount() {
window.removeEventListener('scroll', this.positionPopoverFixed, true);
clearTimeout(this.respositionTimeout);
this.repositionOnScroll.cleanup();
clearTimeout(this.repositionTimeout);
clearTimeout(this.strandedFocusTimeout);
clearTimeout(this.closingTransitionTimeout);
cancelAnimationFrame(this.closingTransitionAnimationFrame!);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type { EuiPortalProps } from '../../portal';
import type { EuiFocusTrapProps } from '../../focus_trap';
import type { EuiTablePaginationProps, EuiTableProps } from '../../table';
import type { EuiFlyoutProps } from '../../flyout';
import type { EuiPopoverProps } from '../../popover';
import type { EuiToolTipProps } from '../../tool_tip';

export type EuiComponentDefaults = {
/**
Expand Down Expand Up @@ -53,6 +55,16 @@ export type EuiComponentDefaults = {
EuiFlyoutProps,
'includeSelectorInFocusTrap' | 'includeFixedHeadersInFocusTrap'
>;
/**
* Provide a global configuration for `EuiPopover`s.
* Defaults will be inherited by every `EuiPopover`.
*/
EuiPopover?: Pick<EuiPopoverProps, 'repositionOnScroll'>;
/**
* Provide a global configuration for `EuiToolTip`s.
* Defaults will be inherited by every `EuiToolTip`.
*/
EuiToolTip?: Pick<EuiToolTipProps, 'repositionOnScroll'>;
};

// Declaring as a static const for reference integrity/reducing rerenders
Expand Down
66 changes: 66 additions & 0 deletions packages/eui/src/components/tool_tip/tool_tip.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { EuiFlyout } from '../flyout';
import { EuiModal } from '../modal';
import { EuiPopover } from '../popover';
import { EuiToolTip } from './tool_tip';
import { testRepositionOnScroll } from '../../test/cypress/test_reposition_on_scroll';

describe('EuiToolTip', () => {
it('shows the tooltip on hover and hides it on mouseout', () => {
Expand Down Expand Up @@ -216,4 +217,69 @@ describe('EuiToolTip', () => {
cy.get('[data-test-subj="popover"]').should('not.exist');
});
});

describe('repositionOnScroll', () => {
const renderTooltip = (props: { repositionOnScroll?: boolean }) => (
<EuiToolTip content="Test tooltip" data-test-subj="tooltip" {...props}>
<EuiButton data-test-subj="fixed-trigger">Fixed Button</EuiButton>
</EuiToolTip>
);

const config = {
renderComponent: renderTooltip,
componentName: 'EuiToolTip' as const,
triggerSelector: '[data-test-subj="fixed-trigger"]',
panelSelector: '[data-test-subj="tooltip"]',
};

describe('is repositioned', () => {
it('when `repositionOnScroll=true`', () => {
testRepositionOnScroll({
...config,
shouldReposition: true,
propValue: true,
});
});

it('when `componentDefaults` has `repositionOnScroll=true`', () => {
testRepositionOnScroll({
...config,
shouldReposition: true,
componentDefaultValue: true,
});
});

it('when `repositionOnScroll=true` even if `componentDefaults` has `repositionOnScroll=false`', () => {
testRepositionOnScroll({
...config,
shouldReposition: true,
propValue: true,
componentDefaultValue: false,
});
});
});

describe('is not repositioned', () => {
it('when `repositionOnScroll=false` (default value)', () => {
testRepositionOnScroll({ ...config, shouldReposition: false });
});

it('when `componentDefaults` has `repositionOnScroll=false`', () => {
testRepositionOnScroll({
...config,
shouldReposition: false,
componentDefaultValue: false,
});
});

it('when `repositionOnScroll=false` even if `componentDefaults` has `repositionOnScroll=true`', () => {
testRepositionOnScroll({
...config,
shouldReposition: false,
propValue: false,
componentDefaultValue: true,
});
});
});
});
});
Loading