From 464c9e0cc054214d1048bf55d49b4a5ce1a8bb02 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 19 Sep 2026 12:41:59 -0700 Subject: [PATCH 1/4] refactor(ui): share log and enrichment details panels --- .../components/log-details/log-details.tsx | 130 ++++++++---------- .../enrichment-details/enrichment-details.tsx | 66 ++++----- .../details-panel/details-panel.test.tsx | 50 +++++++ .../details-panel/details-panel.tsx | 59 ++++++++ packages/emcn/src/components/index.ts | 1 + 5 files changed, 194 insertions(+), 112 deletions(-) create mode 100644 packages/emcn/src/components/details-panel/details-panel.test.tsx create mode 100644 packages/emcn/src/components/details-panel/details-panel.tsx 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..52678900bd7 --- /dev/null +++ b/packages/emcn/src/components/details-panel/details-panel.test.tsx @@ -0,0 +1,50 @@ +/** @vitest-environment jsdom */ +import { act, createRef } from 'react' +import { DetailsPanel } 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) => ( + + + + ) + + try { + act(() => root.render(render(true))) + const panel = ref.current! + const input = panel.querySelector('input')! + const handle = container.querySelector('[role="separator"]')! + 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(ref.current).toBe(panel) + expect(panel.querySelector('input')).toBe(input) + expect(input.value).toBe('Retained query') + expect(container.querySelector('[role="separator"]')).toBeNull() + act(() => root.render(render(true))) + 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..e30d3a6fb02 --- /dev/null +++ b/packages/emcn/src/components/details-panel/details-panel.tsx @@ -0,0 +1,59 @@ +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 without unmounting its content. */ + 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 + + 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, From a3bd9d4017cfa9ca49ee2b11184a64edf4cb3c6d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 19 Sep 2026 12:51:22 -0700 Subject: [PATCH 2/4] fix(ui): prevent focus in closed details panels --- .../emcn/src/components/details-panel/details-panel.test.tsx | 3 +++ packages/emcn/src/components/details-panel/details-panel.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/emcn/src/components/details-panel/details-panel.test.tsx b/packages/emcn/src/components/details-panel/details-panel.test.tsx index 52678900bd7..82172391ef4 100644 --- a/packages/emcn/src/components/details-panel/details-panel.test.tsx +++ b/packages/emcn/src/components/details-panel/details-panel.test.tsx @@ -27,6 +27,7 @@ it('forwards resize events and retains content and refs while closed', () => { try { act(() => root.render(render(true))) const panel = ref.current! + expect(panel.hasAttribute('inert')).toBe(false) const input = panel.querySelector('input')! const handle = container.querySelector('[role="separator"]')! expect(handle.getAttribute('aria-label')).toBe('Resize details') @@ -36,11 +37,13 @@ it('forwards resize events and retains content and refs while closed', () => { 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() act(() => root.render(render(true))) + expect(panel.hasAttribute('inert')).toBe(false) expect(panel.querySelector('input')).toBe(input) expect(container.querySelector('[role="separator"]')).not.toBeNull() } finally { diff --git a/packages/emcn/src/components/details-panel/details-panel.tsx b/packages/emcn/src/components/details-panel/details-panel.tsx index e30d3a6fb02..e7e5d74fca7 100644 --- a/packages/emcn/src/components/details-panel/details-panel.tsx +++ b/packages/emcn/src/components/details-panel/details-panel.tsx @@ -2,7 +2,7 @@ import { type CSSProperties, forwardRef, type HTMLAttributes, type MouseEventHan import { cn } from '../../lib/cn' export interface DetailsPanelProps extends HTMLAttributes { - /** Slide the panel into view without unmounting its content. */ + /** Slide the panel into view; closed content stays mounted but inert. */ open: boolean /** Controlled width, including responsive CSS expressions such as clamp(). */ width: NonNullable @@ -42,6 +42,7 @@ export const DetailsPanel = forwardRef(
Date: Sat, 19 Sep 2026 13:00:03 -0700 Subject: [PATCH 3/4] refactor(ui): use CSS-variable sizing for details panels --- .../details-panel/details-panel.test.tsx | 17 ++++++++++++----- .../components/details-panel/details-panel.tsx | 10 ++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/emcn/src/components/details-panel/details-panel.test.tsx b/packages/emcn/src/components/details-panel/details-panel.test.tsx index 82172391ef4..3b8f9f4943e 100644 --- a/packages/emcn/src/components/details-panel/details-panel.test.tsx +++ b/packages/emcn/src/components/details-panel/details-panel.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { act, createRef } from 'react' -import { DetailsPanel } from '@sim/emcn' +import { DetailsPanel, type DetailsPanelProps } from '@sim/emcn' import { createRoot } from 'react-dom/client' import { expect, it, vi } from 'vitest' @@ -11,11 +11,12 @@ it('forwards resize events and retains content and refs while closed', () => { const root = createRoot(container) const ref = createRef() const resize = vi.fn() - const render = (open: boolean) => ( + 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"]')! + 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 }))) @@ -42,7 +47,9 @@ it('forwards resize events and retains content and refs while closed', () => { expect(panel.querySelector('input')).toBe(input) expect(input.value).toBe('Retained query') expect(container.querySelector('[role="separator"]')).toBeNull() - act(() => root.render(render(true))) + 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() diff --git a/packages/emcn/src/components/details-panel/details-panel.tsx b/packages/emcn/src/components/details-panel/details-panel.tsx index e7e5d74fca7..d3784791e99 100644 --- a/packages/emcn/src/components/details-panel/details-panel.tsx +++ b/packages/emcn/src/components/details-panel/details-panel.tsx @@ -26,13 +26,15 @@ export interface DetailsPanelProps extends HTMLAttributes { 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 && (
( ref={ref} inert={!open || props.inert} className={cn( - 'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out', + 'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] w-[var(--details-panel-width)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out', open ? 'translate-x-0' : 'translate-x-full', className )} - style={{ ...style, width }} + style={{ ...panelStyle, ...widthStyle }} > {children}
From 849dbe458b819a04ffa3aba9860e9ccd1ca94f0c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 19 Sep 2026 13:04:49 -0700 Subject: [PATCH 4/4] style(ui): make resize calculation spacing explicit --- packages/emcn/src/components/details-panel/details-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/emcn/src/components/details-panel/details-panel.tsx b/packages/emcn/src/components/details-panel/details-panel.tsx index d3784791e99..83453c857d5 100644 --- a/packages/emcn/src/components/details-panel/details-panel.tsx +++ b/packages/emcn/src/components/details-panel/details-panel.tsx @@ -33,7 +33,7 @@ export const DetailsPanel = forwardRef( <> {open && (