aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/design-system/components/popups.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/src/main/js/design-system/components/popups.tsx')
-rw-r--r--server/sonar-web/src/main/js/design-system/components/popups.tsx233
1 files changed, 233 insertions, 0 deletions
diff --git a/server/sonar-web/src/main/js/design-system/components/popups.tsx b/server/sonar-web/src/main/js/design-system/components/popups.tsx
new file mode 100644
index 00000000000..c39bff30fbb
--- /dev/null
+++ b/server/sonar-web/src/main/js/design-system/components/popups.tsx
@@ -0,0 +1,233 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+import React, { AriaRole } from 'react';
+import { findDOMNode } from 'react-dom';
+import tw from 'twin.macro';
+import { THROTTLE_SCROLL_DELAY } from '../helpers/constants';
+import { PopupPlacement, PopupZLevel, popupPositioning } from '../helpers/positioning';
+import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme';
+import ClickEventBoundary from './ClickEventBoundary';
+
+interface PopupBaseProps {
+ 'aria-labelledby'?: string;
+ children?: React.ReactNode;
+ className?: string;
+ id?: string;
+ placement?: PopupPlacement;
+ role?: AriaRole;
+ style?: React.CSSProperties;
+ zLevel?: PopupZLevel;
+}
+
+function PopupBase(props: PopupBaseProps, ref: React.Ref<HTMLDivElement>) {
+ const {
+ children,
+ className,
+ placement = PopupPlacement.Bottom,
+ style,
+ zLevel = PopupZLevel.Default,
+ ...ariaProps
+ } = props;
+ return (
+ <ClickEventBoundary>
+ <PopupWrapper
+ className={classNames(`is-${placement}`, className)}
+ ref={ref ?? React.createRef()}
+ style={style}
+ zLevel={zLevel}
+ {...ariaProps}
+ >
+ {children}
+ </PopupWrapper>
+ </ClickEventBoundary>
+ );
+}
+
+const PopupWithRef = React.forwardRef(PopupBase);
+PopupWithRef.displayName = 'Popup';
+
+interface PopupProps extends Omit<PopupBaseProps, 'style'> {
+ allowResizing?: boolean;
+ children: React.ReactNode;
+ overlay: React.ReactNode;
+}
+
+interface Measurements {
+ height: number;
+ left: number;
+ top: number;
+ width: number;
+}
+
+type State = Partial<Measurements>;
+
+function isMeasured(state: State): state is Measurements {
+ return state.height !== undefined;
+}
+
+export class Popup extends React.PureComponent<PopupProps, State> {
+ mounted = false;
+ popupNode = React.createRef<HTMLDivElement>();
+ throttledPositionTooltip: () => void;
+
+ constructor(props: PopupProps) {
+ super(props);
+ this.state = {};
+ this.throttledPositionTooltip = throttle(this.positionPopup, THROTTLE_SCROLL_DELAY);
+ }
+
+ componentDidMount() {
+ this.positionPopup();
+ this.addEventListeners();
+ this.mounted = true;
+ }
+
+ componentDidUpdate(prevProps: PopupProps) {
+ if (this.props.placement !== prevProps.placement || this.props.overlay !== prevProps.overlay) {
+ this.positionPopup();
+ }
+ }
+
+ componentWillUnmount() {
+ this.removeEventListeners();
+ this.mounted = false;
+ }
+
+ addEventListeners = () => {
+ window.addEventListener('resize', this.throttledPositionTooltip);
+ if (this.props.zLevel !== PopupZLevel.Global) {
+ window.addEventListener('scroll', this.throttledPositionTooltip);
+ }
+ };
+
+ removeEventListeners = () => {
+ window.removeEventListener('resize', this.throttledPositionTooltip);
+ if (this.props.zLevel !== PopupZLevel.Global) {
+ window.removeEventListener('scroll', this.throttledPositionTooltip);
+ }
+ };
+
+ positionPopup = () => {
+ if (this.mounted && this.props.zLevel !== PopupZLevel.Absolute) {
+ // `findDOMNode(this)` will search for the DOM node for the current component
+ // first it will find a React.Fragment (see `render`),
+ // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children`
+ // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components
+
+ // eslint-disable-next-line react/no-find-dom-node
+ const toggleNode = findDOMNode(this);
+ if (toggleNode && toggleNode instanceof Element && this.popupNode.current) {
+ const { placement, zLevel } = this.props;
+ const isGlobal = zLevel === PopupZLevel.Global;
+ const { height, left, top, width } = popupPositioning(
+ toggleNode,
+ this.popupNode.current,
+ placement,
+ );
+
+ // save width and height (and later set in `render`) to avoid resizing the popup element,
+ // when it's placed close to the window edge
+ this.setState({
+ left: left + (isGlobal ? 0 : window.scrollX),
+ top: top + (isGlobal ? 0 : window.scrollY),
+ width,
+ height,
+ });
+ }
+ }
+ };
+
+ render() {
+ const {
+ allowResizing,
+ children,
+ overlay,
+ placement = PopupPlacement.Bottom,
+ ...popupProps
+ } = this.props;
+
+ let style: React.CSSProperties | undefined;
+ if (isMeasured(this.state)) {
+ style = { left: this.state.left, top: this.state.top };
+ if (!allowResizing) {
+ style.width = this.state.width;
+ style.height = this.state.height;
+ }
+ }
+ return (
+ <>
+ {this.props.children}
+ {this.props.overlay && (
+ <PopupWithRef placement={placement} ref={this.popupNode} style={style} {...popupProps}>
+ {overlay}
+ </PopupWithRef>
+ )}
+ </>
+ );
+ }
+}
+
+export const PopupWrapper = styled.div<{ zLevel: PopupZLevel }>`
+ position: ${({ zLevel }) => (zLevel === PopupZLevel.Global ? 'fixed' : 'absolute')};
+ background-color: ${themeColor('popup')};
+ color: ${themeContrast('popup')};
+ border: ${themeBorder('default', 'popupBorder')};
+ box-shadow: ${themeShadow('md')};
+
+ ${tw`sw-box-border`};
+ ${tw`sw-rounded-2`};
+ ${tw`sw-cursor-default`};
+ ${tw`sw-overflow-hidden`};
+ ${({ zLevel }) =>
+ ({
+ [PopupZLevel.Default]: tw`sw-z-popup`,
+ [PopupZLevel.Global]: tw`sw-z-global-popup`,
+ [PopupZLevel.Content]: tw`sw-z-content-popup`,
+ [PopupZLevel.Absolute]: tw`sw-z-global-popup`,
+ })[zLevel]};
+
+ &.is-bottom,
+ &.is-bottom-left,
+ &.is-bottom-right {
+ ${tw`sw-mt-2`};
+ }
+
+ &.is-top,
+ &.is-top-left,
+ &.is-top-right {
+ ${tw`sw--mt-2`};
+ }
+
+ &.is-left,
+ &.is-left-top,
+ &.is-left-bottom {
+ ${tw`sw--ml-2`};
+ }
+
+ &.is-right,
+ &.is-right-top,
+ &.is-right-bottom {
+ ${tw`sw-ml-2`};
+ }
+`;