diff --git a/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx b/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx index 2a7443802..a8844305f 100644 --- a/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx +++ b/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx @@ -267,6 +267,9 @@ export const AddNodeManager: React.FC = ({ nodeId={PREVIEW_NODE_ID} placement="right-start" offset={FLOATING_CANVAS_PANEL_OFFSET} + // The default AddNodePanel (Toolbox) manages its own virtualized scroll, + // so the chrome's outer scroll is disabled to avoid a redundant scrollbar. + scrollableContent={!!CustomPanel} > {CustomPanel ? ( handleNodeSelect(item)} onClose={handleClose} /> diff --git a/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/FloatingCanvasPanel.tsx b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/FloatingCanvasPanel.tsx index ab9047fc9..d31f0893d 100644 --- a/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/FloatingCanvasPanel.tsx +++ b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/FloatingCanvasPanel.tsx @@ -1,7 +1,7 @@ import type { Placement } from '@floating-ui/react'; import { ViewportPortal } from '@uipath/apollo-react/canvas/xyflow/react'; import { cn } from '@uipath/apollo-wind'; -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { useMemo } from 'react'; import { createPortal } from 'react-dom'; import { CanvasPortal } from './CanvasPortal'; @@ -11,6 +11,23 @@ import { type AnchorRect, useFloatingPosition } from './useFloatingPosition'; const PANEL_BASE_CLASS = 'text-(--canvas-foreground) bg-(--canvas-background-raised) border border-(--canvas-border-de-emp) text-sm flex flex-col transition-opacity duration-200 ease-in-out'; +/** + * Design ceiling for the floating panel height (px). Mirrors the `max-h-[600px]` + * utility in {@link PANEL_FLOATING_CLASS}; keep the two in sync. The viewport + * cap from the `size` middleware is clamped to this so the panel never exceeds + * the intended ceiling on tall viewports. + */ +const PANEL_FLOATING_MAX_HEIGHT = 600; + +/** + * Floor for the viewport-aware height cap (px). When the anchor sits hard + * against a viewport edge on a short screen, the `size` middleware can report + * an `availableHeight` of just a few px (or 0). Without a floor the panel + * would collapse to an unusable sliver with no reachable close affordance; + * the floor keeps it usable and lets `shift` reposition it into view instead. + */ +const PANEL_FLOATING_MIN_HEIGHT = 100; + const PANEL_FLOATING_CLASS = 'rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.12)] w-auto min-w-[280px] max-w-none h-auto max-h-[600px]'; @@ -55,6 +72,13 @@ export type FloatingCanvasPanelProps = { children?: ReactNode; onClose?: () => void; scrollKey?: string; + /** + * When `false`, the panel chrome's content area uses `overflow: hidden` + * instead of `overflow-y: auto`. Set this when `children` already manage + * their own scrolling (e.g. a virtualized list inside `Toolbox`) so the + * chrome doesn't add a redundant outer scrollbar. Defaults to `true`. + */ + scrollableContent?: boolean; // Mouse events for hover persistence onMouseEnter?: () => void; @@ -77,23 +101,39 @@ export function FloatingCanvasPanel({ children, onClose, scrollKey, + scrollableContent = true, onMouseEnter, onMouseLeave, }: FloatingCanvasPanelProps) { - const { computedAnchor, floatingStyles, refs, mergedReferenceRef } = useFloatingPosition({ - open, - nodeId, - anchorRect, - placement, - offset, - fallbackPlacement, - }); + const { computedAnchor, floatingStyles, availableHeight, refs, mergedReferenceRef } = + useFloatingPosition({ + open, + nodeId, + anchorRect, + placement, + offset, + fallbackPlacement, + }); const panelClassName = useMemo( () => cn(PANEL_BASE_CLASS, isPinned ? PANEL_PINNED_CLASS : PANEL_FLOATING_CLASS), [isPinned] ); + // Viewport-aware ceiling derived from the `size` middleware. The inline + // `maxHeight` overrides the `max-h-[600px]` class, so it's clamped to the + // design ceiling to keep the panel from exceeding 600px on tall viewports. + const sizingStyle = useMemo(() => { + if (isPinned || availableHeight == null) return {}; + const maxHeight = Math.min(PANEL_FLOATING_MAX_HEIGHT, availableHeight); + const occupiedHeight = Math.max(PANEL_FLOATING_MIN_HEIGHT, maxHeight); + return { + maxHeight: `${maxHeight}px`, + minHeight: `${PANEL_FLOATING_MIN_HEIGHT}px`, + ['--floating-available-height' as string]: `${occupiedHeight}px`, + }; + }, [isPinned, availableHeight]); + if (!open || !computedAnchor) return null; if (useFixedPosition && anchorRect) { @@ -150,6 +190,7 @@ export function FloatingCanvasPanel({ style={{ position: 'fixed', ...screenPosition, + ...sizingStyle, zIndex: 1100, pointerEvents: 'auto', }} @@ -160,6 +201,7 @@ export function FloatingCanvasPanel({ headerActions={headerActions} onClose={onClose} scrollKey={scrollKey} + scrollableContent={scrollableContent} > {children} @@ -181,6 +223,7 @@ export function FloatingCanvasPanel({ onPointerLeave={onMouseLeave} style={{ ...(isPinned ? {} : floatingStyles), + ...sizingStyle, position: isPinned ? 'fixed' : 'absolute', right: isPinned ? 0 : undefined, top: isPinned ? 0 : undefined, @@ -194,6 +237,7 @@ export function FloatingCanvasPanel({ headerActions={headerActions} onClose={onClose} scrollKey={scrollKey} + scrollableContent={scrollableContent} > {children} diff --git a/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/PanelChrome.test.tsx b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/PanelChrome.test.tsx new file mode 100644 index 000000000..f3839c36f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/PanelChrome.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '../../utils/testing'; +import { PanelChrome } from './PanelChrome'; + +describe('PanelChrome', () => { + it('uses an auto-scrolling content area by default', () => { + render( + +
body
+
+ ); + + const content = screen.getByTestId('content').parentElement; + expect(content).toHaveStyle({ overflowY: 'auto' }); + }); + + it('disables content scrolling when scrollableContent is false', () => { + render( + +
body
+
+ ); + + const content = screen.getByTestId('content').parentElement; + expect(content).toHaveStyle({ overflowY: 'hidden' }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/PanelChrome.tsx b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/PanelChrome.tsx index e17fcd866..5f8f7858d 100644 --- a/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/PanelChrome.tsx +++ b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/PanelChrome.tsx @@ -14,9 +14,10 @@ const PanelHeader = styled.div` flex-shrink: 0; `; -const PanelContent = styled.div` +const PanelContent = styled.div<{ scrollable?: boolean }>` flex: 1; - overflow-y: auto; + min-height: 0; + overflow-y: ${(props) => (props.scrollable === false ? 'hidden' : 'auto')}; overflow-x: hidden; &::-webkit-scrollbar { @@ -45,6 +46,7 @@ export interface PanelChromeProps { children?: ReactNode; onClose?: () => void; scrollKey?: string; + scrollableContent?: boolean; } export function PanelChrome({ @@ -54,6 +56,7 @@ export function PanelChrome({ children, onClose, scrollKey, + scrollableContent = true, }: PanelChromeProps) { const contentRef = useRef(null); @@ -88,7 +91,9 @@ export function PanelChrome({ )} )} - {children} + + {children} + ); } diff --git a/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/useFloatingPosition.ts b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/useFloatingPosition.ts index 0aa079938..1635daa22 100644 --- a/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/useFloatingPosition.ts +++ b/packages/apollo-react/src/canvas/components/FloatingCanvasPanel/useFloatingPosition.ts @@ -3,14 +3,23 @@ import { flip, offset, type Placement, + shift, + size, useFloating, useMergeRefs, } from '@floating-ui/react'; import { useInternalNode } from '@uipath/apollo-react/canvas/xyflow/react'; -import { type CSSProperties, type RefCallback, useEffect, useMemo, useRef } from 'react'; +import { type CSSProperties, type RefCallback, useEffect, useMemo, useRef, useState } from 'react'; export type AnchorRect = { x: number; y: number; width: number; height: number }; +/** + * Viewport padding (px) reserved around the floating element when computing + * `availableHeight` / `shift` boundaries. Keeps the panel from butting against + * the viewport edge at low resolutions. + */ +const VIEWPORT_PADDING = 8; + export interface UseFloatingPositionOptions { open?: boolean; nodeId?: string; @@ -23,6 +32,7 @@ export interface UseFloatingPositionOptions { export interface UseFloatingPositionReturn { computedAnchor: AnchorRect | null; floatingStyles: CSSProperties; + availableHeight: number | null; refs: { setReference: RefCallback; setFloating: RefCallback; @@ -40,6 +50,7 @@ export function useFloatingPosition({ }: UseFloatingPositionOptions): UseFloatingPositionReturn { const referenceRef = useRef(null); const internalNode = useInternalNode(nodeId || ''); + const [availableHeight, setAvailableHeight] = useState(null); const computedAnchor = useMemo(() => { if (anchorRect) { @@ -59,12 +70,31 @@ export function useFloatingPosition({ const { refs, floatingStyles, update } = useFloating({ placement, open: !!open && !!computedAnchor, - middleware: [offset(offsetValue), flip({ fallbackAxisSideDirection: fallbackPlacement })], + middleware: [ + offset(offsetValue), + flip({ fallbackAxisSideDirection: fallbackPlacement }), + shift({ padding: VIEWPORT_PADDING }), + size({ + padding: VIEWPORT_PADDING, + apply({ availableHeight: ah }) { + const next = Math.max(0, Math.floor(ah)); + setAvailableHeight((prev) => (prev === next ? prev : next)); + }, + }), + ], whileElementsMounted: autoUpdate, }); const mergedReferenceRef = useMergeRefs([refs.setReference, referenceRef]); + // Drop the cached viewport cap when the panel closes. Call sites typically + // keep the panel mounted and toggle `open`, so without this a reopen would + // render one frame with a stale `availableHeight` (e.g. from a since-changed + // viewport) before the `size` middleware recomputes. + useEffect(() => { + if (!open) setAvailableHeight(null); + }, [open]); + // biome-ignore lint/correctness/useExhaustiveDependencies: Dependencies are correct useEffect(() => { if (open) update(); @@ -80,6 +110,7 @@ export function useFloatingPosition({ return { computedAnchor, floatingStyles, + availableHeight, refs, mergedReferenceRef, }; diff --git a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.styles.ts b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.styles.ts index 27023346f..db8224e74 100644 --- a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.styles.ts +++ b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.styles.ts @@ -33,7 +33,7 @@ export const AnimatedContainer = styled.div` display: flex; flex-direction: column; overflow: hidden; - min-height: 200px; + min-height: 50px; `; export const AnimatedContent = styled.div<{ entering?: boolean; direction?: 'forward' | 'back' }>` @@ -41,7 +41,7 @@ export const AnimatedContent = styled.div<{ entering?: boolean; direction?: 'for display: flex; flex-direction: column; animation: ${(props) => (props.entering ? `slideIn-${props.direction}` : 'none')} 0.15s ease-out; - min-height: 200px; + min-height: 50px; @keyframes slideIn-forward { from { diff --git a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx index e854a743a..3a3a68287 100644 --- a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx +++ b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx @@ -630,14 +630,34 @@ export function Toolbox({ handleBackTransition, ]); + // When rendered inside a `FloatingCanvasPanel`, the panel's `size` middleware + // exposes `--floating-available-height` so the Toolbox can cap its own + // height to whatever fits the viewport. + const responsiveStyle = useMemo( + () => + fullHeight + ? { boxSizing: 'border-box', overflow: 'hidden' } + : { + boxSizing: 'border-box', + overflow: 'hidden', + maxHeight: `min(${TOOLBOX_HEIGHT}px, var(--floating-available-height, ${TOOLBOX_HEIGHT}px))`, + }, + [fullHeight] + ); + return ( -
+
{quickActions && quickActions.length > 0 && }