From 6b50439cb897ccb1b161e1d030e4f191b6a50ae0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 18 Sep 2026 14:27:15 -0700 Subject: [PATCH] refactor(emcn): centralize composer action button appearance --- .../home/components/composer/composer.tsx | 27 +--- .../app/o/[organizationId]/search/search.tsx | 24 ++-- .../user-input/components/constants.ts | 5 - .../components/send-button/send-button.tsx | 20 +-- .../components/chat/chat.test.tsx | 8 ++ .../w/[workflowId]/components/chat/chat.tsx | 21 ++- .../composer-action-button.test.tsx | 122 ++++++++++++++++++ .../composer-action-button.tsx | 61 +++++++++ packages/emcn/src/components/index.ts | 5 + 9 files changed, 227 insertions(+), 66 deletions(-) create mode 100644 packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx create mode 100644 packages/emcn/src/components/composer-action-button/composer-action-button.tsx diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 18ae5575976..78dde9bf9ac 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -1,7 +1,7 @@ 'use client' import { useRef } from 'react' -import { Button, Chip, cn, Tooltip } from '@sim/emcn' +import { Chip, ComposerActionButton, cn, Tooltip } from '@sim/emcn' import { ArrowUp, Plus, StopFilled } from '@sim/emcn/icons' import { ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE } from '@/lib/uploads/shared/assistant-images' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' @@ -14,11 +14,6 @@ import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder' import { useChatInputFocus } from '@/hooks/use-chat-input-focus' import { useVoiceInput } from '@/hooks/use-voice-input' -const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0' -const SEND_BUTTON_ACTIVE = - 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' -const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' - interface ComposerProps { value: string files: ReturnType @@ -130,29 +125,19 @@ export function Composer({ /> )} {isSending ? ( - + ) : ( - + )} diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 25633c361e8..f81ea1523a4 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -1,7 +1,13 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' +import { + ComposerActionButton, + cn, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' import { ArrowUp, Search } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -25,11 +31,6 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { useVoiceInput } from '@/hooks/use-voice-input' -const SUBMIT_BUTTON_BASE = 'size-[28px] shrink-0 rounded-full border-0 p-0' -const SUBMIT_BUTTON_ACTIVE = - 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' -const SUBMIT_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' - interface SearchFieldProps { initialValue: string onSubmit: (value: string) => void @@ -98,19 +99,16 @@ function SearchField({ onToggle={voice.toggleListening} /> )} - + - + ) } return ( - + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx index 9469d62fd28..a28d06bc942 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx @@ -80,6 +80,14 @@ vi.mock('@sim/emcn', () => ({ ), cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + ComposerActionButton: ({ + children, + size: _size, + active: _active, + ...props + }: ButtonHTMLAttributes & { size?: string; active?: boolean }) => ( + + ), Input: ({ ref, className: _className, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 42ab98a01a6..804e7b570f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -4,6 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } import { Badge, Button, + ComposerActionButton, cn, Input, Popover, @@ -1102,34 +1103,28 @@ export function Chat() { {isStreaming ? ( - + ) : ( - + )} diff --git a/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx b/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx new file mode 100644 index 00000000000..27bf8856ef0 --- /dev/null +++ b/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx @@ -0,0 +1,122 @@ +/** @vitest-environment jsdom */ +import { act, createRef, type ReactNode } from 'react' +import { Button, ComposerActionButton } 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 +} + +function button() { + const element = container?.querySelector('button') + if (!element) throw new Error('Button did not render') + return element +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +/** Exact pre-migration class inputs from the organization composer and workflow chat. */ +const PREVIOUS = { + md: { + base: 'size-[28px] rounded-full border-0 p-0', + active: 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]', + }, + sm: { + base: 'size-[22px] rounded-full p-0', + active: 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]', + }, +} as const + +describe('ComposerActionButton', () => { + for (const size of ['md', 'sm'] as const) { + for (const active of [true, false]) { + it(`preserves the previous ${size} markup with active=${active}`, () => { + const previous = PREVIOUS[size] + const view = mount( + <> + + + + + + ) + const [before, after] = view.querySelectorAll('button') + before.className = before.className.split(/\s+/).sort().join(' ') + after.className = after.className.split(/\s+/).sort().join(' ') + expect(after.outerHTML).toBe(before.outerHTML) + }) + } + } + + it('forwards refs and events while keeping active appearance independent of disabled', () => { + const ref = createRef() + const onClick = vi.fn() + const onKeyDown = vi.fn() + const action = (disabled: boolean) => ( + + ) + mount(action(false)) + expect(ref.current).toBe(button()) + expect(button().dataset.action).toBe('send') + 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) + const activeClasses = button().className + act(() => root?.render(action(true))) + expect(button().disabled).toBe(true) + expect(button().className).toBe(activeClasses) + act(() => button().click()) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + for (const type of [undefined, 'button'] as const) { + it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => { + const onSubmit = vi.fn((event) => event.preventDefault()) + mount( +
+ + + ) + act(() => button().click()) + expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1) + }) + } +}) diff --git a/packages/emcn/src/components/composer-action-button/composer-action-button.tsx b/packages/emcn/src/components/composer-action-button/composer-action-button.tsx new file mode 100644 index 00000000000..806f128a179 --- /dev/null +++ b/packages/emcn/src/components/composer-action-button/composer-action-button.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../lib/cn' +import { Button, type ButtonProps } from '../button/button' + +/** Shared circular send, stop and search appearance, including the compact chat treatment. */ +export const composerActionButtonVariants = cva('rounded-full p-0', { + variants: { + size: { + sm: 'size-[22px]', + md: 'size-[28px] border-0', + }, + active: { + true: 'bg-[#383838] dark:bg-[#E0E0E0]', + false: 'bg-[#808080] dark:bg-[#808080]', + }, + }, + compoundVariants: [ + { size: 'md', active: true, className: 'hover:bg-[#575757] dark:hover:bg-[#CFCFCF]' }, + { + size: 'sm', + active: true, + className: 'hover-hover:bg-[#575757] dark:hover-hover:bg-[#CFCFCF]', + }, + ], + defaultVariants: { size: 'md', active: true }, +}) + +export interface ComposerActionButtonProps extends Omit { + /** Accessible name for the caller's icon action. */ + 'aria-label': string + /** 28px by default; `sm` retains compact chat's 22px geometry and hover treatment. */ + size?: NonNullable['size']> + /** + * Whether to show the active fill. Independent of `disabled`: a populated composer + * can retain its active appearance while execution temporarily prevents submission. + * @default true + */ + active?: boolean +} + +/** + * Circular action at the end of a composer or search field. Owns the button appearance; + * callers retain icons, labels, handlers and submission/streaming conditions. + * Forwards the native button ref and props, including Button's native form behavior. + * + * @example + */ +export const ComposerActionButton = forwardRef( + ({ size, active, className, ...props }, ref) => ( +