From 54344aa0b5d2b299da59b75fe5278680cf406476 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Fri, 24 Jul 2026 12:41:44 -0700 Subject: [PATCH] feat(apollo-react): add canvas bottom panel --- .../CanvasBottomPanel.stories.tsx | 210 ++++++++++++++++++ .../CanvasBottomPanel.test.tsx | 98 ++++++++ .../CanvasBottomPanel/CanvasBottomPanel.tsx | 147 ++++++++++++ .../CanvasBottomPanel.types.ts | 25 +++ .../components/CanvasBottomPanel/index.ts | 5 + .../src/canvas/components/index.ts | 1 + 6 files changed, 486 insertions(+) create mode 100644 packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.tsx create mode 100644 packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.types.ts create mode 100644 packages/apollo-react/src/canvas/components/CanvasBottomPanel/index.ts diff --git a/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.stories.tsx b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.stories.tsx new file mode 100644 index 000000000..77b35b89d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.stories.tsx @@ -0,0 +1,210 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Button, Separator, TooltipProvider } from '@uipath/apollo-wind'; +import { + Bug, + ChevronDown, + ChevronUp, + FlaskConical, + Play, + RotateCcw, + Sparkles, + Square, +} from 'lucide-react'; +import { useState } from 'react'; +import { useCanvasStory, withCanvasProviders } from '../../storybook-utils'; +import { Panel } from '../../xyflow/react'; +import { BaseCanvas } from '../BaseCanvas'; +import { + CanvasModeToolbar, + TOOLBAR_ICON_BUTTON_CLASS, +} from '../CanvasModeToolbar/CanvasModeToolbar'; +import { CanvasZoomControls } from '../CanvasZoomControls'; +import { ToolbarButton } from '../ToolbarButton'; +import { CanvasBottomPanel } from './CanvasBottomPanel'; +import type { CanvasBottomPanelTab } from './CanvasBottomPanel.types'; + +const meta: Meta = { + title: 'Components/Controls/CanvasBottomPanel', + component: CanvasBottomPanel, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +function DebugContent() { + return ( +
+
+

Run history

+ +
+
+
+ + Execution completed +
+
+ Select an execution step to inspect its input, output, logs, and metrics. +
+
+
+ ); +} + +function EvaluateContent() { + return ( +
+
+ +

Evaluate your flow

+

+ Connect a dataset and evaluators in your product, then render that experience here. +

+ +
+
+ ); +} + +function usePanelState(initialCollapsed = false) { + const [activeTabId, setActiveTabId] = useState('debug'); + const [isCollapsed, setIsCollapsed] = useState(initialCollapsed); + const tabs: CanvasBottomPanelTab[] = [ + { + id: 'debug', + label: ( + <> + + Debug + + ), + ariaLabel: 'Debug', + content: , + }, + { + id: 'evaluate', + label: ( + <> + + Evaluate + + ), + ariaLabel: 'Evaluate', + content: , + }, + ]; + + return { + activeTabId, + setActiveTabId, + isCollapsed, + setIsCollapsed, + tabs, + }; +} + +function PanelExample({ initialCollapsed = false }: { initialCollapsed?: boolean }) { + const state = usePanelState(initialCollapsed); + + return ( +
+ + + state.setIsCollapsed(!state.isCollapsed)} + > + {state.isCollapsed ? : } + + + } + /> +
+ ); +} + +export const Collapsed: Story = { + render: () => , +}; + +export const Expanded: Story = { + render: () => , +}; + +function CanvasCompositionStory() { + const { canvasProps } = useCanvasStory({ initialNodes: [], initialEdges: [] }); + const state = usePanelState(); + + return ( +
+ + + + + + + + + + + + + + + + + + +
+ state.setIsCollapsed(!state.isCollapsed)} + > + {state.isCollapsed ? : } + + } + /> +
+
+
+ ); +} + +export const CanvasComposition: Story = { + name: 'Debug / Evaluate Canvas Composition', + decorators: [withCanvasProviders()], + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.test.tsx b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.test.tsx new file mode 100644 index 000000000..f80953dcc --- /dev/null +++ b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { CanvasBottomPanel } from './CanvasBottomPanel'; +import type { CanvasBottomPanelTab } from './CanvasBottomPanel.types'; + +const tabs: CanvasBottomPanelTab[] = [ + { id: 'debug', label: 'Debug', content:
Debug view
}, + { + id: 'evaluate', + label: 'Evaluate', + content:
Evaluate view
, + }, +]; + +function renderPanel(overrides: Partial> = {}) { + const props: ComponentProps = { + tabs, + activeTabId: 'debug', + onTabChange: vi.fn(), + isCollapsed: false, + onCollapsedChange: vi.fn(), + ...overrides, + }; + return { ...render(), props }; +} + +describe('CanvasBottomPanel', () => { + it('renders consumer-provided tabs and only displays the active content', () => { + renderPanel(); + + expect(screen.getByRole('tab', { name: 'Debug' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Evaluate' })).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByTestId('debug-content').parentElement).toHaveStyle({ display: 'block' }); + expect(screen.getByTestId('evaluate-content').parentElement).toHaveStyle({ + display: 'none', + }); + }); + + it('reports tab changes without owning active state', () => { + const onTabChange = vi.fn(); + renderPanel({ onTabChange }); + + fireEvent.click(screen.getByRole('tab', { name: 'Evaluate' })); + + expect(onTabChange).toHaveBeenCalledWith('evaluate'); + expect(screen.getByRole('tab', { name: 'Debug' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('keeps tab content mounted when inactive or collapsed', () => { + renderPanel({ isCollapsed: true }); + + expect(screen.getByTestId('canvas-bottom-panel-content')).toHaveStyle({ display: 'none' }); + expect(screen.getByTestId('debug-content')).toBeInTheDocument(); + expect(screen.getByTestId('evaluate-content')).toBeInTheDocument(); + }); + + it('expands when a tab is selected while collapsed', () => { + const onCollapsedChange = vi.fn(); + renderPanel({ isCollapsed: true, onCollapsedChange }); + + fireEvent.click(screen.getByRole('tab', { name: 'Evaluate' })); + + expect(onCollapsedChange).toHaveBeenCalledWith(false); + }); + + it('renders optional consumer-owned header actions', () => { + renderPanel({ headerActions: }); + + expect(screen.getByRole('button', { name: 'Collapse panel' })).toBeInTheDocument(); + }); + + it('associates each tab with its panel and applies roving tab index', () => { + renderPanel(); + + const debugTab = screen.getByRole('tab', { name: 'Debug' }); + const evaluateTab = screen.getByRole('tab', { name: 'Evaluate' }); + const debugPanel = document.getElementById(debugTab.getAttribute('aria-controls') ?? ''); + + expect(debugTab).toHaveAttribute('tabindex', '0'); + expect(evaluateTab).toHaveAttribute('tabindex', '-1'); + expect(debugPanel).toHaveAttribute('aria-labelledby', debugTab.id); + }); + + it.each([ + ['ArrowRight', 'evaluate'], + ['ArrowLeft', 'evaluate'], + ['End', 'evaluate'], + ['Home', 'debug'], + ])('supports %s keyboard navigation', (key, expectedTab) => { + const onTabChange = vi.fn(); + renderPanel({ onTabChange }); + + fireEvent.keyDown(screen.getByRole('tab', { name: 'Debug' }), { key }); + + expect(onTabChange).toHaveBeenLastCalledWith(expectedTab); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.tsx b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.tsx new file mode 100644 index 000000000..1f479c1a8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.tsx @@ -0,0 +1,147 @@ +import { Fragment, memo, useCallback, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { cn } from '@uipath/apollo-wind'; +import type { CanvasBottomPanelProps } from './CanvasBottomPanel.types'; + +export const CANVAS_BOTTOM_PANEL_COLLAPSED_HEIGHT = 48; + +function clearNativeSelection() { + if (typeof window === 'undefined' || typeof window.getSelection !== 'function') return; + window.getSelection()?.removeAllRanges(); +} + +export const CanvasBottomPanel = memo(function CanvasBottomPanel({ + tabs, + activeTabId, + onTabChange, + isCollapsed, + onCollapsedChange, + headerActions, + idPrefix = 'canvas-bottom-panel', + className, +}: CanvasBottomPanelProps) { + const focusTab = useCallback( + (tabId: string) => { + requestAnimationFrame(() => { + document.getElementById(`${idPrefix}-tab-${tabId}`)?.focus(); + }); + }, + [idPrefix] + ); + + const activateTab = useCallback( + (tabId: string) => { + clearNativeSelection(); + onTabChange(tabId); + if (isCollapsed) onCollapsedChange(false); + }, + [isCollapsed, onCollapsedChange, onTabChange] + ); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent, index: number) => { + if (tabs.length === 0) return; + + let nextIndex: number; + switch (event.key) { + case 'ArrowRight': + nextIndex = (index + 1) % tabs.length; + break; + case 'ArrowLeft': + nextIndex = (index - 1 + tabs.length) % tabs.length; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = tabs.length - 1; + break; + default: + return; + } + + event.preventDefault(); + const nextTab = tabs[nextIndex]; + if (!nextTab) return; + activateTab(nextTab.id); + focusTab(nextTab.id); + }, + [activateTab, focusTab, tabs] + ); + + return ( +
+
+
+ {tabs.map((tab, index) => { + const isActive = tab.id === activeTabId; + return ( + + + + ); + })} +
+ + {headerActions && ( +
+ {headerActions} +
+ )} +
+ +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( + + ); + })} +
+
+ ); +}); diff --git a/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.types.ts b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.types.ts new file mode 100644 index 000000000..0a9e2f976 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/CanvasBottomPanel.types.ts @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +export interface CanvasBottomPanelTab { + /** Stable identifier used for controlled selection and ARIA relationships. */ + id: string; + /** Visible tab label. */ + label: ReactNode; + /** Accessible label when the visible label is not plain text. */ + ariaLabel?: string; + /** Content remains mounted while inactive or collapsed so consumer state is preserved. */ + content: ReactNode; +} + +export interface CanvasBottomPanelProps { + tabs: CanvasBottomPanelTab[]; + activeTabId: string; + onTabChange: (tabId: string) => void; + isCollapsed: boolean; + onCollapsedChange: (isCollapsed: boolean) => void; + /** Consumer-owned controls rendered at the end of the panel header. */ + headerActions?: ReactNode; + /** Unique prefix for tab and panel IDs when multiple instances share a page. */ + idPrefix?: string; + className?: string; +} diff --git a/packages/apollo-react/src/canvas/components/CanvasBottomPanel/index.ts b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/index.ts new file mode 100644 index 000000000..30763bc39 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/CanvasBottomPanel/index.ts @@ -0,0 +1,5 @@ +export { CANVAS_BOTTOM_PANEL_COLLAPSED_HEIGHT, CanvasBottomPanel } from './CanvasBottomPanel'; +export type { + CanvasBottomPanelProps, + CanvasBottomPanelTab, +} from './CanvasBottomPanel.types'; diff --git a/packages/apollo-react/src/canvas/components/index.ts b/packages/apollo-react/src/canvas/components/index.ts index 3cf5e8f0f..ebdb6e523 100644 --- a/packages/apollo-react/src/canvas/components/index.ts +++ b/packages/apollo-react/src/canvas/components/index.ts @@ -4,6 +4,7 @@ export * from './BaseCanvas'; export * from './BaseNode'; export * from './ButtonHandle'; export * from './CanvasModeToolbar'; +export * from './CanvasBottomPanel'; export * from './CanvasPositionControls'; export * from './CanvasZoomControls'; export * from './CaseFlow';