import { css, cx } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; import React, { forwardRef, HTMLAttributes, useState, useRef, useLayoutEffect, createRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useTheme2 } from '../../themes'; import { ToolbarButton } from './ToolbarButton'; export interface Props extends HTMLAttributes { className?: string; /** Determine flex-alignment of child buttons. Needed for overflow behaviour. */ alignment?: 'left' | 'right'; } const OVERFLOW_BUTTON_ID = 'overflow-button'; export const ToolbarButtonRow = forwardRef( ({ alignment = 'left', className, children, ...rest }, ref) => { // null is a valid react child so we need to filter it out to prevent unnecessary padding const childrenWithoutNull = React.Children.toArray(children).filter((child) => child !== null); const [childVisibility, setChildVisibility] = useState(Array(childrenWithoutNull.length).fill(true)); const containerRef = useRef(null); const [showOverflowItems, setShowOverflowItems] = useState(false); const overflowItemsRef = createRef(); const { overlayProps } = useOverlay( { onClose: () => setShowOverflowItems(false), isDismissable: true, isOpen: showOverflowItems }, overflowItemsRef ); const { dialogProps } = useDialog({}, overflowItemsRef); const theme = useTheme2(); const overflowButtonOrder = alignment === 'left' ? childVisibility.indexOf(false) - 1 : childVisibility.length; const styles = getStyles(theme, overflowButtonOrder, alignment); useLayoutEffect(() => { const intersectionObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.target instanceof HTMLElement && entry.target.parentNode) { const index = Array.prototype.indexOf.call(entry.target.parentNode.children, entry.target); setChildVisibility((prev) => { const newVisibility = [...prev]; newVisibility[index] = entry.isIntersecting; return newVisibility; }); } }); }, { threshold: 1, root: containerRef.current, } ); if (containerRef.current) { Array.from(containerRef.current.children).forEach((item) => { // don't observe the overflow button if (item instanceof HTMLElement && item.dataset.testid !== OVERFLOW_BUTTON_ID) { intersectionObserver.observe(item); } }); } return () => intersectionObserver.disconnect(); }, [children]); return (
{childrenWithoutNull.map((child, index) => (
{child}
))} {childVisibility.includes(false) && (
setShowOverflowItems(!showOverflowItems)} icon="ellipsis-v" iconOnly narrow /> {showOverflowItems && (
{childrenWithoutNull.map((child, index) => !childVisibility[index] && child)}
)}
)}
); } ); ToolbarButtonRow.displayName = 'ToolbarButtonRow'; const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment: Props['alignment']) => ({ overflowButton: css` order: ${overflowButtonOrder}; `, overflowItems: css` align-items: center; background-color: ${theme.colors.background.primary}; border-radius: ${theme.shape.borderRadius()}; box-shadow: ${theme.shadows.z3}; display: flex; flex-wrap: wrap; gap: ${theme.spacing(1)}; margin-top: ${theme.spacing(1)}; max-width: 80vw; padding: ${theme.spacing(0.5, 1)}; position: absolute; right: 0; top: 100%; width: max-content; z-index: ${theme.zIndex.sidemenu}; `, container: css` align-items: center; display: flex; gap: ${theme.spacing(1)}; justify-content: ${alignment === 'left' ? 'flex-start' : 'flex-end'}; min-width: 0; position: relative; `, childWrapper: css` align-items: center; display: flex; gap: ${theme.spacing(1)}; `, });