Chore: remove react-popper-tooltip in favour of @floating-ui/react (#79465)

* use floating-ui instead of react-popper-tooltip in Tooltip

* remove useTheme2 usage

* remove escape handling logic in favour of useDismiss

* don't need this useEffect anymore

* convert Toggletip to use floating-ui

* use explicit version

* convert OperationInfoButton to use Toggletip

* convert nestedFolderPicker to use floating-ui

* convert Dropdown to use floating-ui and remove react-popper-tooltip

* fix Modal/Tooltip tests

* revert to old toggletip behaviour

* revert OperationInfoButton to not use Toggletip

* add mock for requestAnimationFrame

* remove requestAnimationFrame mock

* remove fakeTimers where they're not used

* use floating-ui in ButtonSelect

* Fix filters unit tests

* only attach description if label is different

* use 'fixed' strategy for Toggletip

* use stroke and strokeWidth

* set move: false to only show the tooltip if a hover event occurs

* update type for onClose
This commit is contained in:
Ashley Harrison 2024-01-03 12:42:26 +00:00 committed by GitHub
parent 9c9a055c3e
commit 9de79fb5e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 429 additions and 392 deletions

View File

@ -241,6 +241,7 @@
"@emotion/css": "11.11.2",
"@emotion/react": "11.11.1",
"@fingerprintjs/fingerprintjs": "^3.4.2",
"@floating-ui/react": "0.26.4",
"@glideapps/glide-data-grid": "^5.2.1",
"@grafana-plugins/grafana-testdata-datasource": "workspace:*",
"@grafana-plugins/parca": "workspace:*",
@ -382,7 +383,6 @@
"react-loading-skeleton": "3.3.1",
"react-moveable": "0.46.1",
"react-popper": "2.3.0",
"react-popper-tooltip": "4.4.2",
"react-redux": "8.1.3",
"react-resizable": "3.0.5",
"react-responsive-carousel": "^3.2.23",

View File

@ -49,6 +49,7 @@
"dependencies": {
"@emotion/css": "11.11.2",
"@emotion/react": "11.11.1",
"@floating-ui/react": "0.26.4",
"@grafana/data": "10.3.0-pre",
"@grafana/e2e-selectors": "10.3.0-pre",
"@grafana/faro-web-sdk": "^1.3.5",
@ -96,7 +97,6 @@
"react-inlinesvg": "3.0.2",
"react-loading-skeleton": "3.3.1",
"react-popper": "2.3.0",
"react-popper-tooltip": "4.4.2",
"react-router-dom": "5.3.3",
"react-select": "5.7.4",
"react-table": "7.8.0",

View File

@ -151,7 +151,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
{isOpen && (
<div data-testid={selectors.components.TimePicker.overlayContent}>
<div role="presentation" className={cx(modalBackdrop, styles.backdrop)} {...underlayProps} />
<FocusScope contain autoFocus>
<FocusScope contain autoFocus restoreFocus>
<section className={styles.content} ref={overlayRef} {...overlayProps} {...dialogProps}>
<TimePickerContent
timeZone={timeZone}

View File

@ -1,14 +1,20 @@
import { css } from '@emotion/css';
import { useButton } from '@react-aria/button';
import {
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import { FocusScope } from '@react-aria/focus';
import { useMenuTrigger } from '@react-aria/menu';
import { useMenuTriggerState } from '@react-stately/menu';
import React, { HTMLAttributes } from 'react';
import React, { HTMLAttributes, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
import { Menu } from '../Menu/Menu';
import { MenuItem } from '../Menu/MenuItem';
import { ToolbarButton, ToolbarButtonVariant } from '../ToolbarButton';
@ -33,53 +39,72 @@ export interface Props<T> extends HTMLAttributes<HTMLButtonElement> {
const ButtonSelectComponent = <T,>(props: Props<T>) => {
const { className, options, value, onChange, narrow, variant, ...restProps } = props;
const styles = useStyles2(getStyles);
const state = useMenuTriggerState({});
const [isOpen, setIsOpen] = useState(false);
const ref = React.useRef(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
const { buttonProps } = useButton(menuTriggerProps, ref);
// the order of middleware is important!
const middleware = [
offset(0),
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
];
const { context, refs, floatingStyles } = useFloating({
open: isOpen,
placement: 'bottom-end',
onOpenChange: setIsOpen,
middleware,
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]);
const onChangeInternal = (item: SelectableValue<T>) => {
onChange(item);
state.close();
setIsOpen(false);
};
return (
<div className={styles.wrapper}>
<ToolbarButton
className={className}
isOpen={state.isOpen}
isOpen={isOpen}
narrow={narrow}
variant={variant}
ref={ref}
{...buttonProps}
ref={refs.setReference}
{...getReferenceProps()}
{...restProps}
>
{value?.label || (value?.value != null ? String(value?.value) : null)}
</ToolbarButton>
{state.isOpen && (
<div className={styles.menuWrapper}>
<ClickOutsideWrapper onClick={state.close} parent={document} includeButtonPress={false}>
<FocusScope contain autoFocus restoreFocus>
{/*
tabIndex=-1 is needed here to support highlighting text within the menu when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<Menu tabIndex={-1} onClose={state.close} {...menuProps} autoFocus={!!menuProps.autoFocus}>
{options.map((item) => (
<MenuItem
key={`${item.value}`}
label={item.label ?? String(item.value)}
onClick={() => onChangeInternal(item)}
active={item.value === value?.value}
ariaChecked={item.value === value?.value}
ariaLabel={item.ariaLabel || item.label}
role="menuitemradio"
/>
))}
</Menu>
</FocusScope>
</ClickOutsideWrapper>
{isOpen && (
<div className={styles.menuWrapper} ref={refs.setFloating} {...getFloatingProps()} style={floatingStyles}>
<FocusScope contain autoFocus restoreFocus>
{/*
tabIndex=-1 is needed here to support highlighting text within the menu when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<Menu tabIndex={-1} onClose={() => setIsOpen(false)}>
{options.map((item) => (
<MenuItem
key={`${item.value}`}
label={item.label ?? String(item.value)}
onClick={() => onChangeInternal(item)}
active={item.value === value?.value}
ariaChecked={item.value === value?.value}
ariaLabel={item.ariaLabel || item.label}
role="menuitemradio"
/>
))}
</Menu>
</FocusScope>
</div>
)}
</div>
@ -100,10 +125,7 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'inline-flex',
}),
menuWrapper: css({
position: 'absolute',
zIndex: theme.zIndex.dropdown,
top: theme.spacing(4),
right: 0,
}),
};
};

View File

@ -1,10 +1,20 @@
import { css } from '@emotion/css';
import {
autoUpdate,
flip,
offset as floatingUIOffset,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import { FocusScope } from '@react-aria/focus';
import React, { useEffect, useRef, useState } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { CSSTransition } from 'react-transition-group';
import { ReactUtils } from '../../utils';
import { getPlacement } from '../../utils/tooltipUtils';
import { Portal } from '../Portal/Portal';
import { TooltipPlacement } from '../Tooltip/types';
@ -25,17 +35,33 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
onVisibleChange?.(show);
}, [onVisibleChange, show]);
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({
visible: show,
placement: placement,
onVisibleChange: setShow,
interactive: true,
delayHide: 0,
delayShow: 0,
offset: offset ?? [0, 8],
trigger: ['click'],
// the order of middleware is important!
const middleware = [
floatingUIOffset({
mainAxis: offset?.[0] ?? 8,
crossAxis: offset?.[1] ?? 0,
}),
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
];
const { context, refs, floatingStyles } = useFloating({
open: show,
placement: getPlacement(placement),
onOpenChange: setShow,
middleware,
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]);
const animationDuration = 150;
const animationStyles = getStyles(animationDuration);
@ -44,7 +70,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
};
const handleKeys = (event: React.KeyboardEvent) => {
if (event.key === 'Escape' || event.key === 'Tab') {
if (event.key === 'Tab') {
setShow(false);
}
};
@ -52,9 +78,10 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
return (
<>
{React.cloneElement(children, {
ref: setTriggerRef,
ref: refs.setReference,
...getReferenceProps(),
})}
{visible && (
{show && (
<Portal>
<FocusScope autoFocus restoreFocus contain>
{/*
@ -62,8 +89,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
*/}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div ref={setTooltipRef} {...getTooltipProps()} onClick={onOverlayClicked} onKeyDown={handleKeys}>
<div {...getArrowProps({ className: 'tooltip-arrow' })} />
<div ref={refs.setFloating} style={floatingStyles} onClick={onOverlayClicked} onKeyDown={handleKeys}>
<CSSTransition
nodeRef={transitionRef}
appear={true}
@ -71,7 +97,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
timeout={{ appear: animationDuration, exit: 0, enter: 0 }}
classNames={animationStyles}
>
<div ref={transitionRef}>{ReactUtils.renderOrCallToRender(overlay, {})}</div>
<div ref={transitionRef}>{ReactUtils.renderOrCallToRender(overlay, { ...getFloatingProps() })}</div>
</CSSTransition>
</div>
</FocusScope>

View File

@ -88,7 +88,7 @@ export function Modal(props: PropsWithChildren<Props>) {
name="times"
size="xl"
onClick={onDismiss}
tooltip={t('grafana-ui.modal.close-tooltip', 'Close')}
aria-label={t('grafana-ui.modal.close-tooltip', 'Close')}
/>
</div>
</div>

View File

@ -15,7 +15,7 @@ const meta: Meta<typeof Toggletip> = {
page: mdx,
},
controls: {
exclude: ['onClose', 'children'],
exclude: ['children'],
},
},
argTypes: {

View File

@ -1,4 +1,4 @@
import { act, render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
@ -48,16 +48,15 @@ describe('Toggletip', () => {
expect(await screen.findByTestId('toggletip-content')).toBeInTheDocument();
// Escape should not close the toggletip
const button = screen.getByTestId('myButton');
await userEvent.click(button);
expect(onClose).toHaveBeenCalledTimes(1);
// Close button should not close the toggletip
const closeButton = screen.getByTestId('toggletip-header-close');
expect(closeButton).toBeInTheDocument();
await userEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
// Escape should not close the toggletip
const button = screen.getByTestId('myButton');
await userEvent.click(button);
await userEvent.keyboard('{escape}');
expect(onClose).toHaveBeenCalledTimes(2);
// Either way, the toggletip should still be visible
@ -162,7 +161,7 @@ describe('Toggletip', () => {
const button = screen.getByTestId('myButton');
const afterButton = screen.getByText(afterInDom);
await userEvent.click(button);
await userEvent.tab();
const closeButton = screen.getByTestId('toggletip-header-close');
expect(closeButton).toHaveFocus();
@ -183,14 +182,7 @@ describe('Toggletip', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
// Need to use delay: null here to work with fakeTimers
// see https://github.com/testing-library/user-event/issues/833
user = userEvent.setup({ delay: null });
});
afterEach(() => {
jest.useRealTimers();
user = userEvent.setup();
});
it('should restore focus to the button that opened the toggletip when closed from within the toggletip', async () => {
@ -208,11 +200,10 @@ describe('Toggletip', () => {
const closeButton = await screen.findByTestId('toggletip-header-close');
expect(closeButton).toBeInTheDocument();
await user.click(closeButton);
act(() => {
jest.runAllTimers();
});
expect(button).toHaveFocus();
await waitFor(() => {
expect(button).toHaveFocus();
});
});
it('should NOT restore focus to the button that opened the toggletip when closed from outside the toggletip', async () => {
@ -239,9 +230,6 @@ describe('Toggletip', () => {
afterButton.focus();
await user.keyboard('{escape}');
act(() => {
jest.runAllTimers();
});
expect(afterButton).toHaveFocus();
});

View File

@ -1,12 +1,24 @@
import { css, cx } from '@emotion/css';
import {
arrow,
autoUpdate,
flip,
FloatingArrow,
FloatingFocusManager,
offset,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import { Placement } from '@popperjs/core';
import React, { useCallback, useEffect, useRef } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import React, { useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { buildTooltipTheme } from '../../utils/tooltipUtils';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import { buildTooltipTheme, getPlacement } from '../../utils/tooltipUtils';
import { IconButton } from '../IconButton/IconButton';
import { ToggletipContent } from './types';
@ -19,7 +31,7 @@ export interface ToggletipProps {
/** determine whether to show or not the close button **/
closeButton?: boolean;
/** Callback function to be called when the toggletip is closed */
onClose?: Function;
onClose?: () => void;
/** The preferred placement of the toggletip */
placement?: Placement;
/** The text or component that houses the content of the toggleltip */
@ -50,94 +62,100 @@ export const Toggletip = React.memo(
onOpen,
show,
}: ToggletipProps) => {
const arrowRef = useRef(null);
const grafanaTheme = useTheme2();
const styles = useStyles2(getStyles);
const style = styles[theme];
const contentRef = useRef(null);
const [controlledVisible, setControlledVisible] = React.useState(show);
const [controlledVisible, setControlledVisible] = useState(show);
const isOpen = show ?? controlledVisible;
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update, tooltipRef, triggerRef } =
usePopperTooltip(
{
visible: show ?? controlledVisible,
placement: placement,
interactive: true,
offset: [0, 8],
// If show is undefined, the toggletip will be shown on click
trigger: 'click',
onVisibleChange: (visible: boolean) => {
if (show === undefined) {
setControlledVisible(visible);
}
if (!visible) {
onClose?.();
} else {
onOpen?.();
}
},
},
{
strategy: 'fixed',
// the order of middleware is important!
// `arrow` should almost always be at the end
// see https://floating-ui.com/docs/arrow#order
const middleware = [
offset(8),
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
arrow({
element: arrowRef,
}),
];
const { context, refs, floatingStyles } = useFloating({
open: isOpen,
placement: getPlacement(placement),
onOpenChange: (open) => {
if (show === undefined) {
setControlledVisible(open);
}
);
const closeToggletip = useCallback(
(event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
setControlledVisible(false);
onClose?.();
if (event.target instanceof Node && tooltipRef?.contains(event.target)) {
triggerRef?.focus();
if (!open) {
onClose?.();
} else {
onOpen?.();
}
},
[onClose, tooltipRef, triggerRef]
);
middleware,
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
useEffect(() => {
if (controlledVisible) {
const handleKeyDown = (enterKey: KeyboardEvent) => {
if (enterKey.key === 'Escape') {
closeToggletip(enterKey);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
return;
}, [controlledVisible, closeToggletip]);
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]);
return (
<>
{React.cloneElement(children, {
ref: setTriggerRef,
ref: refs.setReference,
tabIndex: 0,
'aria-expanded': visible,
'aria-expanded': isOpen,
...getReferenceProps(),
})}
{visible && (
<div
data-testid="toggletip-content"
ref={setTooltipRef}
{...getTooltipProps({ className: cx(style.container, fitContent && styles.fitContent) })}
>
{Boolean(title) && <div className={style.header}>{title}</div>}
{closeButton && (
<div className={style.headerClose}>
<IconButton
tooltip="Close"
name="times"
data-testid="toggletip-header-close"
onClick={closeToggletip}
/>
{isOpen && (
<FloatingFocusManager context={context} modal={false} closeOnFocusOut={false}>
<div
data-testid="toggletip-content"
className={cx(style.container, {
[styles.fitContent]: fitContent,
})}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow
strokeWidth={0.3}
stroke={grafanaTheme.colors.border.weak}
className={style.arrow}
ref={arrowRef}
context={context}
/>
{Boolean(title) && <div className={style.header}>{title}</div>}
{closeButton && (
<div className={style.headerClose}>
<IconButton
aria-label="Close"
name="times"
data-testid="toggletip-header-close"
onClick={() => {
setControlledVisible(false);
onClose?.();
}}
/>
</div>
)}
<div className={style.body}>
{(typeof content === 'string' || React.isValidElement(content)) && content}
{typeof content === 'function' && content({})}
</div>
)}
<div ref={contentRef} {...getArrowProps({ className: style.arrow })} />
<div className={style.body}>
{(typeof content === 'string' || React.isValidElement(content)) && content}
{typeof content === 'function' && update && content({ update })}
{Boolean(footer) && <div className={style.footer}>{footer}</div>}
</div>
{Boolean(footer) && <div className={style.footer}>{footer}</div>}
</div>
</FloatingFocusManager>
)}
</>
);

View File

@ -97,7 +97,7 @@ export const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<Tooltip ref={ref} content={tooltip} placement="bottom">
{body}
</Tooltip>
) : (

View File

@ -1,11 +1,23 @@
import React, { useCallback, useEffect, useId, useState } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import {
arrow,
autoUpdate,
flip,
FloatingArrow,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
} from '@floating-ui/react';
import React, { useCallback, useId, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '../../themes/ThemeContext';
import { buildTooltipTheme } from '../../utils/tooltipUtils';
import { buildTooltipTheme, getPlacement } from '../../utils/tooltipUtils';
import { Portal } from '../Portal/Portal';
import { PopoverContent, TooltipPlacement } from './types';
@ -24,53 +36,55 @@ export interface TooltipProps {
export const Tooltip = React.forwardRef<HTMLElement, TooltipProps>(
({ children, theme, interactive, show, placement, content }, forwardedRef) => {
const arrowRef = useRef(null);
const [controlledVisible, setControlledVisible] = useState(show);
const isOpen = show ?? controlledVisible;
// the order of middleware is important!
// `arrow` should almost always be at the end
// see https://floating-ui.com/docs/arrow#order
const middleware = [
offset(8),
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
arrow({
element: arrowRef,
}),
];
const { context, refs, floatingStyles } = useFloating({
open: isOpen,
placement: getPlacement(placement),
onOpenChange: setControlledVisible,
middleware,
whileElementsMounted: autoUpdate,
});
const tooltipId = useId();
useEffect(() => {
if (controlledVisible !== false) {
const handleKeyDown = (enterKey: KeyboardEvent) => {
if (enterKey.key === 'Escape') {
setControlledVisible(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
} else {
return;
}
}, [controlledVisible]);
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update } = usePopperTooltip({
visible: show ?? controlledVisible,
placement,
interactive,
delayHide: interactive ? 100 : 0,
offset: [0, 8],
trigger: ['hover', 'focus'],
onVisibleChange: setControlledVisible,
const hover = useHover(context, {
delay: {
close: interactive ? 100 : 0,
},
move: false,
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, hover, focus]);
const contentIsFunction = typeof content === 'function';
/**
* If content is a function we need to call popper update function to make sure the tooltip is positioned correctly
* if it's close to the viewport boundary
**/
useEffect(() => {
if (update && contentIsFunction) {
update();
}
}, [visible, update, contentIsFunction]);
const styles = useStyles2(getStyles);
const style = styles[theme ?? 'info'];
const handleRef = useCallback(
(ref: HTMLElement | null) => {
setTriggerRef(ref);
refs.setReference(ref);
if (typeof forwardedRef === 'function') {
forwardedRef(ref);
@ -78,33 +92,35 @@ export const Tooltip = React.forwardRef<HTMLElement, TooltipProps>(
forwardedRef.current = ref;
}
},
[forwardedRef, setTriggerRef]
[forwardedRef, refs]
);
// if the child has a matching aria-label, this should take precedence over the tooltip content
// otherwise we end up double announcing things in e.g. IconButton
const childHasMatchingAriaLabel = 'aria-label' in children.props && children.props['aria-label'] === content;
return (
<>
{React.cloneElement(children, {
ref: handleRef,
tabIndex: 0, // tooltip trigger should be keyboard focusable
'aria-describedby': visible ? tooltipId : undefined,
'aria-describedby': !childHasMatchingAriaLabel && isOpen ? tooltipId : undefined,
...getReferenceProps(),
})}
{visible && (
{isOpen && (
<Portal>
<div
data-testid={selectors.components.Tooltip.container}
ref={setTooltipRef}
id={tooltipId}
role="tooltip"
{...getTooltipProps({ className: style.container })}
>
<div {...getArrowProps({ className: style.arrow })} />
{typeof content === 'string' && content}
{React.isValidElement(content) && React.cloneElement(content)}
{contentIsFunction &&
update &&
content({
updatePopperPosition: update,
})}
<div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
<FloatingArrow className={style.arrow} ref={arrowRef} context={context} />
<div
data-testid={selectors.components.Tooltip.container}
id={tooltipId}
role="tooltip"
className={style.container}
>
{typeof content === 'string' && content}
{React.isValidElement(content) && React.cloneElement(content)}
{contentIsFunction && content({})}
</div>
</div>
</Portal>
)}

View File

@ -1,3 +1,4 @@
import { Placement } from '@floating-ui/react';
/**
* This API allows popovers to update Popper's position when e.g. popover content changes
* updatePopperPosition is delivered to content by react-popper.
@ -9,19 +10,4 @@ export interface PopoverContentProps {
export type PopoverContent = string | React.ReactElement | ((props: PopoverContentProps) => JSX.Element);
export type TooltipPlacement =
| 'auto-start'
| 'auto'
| 'auto-end'
| 'top-start'
| 'top'
| 'top-end'
| 'right-start'
| 'right'
| 'right-end'
| 'bottom-end'
| 'bottom'
| 'bottom-start'
| 'left-end'
| 'left'
| 'left-start';
export type TooltipPlacement = Placement | 'auto' | 'auto-start' | 'auto-end';

View File

@ -1,7 +1,23 @@
import { css } from '@emotion/css';
import { Placement } from '@floating-ui/react';
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
import { TooltipPlacement } from '../components/Tooltip';
export function getPlacement(placement?: TooltipPlacement): Placement {
switch (placement) {
case 'auto':
return 'bottom';
case 'auto-start':
return 'bottom-start';
case 'auto-end':
return 'bottom-end';
default:
return placement ?? 'bottom';
}
}
export function buildTooltipTheme(
theme: GrafanaTheme2,
tooltipBg: string,
@ -11,29 +27,7 @@ export function buildTooltipTheme(
) {
return {
arrow: css({
height: '1rem',
width: '1rem',
position: 'absolute',
pointerEvents: 'none',
'&::before': {
borderStyle: 'solid',
content: '""',
display: 'block',
height: 0,
margin: 'auto',
width: 0,
},
'&::after': {
borderStyle: 'solid',
content: '""',
display: 'block',
height: 0,
margin: 'auto',
position: 'absolute',
width: 0,
},
fill: tooltipBg,
}),
container: css({
backgroundColor: tooltipBg,
@ -52,81 +46,12 @@ export function buildTooltipTheme(
pointerEvents: 'none',
},
"&[data-popper-placement*='bottom'] > div[data-popper-arrow='true']": {
left: 0,
marginTop: '-7px',
top: 0,
'&::before': {
borderColor: `transparent transparent ${toggletipBorder} transparent`,
borderWidth: '0 8px 7px 8px',
position: 'absolute',
top: '-1px',
},
'&::after': {
borderColor: `transparent transparent ${tooltipBg} transparent`,
borderWidth: '0 8px 7px 8px',
},
},
"&[data-popper-placement*='top'] > div[data-popper-arrow='true']": {
bottom: 0,
left: 0,
marginBottom: '-14px',
'&::before': {
borderColor: `${toggletipBorder} transparent transparent transparent`,
borderWidth: '7px 8px 0 7px',
position: 'absolute',
top: '1px',
},
'&::after': {
borderColor: `${tooltipBg} transparent transparent transparent`,
borderWidth: '7px 8px 0 7px',
},
},
"&[data-popper-placement*='right'] > div[data-popper-arrow='true']": {
left: 0,
marginLeft: '-10px',
'&::before': {
borderColor: `transparent ${toggletipBorder} transparent transparent`,
borderWidth: '7px 6px 7px 0',
},
'&::after': {
borderColor: `transparent ${tooltipBg} transparent transparent`,
borderWidth: '6px 7px 7px 0',
left: '2px',
top: '1px',
},
},
"&[data-popper-placement*='left'] > div[data-popper-arrow='true']": {
marginRight: '-11px',
right: 0,
'&::before': {
borderColor: `transparent transparent transparent ${toggletipBorder}`,
borderWidth: '7px 0 6px 7px',
},
'&::after': {
borderColor: `transparent transparent transparent ${tooltipBg}`,
borderWidth: '6px 0 5px 5px',
left: '1px',
top: '1px',
},
},
code: {
border: 'none',
display: 'inline',
background: colorManipulator.darken(tooltipBg, 0.1),
color: tooltipText,
whiteSpace: 'normal',
},
pre: {

View File

@ -57,7 +57,7 @@ export function AppChromeMenu({}: Props) {
classNames={animationStyles.overlay}
timeout={{ enter: animationSpeed, exit: 0 }}
>
<FocusScope contain autoFocus>
<FocusScope contain autoFocus restoreFocus>
<MegaMenu className={styles.menu} onClose={onClose} ref={ref} {...overlayProps} {...dialogProps} />
</FocusScope>
</CSSTransition>

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react';
import React, { useCallback, useId, useMemo, useState } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
@ -85,13 +85,19 @@ export function NestedFolderPicker({
const rootCollection = useSelector(rootItemsSelector);
const childrenCollections = useSelector(childrenByParentUIDSelector);
const { getTooltipProps, setTooltipRef, setTriggerRef, visible, triggerRef } = usePopperTooltip({
visible: overlayOpen,
// the order of middleware is important!
const middleware = [
flip({
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
];
const { context, refs, floatingStyles, elements } = useFloating({
open: overlayOpen,
placement: 'bottom',
interactive: true,
offset: [0, 0],
trigger: 'click',
onVisibleChange: (value: boolean) => {
onOpenChange: (value) => {
// ensure state is clean on opening the overlay
if (value) {
setSearch('');
@ -99,8 +105,15 @@ export function NestedFolderPicker({
}
setOverlayOpen(value);
},
middleware,
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]);
const handleFolderExpand = useCallback(
async (uid: string, newOpenState: boolean) => {
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
@ -209,7 +222,7 @@ export function NestedFolderPicker({
handleFolderExpand,
idPrefix: overlayId,
search,
visible,
visible: overlayOpen,
});
let label = selectedFolder.data?.title;
@ -217,14 +230,14 @@ export function NestedFolderPicker({
label = 'Dashboards';
}
if (!visible) {
if (!overlayOpen) {
return (
<Trigger
label={label}
invalid={invalid}
isLoading={selectedFolder.isLoading}
autoFocus={autoFocusButton}
ref={setTriggerRef}
ref={refs.setReference}
aria-label={
label
? t('browse-dashboards.folder-picker.accessible-label', 'Select folder: {{ label }} currently selected', {
@ -232,6 +245,7 @@ export function NestedFolderPicker({
})
: undefined
}
{...getReferenceProps()}
/>
);
}
@ -239,14 +253,13 @@ export function NestedFolderPicker({
return (
<>
<Input
ref={setTriggerRef}
ref={refs.setReference}
autoFocus
prefix={label ? <Icon name="folder" /> : null}
placeholder={label ?? t('browse-dashboards.folder-picker.search-placeholder', 'Search folders')}
value={search}
invalid={invalid}
className={styles.search}
onKeyDown={handleKeyDown}
onChange={(e) => setSearch(e.currentTarget.value)}
aria-autocomplete="list"
aria-expanded
@ -256,16 +269,18 @@ export function NestedFolderPicker({
aria-activedescendant={getDOMId(overlayId, flatTree[focusedItemIndex]?.item.uid)}
role="combobox"
suffix={<Icon name="search" />}
{...getReferenceProps()}
onKeyDown={handleKeyDown}
/>
<fieldset
ref={setTooltipRef}
ref={refs.setFloating}
id={overlayId}
{...getTooltipProps({
className: styles.tableWrapper,
style: {
width: triggerRef?.clientWidth,
},
})}
className={styles.tableWrapper}
style={{
...floatingStyles,
width: elements.domReference?.clientWidth,
}}
{...getFloatingProps()}
>
{error ? (
<Alert

View File

@ -33,14 +33,9 @@ function renderTraceViewContainer(frames = [frameOld]) {
describe('TraceViewContainer', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
// Need to use delay: null here to work with fakeTimers
// see https://github.com/testing-library/user-event/issues/833
user = userEvent.setup({ delay: null });
});
afterEach(() => {
jest.useRealTimers();
user = userEvent.setup();
});
it('toggles children visibility', async () => {

View File

@ -109,8 +109,9 @@ const addFilter = async (
await waitFor(() => expect(screen.getByText(property)).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Property')).toBeInTheDocument());
const operationSelect = await screen.getAllByText('=');
selectOptionInTest(operationSelect[index], operationLabel);
const operationSelect = await screen.findAllByText('=');
await userEvent.click(operationSelect[index]);
await userEvent.click(screen.getByRole('menuitemradio', { name: operationLabel }));
await waitFor(() => expect(screen.getByText(operationLabel)).toBeInTheDocument());
const valueSelect = await screen.findByText('Value');

View File

@ -1,6 +1,15 @@
import { css } from '@emotion/css';
import {
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, { useState } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
import { FlexItem } from '@grafana/experimental';
@ -16,28 +25,46 @@ export interface Props {
export const OperationInfoButton = React.memo<Props>(({ def, operation }) => {
const styles = useStyles2(getStyles);
const [show, setShow] = useState(false);
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({
// the order of middleware is important!
const middleware = [
offset(16),
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
];
const { context, refs, floatingStyles } = useFloating({
open: show,
placement: 'top',
visible: show,
offset: [0, 16],
onVisibleChange: setShow,
interactive: true,
trigger: ['click'],
onOpenChange: setShow,
middleware,
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]);
return (
<>
<Button
title="Click to show description"
ref={setTriggerRef}
ref={refs.setReference}
icon="info-circle"
size="sm"
variant="secondary"
fill="text"
{...getReferenceProps()}
/>
{visible && (
{show && (
<Portal>
<div ref={setTooltipRef} {...getTooltipProps()} className={styles.docBox}>
<div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className={styles.docBox}>
<div className={styles.docBoxHeader}>
<span>{def.renderer(operation, def, '<expr>')}</span>
<FlexItem grow={1} />
@ -86,14 +113,6 @@ const getStyles = (theme: GrafanaTheme2) => {
marginBottom: theme.spacing(-1),
color: theme.colors.text.secondary,
}),
signature: css({
fontSize: theme.typography.bodySmall.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
}),
dropdown: css({
opacity: 0,
color: theme.colors.text.secondary,
}),
};
};
function getOperationDocs(def: QueryBuilderOperationDef, op: QueryBuilderOperation): string {

View File

@ -2760,6 +2760,32 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.0.3":
version: 2.0.4
resolution: "@floating-ui/react-dom@npm:2.0.4"
dependencies:
"@floating-ui/dom": "npm:^1.5.1"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 4240a718502c797fd2e174cd06dcd7321a6eda9c8966dbaf61864b9e16445e95649a59bfe7c19ee13f68c11f3693724d7970c7e618089a3d3915bd343639cfae
languageName: node
linkType: hard
"@floating-ui/react@npm:0.26.4":
version: 0.26.4
resolution: "@floating-ui/react@npm:0.26.4"
dependencies:
"@floating-ui/react-dom": "npm:^2.0.3"
"@floating-ui/utils": "npm:^0.1.5"
tabbable: "npm:^6.0.1"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: dda933ec2abee0b09360eaec889462e072967340837fd55a91ef76f78a034c57b73f8174fa9f37d62141037ab651b1e02dcd996ee12b66dcc6a8b0e8516821c9
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.1.3":
version: 0.1.4
resolution: "@floating-ui/utils@npm:0.1.4"
@ -2767,6 +2793,13 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.1.5":
version: 0.1.6
resolution: "@floating-ui/utils@npm:0.1.6"
checksum: 450ec4ecc1dd8161b1904d4e1e9d95e653cc06f79af6c3b538b79efb10541d90bcc88646ab3cdffc5b92e00c4804ca727b025d153ad285f42dbbb39aec219ec9
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:1.12.0":
version: 1.12.0
resolution: "@formatjs/ecma402-abstract@npm:1.12.0"
@ -3376,6 +3409,7 @@ __metadata:
"@babel/core": "npm:7.23.2"
"@emotion/css": "npm:11.11.2"
"@emotion/react": "npm:11.11.1"
"@floating-ui/react": "npm:0.26.4"
"@grafana/data": "npm:10.3.0-pre"
"@grafana/e2e-selectors": "npm:10.3.0-pre"
"@grafana/faro-web-sdk": "npm:^1.3.5"
@ -3481,7 +3515,6 @@ __metadata:
react-inlinesvg: "npm:3.0.2"
react-loading-skeleton: "npm:3.3.1"
react-popper: "npm:2.3.0"
react-popper-tooltip: "npm:4.4.2"
react-router-dom: "npm:5.3.3"
react-select: "npm:5.7.4"
react-select-event: "npm:^5.1.0"
@ -5203,7 +5236,7 @@ __metadata:
languageName: node
linkType: hard
"@popperjs/core@npm:2.11.8, @popperjs/core@npm:^2.11.5":
"@popperjs/core@npm:2.11.8":
version: 2.11.8
resolution: "@popperjs/core@npm:2.11.8"
checksum: ddd16090cde777aaf102940f05d0274602079a95ad9805bd20bc55dcc7c3a2ba1b99dd5c73e5cc2753c3d31250ca52a67d58059459d7d27debb983a9f552936c
@ -17328,6 +17361,7 @@ __metadata:
"@emotion/eslint-plugin": "npm:11.11.0"
"@emotion/react": "npm:11.11.1"
"@fingerprintjs/fingerprintjs": "npm:^3.4.2"
"@floating-ui/react": "npm:0.26.4"
"@glideapps/glide-data-grid": "npm:^5.2.1"
"@grafana-plugins/grafana-testdata-datasource": "workspace:*"
"@grafana-plugins/parca": "workspace:*"
@ -17594,7 +17628,6 @@ __metadata:
react-loading-skeleton: "npm:3.3.1"
react-moveable: "npm:0.46.1"
react-popper: "npm:2.3.0"
react-popper-tooltip: "npm:4.4.2"
react-redux: "npm:8.1.3"
react-refresh: "npm:0.14.0"
react-resizable: "npm:3.0.5"
@ -25743,21 +25776,7 @@ __metadata:
languageName: node
linkType: hard
"react-popper-tooltip@npm:4.4.2":
version: 4.4.2
resolution: "react-popper-tooltip@npm:4.4.2"
dependencies:
"@babel/runtime": "npm:^7.18.3"
"@popperjs/core": "npm:^2.11.5"
react-popper: "npm:^2.3.0"
peerDependencies:
react: ">=16.6.0"
react-dom: ">=16.6.0"
checksum: 87192cd6a42f2d04826da82b4a55130a0bc50ef5fbf83328e0560b47a27e363dbe5538a66b835fb0309e2f2c011e20b09029c36c8a241fb790605638f212e6aa
languageName: node
linkType: hard
"react-popper@npm:2.3.0, react-popper@npm:^2.3.0":
"react-popper@npm:2.3.0":
version: 2.3.0
resolution: "react-popper@npm:2.3.0"
dependencies:
@ -28773,6 +28792,13 @@ __metadata:
languageName: node
linkType: hard
"tabbable@npm:^6.0.1":
version: 6.2.0
resolution: "tabbable@npm:6.2.0"
checksum: 980fa73476026e99dcacfc0d6e000d41d42c8e670faf4682496d30c625495e412c4369694f2a15cf1e5252d22de3c396f2b62edbe8d60b5dadc40d09e3f2dde3
languageName: node
linkType: hard
"table@npm:^6.8.1":
version: 6.8.1
resolution: "table@npm:6.8.1"