diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index c75ef9c2457..a1a2acfba9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -15,6 +15,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, Duplicate, + OverlayActionButton, Search as SearchIcon, Tooltip, useCopyToClipboard, @@ -504,39 +505,35 @@ function DetailCodeSection({
- + {copied ? 'Copied' : 'Copy'} - + Search 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..60dea0fb0ed 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 @@ -27,6 +27,7 @@ import { Duplicate, Eye, handleKeyboardActivation, + OverlayActionButton, Redo, Search as SearchIcon, Tooltip, @@ -168,39 +169,35 @@ export const WorkflowOutputSection = memo(
- + {copied ? 'Copied' : 'Copy'} - + Search diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 231ea553c16..b38f2324a58 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -2,7 +2,6 @@ import { useId, useState } from 'react' import { - Button, ChipButtonGroup, ChipButtonGroupItem, ChipConfirmModal, @@ -12,6 +11,7 @@ import { cn, Expand, Label, + OverlayActionButton, Skeleton, Tooltip, } from '@sim/emcn' @@ -241,15 +241,15 @@ export function GeneralDeploy({
- + See preview diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 6a1ce331611..8dd52e81c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -12,6 +12,7 @@ import { Input, Label, OverflowText, + OverlayActionButton, Tooltip, } from '@sim/emcn' import { @@ -1235,22 +1236,20 @@ function PreviewEditorContent({
- + {copiedSection === 'input' ? 'Copied' : 'Copy'} @@ -1258,18 +1257,16 @@ function PreviewEditorContent({ - + Search @@ -1309,22 +1306,20 @@ function PreviewEditorContent({
- + {copiedSection === 'output' ? 'Copied' : 'Copy'} @@ -1332,18 +1327,16 @@ function PreviewEditorContent({ - + Search diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 6628b0a2d1b..8b6df9af42c 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -196,6 +196,11 @@ export { overflowTextClipClass, overflowTextFadeClass, } from './overflow-text/overflow-text' +export { + OverlayActionButton, + type OverlayActionButtonProps, + overlayActionButtonVariants, +} from './overlay-action-button/overlay-action-button' export { Popover, PopoverAnchor, diff --git a/packages/emcn/src/components/overlay-action-button/overlay-action-button.test.tsx b/packages/emcn/src/components/overlay-action-button/overlay-action-button.test.tsx new file mode 100644 index 00000000000..cd1ecca1876 --- /dev/null +++ b/packages/emcn/src/components/overlay-action-button/overlay-action-button.test.tsx @@ -0,0 +1,117 @@ +/** @vitest-environment jsdom */ +import { act, createRef, type ReactNode } from 'react' +import { Button, OverlayActionButton, Tooltip } from '@sim/emcn' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(children: ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(children)) + return container +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null + vi.useRealTimers() +}) + +/** Pre-migration recipes from log details and the deployment preview. */ +const PREVIOUS = [ + { + name: 'default 20px adaptive action', + props: {}, + variant: 'default', + className: + 'size-[20px] cursor-pointer border-[var(--border-1)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]', + }, + { + name: '28px adaptive action', + props: { size: 'md' }, + variant: 'default', + className: + 'size-[28px] cursor-pointer bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]', + }, +] as const + +describe('OverlayActionButton', () => { + it.each(PREVIOUS)('preserves the previous $name markup', ({ props, variant, className }) => { + const view = mount( + <> + + + + + + ) + const [previous, current] = view.querySelectorAll('button') + /** The old border-1 token aliases border; class order changes when recipes are composed. */ + for (const button of [previous, current]) { + button.className = button.className + .replaceAll('--border-1', '--border') + .split(/\s+/) + .sort() + .join(' ') + } + expect(current.outerHTML).toBe(previous.outerHTML) + }) + + it('forwards refs and native props through a tooltip and suppresses disabled clicks', () => { + vi.useFakeTimers() + const ref = createRef() + const onClick = vi.fn() + const onKeyDown = vi.fn() + const action = (disabled: boolean) => ( + + + + + Copy output + + ) + const view = mount(action(false)) + const button = view.querySelector('button') + if (!button) throw new Error('Button did not render') + expect(view.querySelectorAll('button')).toHaveLength(1) + expect(ref.current).toBe(button) + expect(button.type).toBe('button') + expect(button.dataset.action).toBe('copy') + expect(button.getAttribute('aria-label')).toBe('Copy') + act(() => + button.dispatchEvent( + new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) + ) + ) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Copy output') + act(() => button.focus()) + expect(document.activeElement).toBe(button) + const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + act(() => button.dispatchEvent(keyEvent)) + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent) + act(() => button.click()) + expect(onClick).toHaveBeenCalledTimes(1) + act(() => root?.render(action(true))) + expect(button.disabled).toBe(true) + act(() => button.click()) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/emcn/src/components/overlay-action-button/overlay-action-button.tsx b/packages/emcn/src/components/overlay-action-button/overlay-action-button.tsx new file mode 100644 index 00000000000..6a2f52fc4f0 --- /dev/null +++ b/packages/emcn/src/components/overlay-action-button/overlay-action-button.tsx @@ -0,0 +1,49 @@ +import { forwardRef } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../lib/cn' +import { Button, type ButtonProps } from '../button/button' + +/** Transparent, bordered icon action over code or preview content. */ +export const overlayActionButtonVariants = cva( + 'cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)] dark:hover-hover:bg-[var(--surface-5)] hover-hover:border-[var(--border)]', + { + variants: { + size: { + sm: 'size-[20px]', + md: 'size-[28px]', + }, + }, + defaultVariants: { size: 'sm' }, + } +) + +export interface OverlayActionButtonProps + extends Omit { + /** Accessible name for the icon action; tooltip content is supplied separately. */ + 'aria-label': string + /** 20px by default; `md` provides the 28px preview action. */ + size?: NonNullable['size']> +} + +/** + * Icon action floating over content. Owns geometry, border, blur and hover treatment; + * callers supply positioning, icons, labels and command behavior. + * Hover uses surface-3 in light mode and surface-5 in dark mode. + * Forwards the native button ref and props for tooltip `asChild` composition. + * Native form behavior is inherited from Button; pass `type` when it must be explicit. + * + * @example + */ +export const OverlayActionButton = forwardRef( + ({ size, className, ...props }, ref) => ( +