Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ export const AddNodeManager: React.FC<AddNodeManagerProps> = ({
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}
Comment thread
ayush578 marked this conversation as resolved.
>
{CustomPanel ? (
<CustomPanel onNodeSelect={(item) => handleNodeSelect(item)} onClose={handleClose} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]';

Expand Down Expand Up @@ -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;
Expand All @@ -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<CSSProperties>(() => {
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]);
Comment thread
ayush578 marked this conversation as resolved.
Comment thread
ayush578 marked this conversation as resolved.

if (!open || !computedAnchor) return null;

if (useFixedPosition && anchorRect) {
Expand Down Expand Up @@ -150,6 +190,7 @@ export function FloatingCanvasPanel({
style={{
position: 'fixed',
...screenPosition,
...sizingStyle,
Comment thread
ayush578 marked this conversation as resolved.
zIndex: 1100,
pointerEvents: 'auto',
}}
Expand All @@ -160,6 +201,7 @@ export function FloatingCanvasPanel({
headerActions={headerActions}
onClose={onClose}
scrollKey={scrollKey}
scrollableContent={scrollableContent}
>
{children}
</PanelChrome>
Expand All @@ -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,
Expand All @@ -194,6 +237,7 @@ export function FloatingCanvasPanel({
headerActions={headerActions}
onClose={onClose}
scrollKey={scrollKey}
scrollableContent={scrollableContent}
>
{children}
</PanelChrome>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<PanelChrome>
<div data-testid="content">body</div>
</PanelChrome>
);

const content = screen.getByTestId('content').parentElement;
expect(content).toHaveStyle({ overflowY: 'auto' });
});

it('disables content scrolling when scrollableContent is false', () => {
render(
<PanelChrome scrollableContent={false}>
<div data-testid="content">body</div>
</PanelChrome>
);

const content = screen.getByTestId('content').parentElement;
expect(content).toHaveStyle({ overflowY: 'hidden' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
ayush578 marked this conversation as resolved.

Comment thread
ayush578 marked this conversation as resolved.
&::-webkit-scrollbar {
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface PanelChromeProps {
children?: ReactNode;
onClose?: () => void;
scrollKey?: string;
scrollableContent?: boolean;
}

export function PanelChrome({
Expand All @@ -54,6 +56,7 @@ export function PanelChrome({
children,
onClose,
scrollKey,
scrollableContent = true,
}: PanelChromeProps) {
const contentRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -88,7 +91,9 @@ export function PanelChrome({
)}
</PanelHeader>
)}
<PanelContent ref={contentRef}>{children}</PanelContent>
<PanelContent ref={contentRef} scrollable={scrollableContent}>
{children}
</PanelContent>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +32,7 @@ export interface UseFloatingPositionOptions {
export interface UseFloatingPositionReturn {
computedAnchor: AnchorRect | null;
floatingStyles: CSSProperties;
availableHeight: number | null;
refs: {
setReference: RefCallback<Element>;
setFloating: RefCallback<HTMLElement>;
Expand All @@ -40,6 +50,7 @@ export function useFloatingPosition({
}: UseFloatingPositionOptions): UseFloatingPositionReturn {
const referenceRef = useRef<HTMLDivElement>(null);
const internalNode = useInternalNode(nodeId || '');
const [availableHeight, setAvailableHeight] = useState<number | null>(null);

Comment thread
ayush578 marked this conversation as resolved.
const computedAnchor = useMemo<AnchorRect | null>(() => {
if (anchorRect) {
Expand All @@ -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();
Expand All @@ -80,6 +110,7 @@ export function useFloatingPosition({
return {
computedAnchor,
floatingStyles,
availableHeight,
refs,
mergedReferenceRef,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ 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' }>`
flex: 1;
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 {
Expand Down
22 changes: 21 additions & 1 deletion packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,14 +630,34 @@ export function Toolbox<T>({
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<React.CSSProperties>(
() =>
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 (
<div ref={containerRef} data-testid="toolbox-container">
<div
ref={containerRef}
data-testid="toolbox-container"
style={{ maxHeight: '100%', overflow: 'hidden' }}
>
<Column
px={TOOLBOX_PADDING_X}
py={TOOLBOX_PADDING_Y}
gap={TOOLBOX_GAP}
w={fullWidth ? '100%' : TOOLBOX_WIDTH}
h={fullHeight ? '100%' : TOOLBOX_HEIGHT}
style={responsiveStyle}
>
{quickActions && quickActions.length > 0 && <QuickActionsRow actions={quickActions} />}
<Header
Expand Down
Loading