diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 492d2fb1d5e..b3a28c53f71 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -19,6 +19,7 @@ import { ChipModalTabs, Code, cn, + DetailsPanel, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -804,79 +805,64 @@ export const LogDetails = memo(function LogDetails({ }, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext]) return ( - <> - {/* Resize Handle - positioned outside the panel */} - {isOpen && ( -
- )} - -
- {log && ( -
- {/* Header */} -
-

Log Details

-
- {log.status === 'failed' && - (log.workflow?.id || log.workflowId) && - log.trigger !== 'mothership' && ( - - - - - Retry - - )} - - - -
+ + {log && ( +
+ {/* Header */} +
+

Log Details

+
+ {log.status === 'failed' && + (log.workflow?.id || log.workflowId) && + log.trigger !== 'mothership' && ( + + + + + Retry + + )} + + +
- -
- )} -
- + + +
+ )} + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx index e2227fb5be1..5ed069adc55 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { Badge, Button, ChipModalTabs, cn, X } from '@sim/emcn' +import { Badge, Button, ChipModalTabs, cn, DetailsPanel, X } from '@sim/emcn' import { formatDuration } from '@sim/utils/formatting' import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table' import { @@ -340,45 +340,31 @@ export function EnrichmentDetails({ }, [isOpen, onClose]) return ( - <> - {isOpen && ( -
- )} - -
- {rowId && groupId && ( -
-
-

Enrichment Details

- -
- - + + {rowId && groupId && ( +
+
+

Enrichment Details

+
- )} -
- + + +
+ )} + ) } diff --git a/packages/emcn/src/components/details-panel/details-panel.test.tsx b/packages/emcn/src/components/details-panel/details-panel.test.tsx new file mode 100644 index 00000000000..3b8f9f4943e --- /dev/null +++ b/packages/emcn/src/components/details-panel/details-panel.test.tsx @@ -0,0 +1,60 @@ +/** @vitest-environment jsdom */ +import { act, createRef } from 'react' +import { DetailsPanel, type DetailsPanelProps } from '@sim/emcn' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' + +it('forwards resize events and retains content and refs while closed', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const ref = createRef() + const resize = vi.fn() + const render = (open: boolean, width: DetailsPanelProps['width'] = 520) => ( + + + + ) + + try { + act(() => root.render(render(true))) + const panel = ref.current! + expect(panel.style.getPropertyValue('--details-panel-width')).toBe('520px') + expect(panel.style.width).toBe('') + expect(panel.style.opacity).toBe('0.9') + expect(panel.hasAttribute('inert')).toBe(false) + const input = panel.querySelector('input')! + const handle = container.querySelector('[role="separator"]')! + expect(handle.style.getPropertyValue('--details-panel-width')).toBe('520px') + expect(handle.getAttribute('aria-label')).toBe('Resize details') + expect(panel.contains(handle)).toBe(false) + act(() => handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 240 }))) + expect(resize).toHaveBeenCalledTimes(1) + expect(resize.mock.calls[0][0].clientX).toBe(240) + + act(() => root.render(render(false))) + expect(panel.hasAttribute('inert')).toBe(true) + expect(ref.current).toBe(panel) + expect(panel.querySelector('input')).toBe(input) + expect(input.value).toBe('Retained query') + expect(container.querySelector('[role="separator"]')).toBeNull() + const responsiveWidth = 'clamp(min(320px, 60vw), 520px, 60vw)' + act(() => root.render(render(true, responsiveWidth))) + expect(panel.style.getPropertyValue('--details-panel-width')).toBe(responsiveWidth) + expect(panel.hasAttribute('inert')).toBe(false) + expect(panel.querySelector('input')).toBe(input) + expect(container.querySelector('[role="separator"]')).not.toBeNull() + } finally { + act(() => root.unmount()) + container.remove() + } +}) diff --git a/packages/emcn/src/components/details-panel/details-panel.tsx b/packages/emcn/src/components/details-panel/details-panel.tsx new file mode 100644 index 00000000000..83453c857d5 --- /dev/null +++ b/packages/emcn/src/components/details-panel/details-panel.tsx @@ -0,0 +1,62 @@ +import { type CSSProperties, forwardRef, type HTMLAttributes, type MouseEventHandler } from 'react' +import { cn } from '../../lib/cn' + +export interface DetailsPanelProps extends HTMLAttributes { + /** Slide the panel into view; closed content stays mounted but inert. */ + open: boolean + /** Controlled width, including responsive CSS expressions such as clamp(). */ + width: NonNullable + /** Existing product resize handler; width state stays with the caller. */ + onResizeStart: MouseEventHandler + /** Accessible name for the external resize handle. */ + resizeLabel: string +} + +/** + * Non-modal details sidebar with a right-edge slide and an external resize handle. + * Its parent supplies the positioned containing block. Content, keyboard commands, + * and persisted width remain owned by the caller. + * + * @example + * + * {content} + * + */ +export const DetailsPanel = forwardRef( + ({ open, width, onResizeStart, resizeLabel, className, style, children, ...props }, ref) => { + const cssWidth = typeof width === 'number' ? `${width}px` : width + const widthStyle = { '--details-panel-width': cssWidth } as CSSProperties + const { width: _styleWidth, ...panelStyle } = style ?? {} + + return ( + <> + {open && ( +
+ )} +
+ {children} +
+ + ) + } +) + +DetailsPanel.displayName = 'DetailsPanel' diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index c7c2204968a..6bf14191b56 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -130,6 +130,7 @@ export { type ComposerActionButtonProps, composerActionButtonVariants, } from './composer-action-button/composer-action-button' +export { DetailsPanel, type DetailsPanelProps } from './details-panel/details-panel' export { DropdownMenu, DropdownMenuCheckboxItem,