diff --git a/package.json b/package.json index 3401f9e5676..3742772540c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 62054c80636..93b852f4f8b 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index d59f7b6ec85..f350accba92 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -151,7 +151,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) { {isOpen && (
- +
extends HTMLAttributes { const ButtonSelectComponent = (props: Props) => { 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) => { onChange(item); - state.close(); + setIsOpen(false); }; return (
{value?.label || (value?.value != null ? String(value?.value) : null)} - {state.isOpen && ( -
- - - {/* - 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 - */} - - {options.map((item) => ( - onChangeInternal(item)} - active={item.value === value?.value} - ariaChecked={item.value === value?.value} - ariaLabel={item.ariaLabel || item.label} - role="menuitemradio" - /> - ))} - - - + {isOpen && ( +
+ + {/* + 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 + */} + setIsOpen(false)}> + {options.map((item) => ( + onChangeInternal(item)} + active={item.value === value?.value} + ariaChecked={item.value === value?.value} + ariaLabel={item.ariaLabel || item.label} + role="menuitemradio" + /> + ))} + +
)}
@@ -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, }), }; }; diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index 3db8ed9c69f..759c96b0dc7 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -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 && ( {/* @@ -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 */} -
-
+
-
{ReactUtils.renderOrCallToRender(overlay, {})}
+
{ReactUtils.renderOrCallToRender(overlay, { ...getFloatingProps() })}
diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index af41c8cc78b..cacd311585a 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -88,7 +88,7 @@ export function Modal(props: PropsWithChildren) { name="times" size="xl" onClick={onDismiss} - tooltip={t('grafana-ui.modal.close-tooltip', 'Close')} + aria-label={t('grafana-ui.modal.close-tooltip', 'Close')} />
diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx index 406a8594fed..1a4ebea1fec 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx @@ -15,7 +15,7 @@ const meta: Meta = { page: mdx, }, controls: { - exclude: ['onClose', 'children'], + exclude: ['children'], }, }, argTypes: { diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx index db37c8753ee..0bbe95cf852 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx @@ -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; 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(); }); diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx index d1e6e2e8e78..f45fee44831 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx @@ -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) => { - 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 && ( -
- {Boolean(title) &&
{title}
} - {closeButton && ( -
- + {isOpen && ( + +
+ + {Boolean(title) &&
{title}
} + {closeButton && ( +
+ { + setControlledVisible(false); + onClose?.(); + }} + /> +
+ )} +
+ {(typeof content === 'string' || React.isValidElement(content)) && content} + {typeof content === 'function' && content({})}
- )} -
-
- {(typeof content === 'string' || React.isValidElement(content)) && content} - {typeof content === 'function' && update && content({ update })} + {Boolean(footer) &&
{footer}
}
- {Boolean(footer) &&
{footer}
} -
+ )} ); diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx index f34b96c99f9..9bebc40b83c 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -97,7 +97,7 @@ export const ToolbarButton = forwardRef( ); return tooltip ? ( - + {body} ) : ( diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 1cc90e3cc58..dcc37ac9f32 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -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( ({ 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( 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 && ( -