Skip to content

Commit 6b50439

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
refactor(emcn): centralize composer action button appearance
1 parent 44869df commit 6b50439

9 files changed

Lines changed: 227 additions & 66 deletions

File tree

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useRef } from 'react'
4-
import { Button, Chip, cn, Tooltip } from '@sim/emcn'
4+
import { Chip, ComposerActionButton, cn, Tooltip } from '@sim/emcn'
55
import { ArrowUp, Plus, StopFilled } from '@sim/emcn/icons'
66
import { ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE } from '@/lib/uploads/shared/assistant-images'
77
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
@@ -14,11 +14,6 @@ import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
1414
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
1515
import { useVoiceInput } from '@/hooks/use-voice-input'
1616

17-
const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0'
18-
const SEND_BUTTON_ACTIVE =
19-
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
20-
const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
21-
2217
interface ComposerProps {
2318
value: string
2419
files: ReturnType<typeof useFileAttachments>
@@ -130,29 +125,19 @@ export function Composer({
130125
/>
131126
)}
132127
{isSending ? (
133-
<Button
134-
type='button'
135-
variant='ghost'
136-
onClick={onStop}
137-
aria-label='Stop generation'
138-
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
139-
>
128+
<ComposerActionButton type='button' onClick={onStop} aria-label='Stop generation'>
140129
<StopFilled className='block size-[14px] fill-white dark:fill-black' />
141-
</Button>
130+
</ComposerActionButton>
142131
) : (
143-
<Button
132+
<ComposerActionButton
144133
type='button'
145-
variant='ghost'
146134
onClick={submit}
147135
disabled={!canSubmit}
148136
aria-label='Send'
149-
className={cn(
150-
SEND_BUTTON_BASE,
151-
canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED
152-
)}
137+
active={canSubmit}
153138
>
154139
<ArrowUp className='block size-[16px] text-white dark:text-black' />
155-
</Button>
140+
</ComposerActionButton>
156141
)}
157142
</div>
158143
</div>

‎apps/sim/app/o/[organizationId]/search/search.tsx‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
'use client'
22

33
import { useEffect, useRef, useState } from 'react'
4-
import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
4+
import {
5+
ComposerActionButton,
6+
cn,
7+
scrollFadeAttributes,
8+
scrollFadeClass,
9+
useScrollEdges,
10+
} from '@sim/emcn'
511
import { ArrowUp, Search } from '@sim/emcn/icons'
612
import { useRouter } from 'next/navigation'
713
import { useQueryStates } from 'nuqs'
@@ -25,11 +31,6 @@ import {
2531
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
2632
import { useVoiceInput } from '@/hooks/use-voice-input'
2733

28-
const SUBMIT_BUTTON_BASE = 'size-[28px] shrink-0 rounded-full border-0 p-0'
29-
const SUBMIT_BUTTON_ACTIVE =
30-
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
31-
const SUBMIT_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
32-
3334
interface SearchFieldProps {
3435
initialValue: string
3536
onSubmit: (value: string) => void
@@ -98,19 +99,16 @@ function SearchField({
9899
onToggle={voice.toggleListening}
99100
/>
100101
)}
101-
<Button
102+
<ComposerActionButton
102103
type='button'
103-
variant='ghost'
104104
onClick={() => onSubmit(value)}
105105
disabled={!canSubmit}
106106
aria-label='Search'
107-
className={cn(
108-
SUBMIT_BUTTON_BASE,
109-
canSubmit ? SUBMIT_BUTTON_ACTIVE : SUBMIT_BUTTON_DISABLED
110-
)}
107+
active={canSubmit}
108+
className='shrink-0'
111109
>
112110
<ArrowUp className='block size-[16px] text-white dark:text-black' />
113-
</Button>
111+
</ComposerActionButton>
114112
</div>
115113
<MicrophonePermissionHelp
116114
open={voice.permissionHelpOpen}

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,6 @@ export const SCROLLER_CLASSES = cn(
9797
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
9898
)
9999

100-
export const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0'
101-
export const SEND_BUTTON_ACTIVE =
102-
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
103-
export const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
104-
105100
export const SPEECH_RECOGNITION_LANG = 'en-US'
106101

107102
/**
Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
'use client'
22

33
import React from 'react'
4-
import { Button, cn } from '@sim/emcn'
4+
import { ComposerActionButton } from '@sim/emcn'
55
import { ArrowUp, StopFilled } from '@sim/emcn/icons'
6-
import {
7-
SEND_BUTTON_ACTIVE,
8-
SEND_BUTTON_BASE,
9-
SEND_BUTTON_DISABLED,
10-
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
116

127
interface SendButtonProps {
138
isSending: boolean
@@ -24,26 +19,23 @@ export const SendButton = React.memo(function SendButton({
2419
}: SendButtonProps) {
2520
if (isSending) {
2621
return (
27-
<Button
22+
<ComposerActionButton
2823
onClick={onStopGeneration}
29-
variant='ghost'
30-
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
3124
title='Stop generation'
3225
aria-label='Stop generation'
3326
>
3427
<StopFilled className='block h-[14px] w-[14px] fill-white dark:fill-black' />
35-
</Button>
28+
</ComposerActionButton>
3629
)
3730
}
3831
return (
39-
<Button
32+
<ComposerActionButton
4033
onClick={onSubmit}
4134
aria-label='Send message'
42-
variant='ghost'
4335
disabled={!canSubmit}
44-
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
36+
active={canSubmit}
4537
>
4638
<ArrowUp className='block h-[16px] w-[16px] text-white dark:text-black' />
47-
</Button>
39+
</ComposerActionButton>
4840
)
4941
})

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ vi.mock('@sim/emcn', () => ({
8080
<button {...props}>{children}</button>
8181
),
8282
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
83+
ComposerActionButton: ({
84+
children,
85+
size: _size,
86+
active: _active,
87+
...props
88+
}: ButtonHTMLAttributes<HTMLButtonElement> & { size?: string; active?: boolean }) => (
89+
<button {...props}>{children}</button>
90+
),
8391
Input: ({
8492
ref,
8593
className: _className,

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx‎

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState }
44
import {
55
Badge,
66
Button,
7+
ComposerActionButton,
78
cn,
89
Input,
910
Popover,
@@ -1102,34 +1103,28 @@ export function Chat() {
11021103
</Tooltip.Root>
11031104

11041105
{isStreaming ? (
1105-
<Button
1106+
<ComposerActionButton
11061107
aria-label='Stop generation'
11071108
onClick={handleStopStreaming}
1108-
variant='ghost'
1109-
className='size-[22px] rounded-full bg-[#383838] p-0 hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]'
1109+
size='sm'
11101110
>
11111111
<Square className='h-2.5 w-2.5 fill-white text-white dark:fill-black dark:text-black' />
1112-
</Button>
1112+
</ComposerActionButton>
11131113
) : (
1114-
<Button
1114+
<ComposerActionButton
11151115
aria-label='Send message'
11161116
onClick={handleSendMessage}
1117-
variant='ghost'
1117+
size='sm'
11181118
disabled={
11191119
(!chatMessage.trim() && chatFiles.length === 0) ||
11201120
!activeWorkflowId ||
11211121
isExecuting ||
11221122
isStreaming
11231123
}
1124-
className={cn(
1125-
'size-[22px] rounded-full p-0',
1126-
chatMessage.trim() || chatFiles.length > 0
1127-
? 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]'
1128-
: 'bg-[#808080] dark:bg-[#808080]'
1129-
)}
1124+
active={!!(chatMessage.trim() || chatFiles.length > 0)}
11301125
>
11311126
<ArrowUp className='size-3.5 text-white dark:text-black' />
1132-
</Button>
1127+
</ComposerActionButton>
11331128
)}
11341129
</div>
11351130
</div>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/** @vitest-environment jsdom */
2+
import { act, createRef, type ReactNode } from 'react'
3+
import { Button, ComposerActionButton } from '@sim/emcn'
4+
import { createRoot, type Root } from 'react-dom/client'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
7+
let root: Root | null = null
8+
let container: HTMLDivElement | null = null
9+
10+
function mount(children: ReactNode) {
11+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
12+
container = document.createElement('div')
13+
document.body.appendChild(container)
14+
root = createRoot(container)
15+
act(() => root?.render(children))
16+
return container
17+
}
18+
19+
function button() {
20+
const element = container?.querySelector('button')
21+
if (!element) throw new Error('Button did not render')
22+
return element
23+
}
24+
25+
afterEach(() => {
26+
if (root) act(() => root?.unmount())
27+
container?.remove()
28+
root = null
29+
container = null
30+
})
31+
32+
/** Exact pre-migration class inputs from the organization composer and workflow chat. */
33+
const PREVIOUS = {
34+
md: {
35+
base: 'size-[28px] rounded-full border-0 p-0',
36+
active: 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]',
37+
},
38+
sm: {
39+
base: 'size-[22px] rounded-full p-0',
40+
active: 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]',
41+
},
42+
} as const
43+
44+
describe('ComposerActionButton', () => {
45+
for (const size of ['md', 'sm'] as const) {
46+
for (const active of [true, false]) {
47+
it(`preserves the previous ${size} markup with active=${active}`, () => {
48+
const previous = PREVIOUS[size]
49+
const view = mount(
50+
<>
51+
<Button
52+
variant='ghost'
53+
aria-label='Send'
54+
className={`${previous.base} ${active ? previous.active : 'bg-[#808080] dark:bg-[#808080]'} shrink-0`}
55+
>
56+
<svg className='size-[16px] text-white dark:text-black' aria-hidden='true' />
57+
</Button>
58+
<ComposerActionButton
59+
aria-label='Send'
60+
size={size === 'md' ? undefined : size}
61+
active={active ? undefined : false}
62+
className='shrink-0'
63+
>
64+
<svg className='size-[16px] text-white dark:text-black' aria-hidden='true' />
65+
</ComposerActionButton>
66+
</>
67+
)
68+
const [before, after] = view.querySelectorAll('button')
69+
before.className = before.className.split(/\s+/).sort().join(' ')
70+
after.className = after.className.split(/\s+/).sort().join(' ')
71+
expect(after.outerHTML).toBe(before.outerHTML)
72+
})
73+
}
74+
}
75+
76+
it('forwards refs and events while keeping active appearance independent of disabled', () => {
77+
const ref = createRef<HTMLButtonElement>()
78+
const onClick = vi.fn()
79+
const onKeyDown = vi.fn()
80+
const action = (disabled: boolean) => (
81+
<ComposerActionButton
82+
ref={ref}
83+
aria-label='Send message'
84+
data-action='send'
85+
active
86+
disabled={disabled}
87+
onClick={onClick}
88+
onKeyDown={onKeyDown}
89+
/>
90+
)
91+
mount(action(false))
92+
expect(ref.current).toBe(button())
93+
expect(button().dataset.action).toBe('send')
94+
act(() => button().focus())
95+
expect(document.activeElement).toBe(button())
96+
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
97+
act(() => button().dispatchEvent(keyEvent))
98+
expect(onKeyDown).toHaveBeenCalledTimes(1)
99+
expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent)
100+
act(() => button().click())
101+
expect(onClick).toHaveBeenCalledTimes(1)
102+
const activeClasses = button().className
103+
act(() => root?.render(action(true)))
104+
expect(button().disabled).toBe(true)
105+
expect(button().className).toBe(activeClasses)
106+
act(() => button().click())
107+
expect(onClick).toHaveBeenCalledTimes(1)
108+
})
109+
110+
for (const type of [undefined, 'button'] as const) {
111+
it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => {
112+
const onSubmit = vi.fn((event) => event.preventDefault())
113+
mount(
114+
<form onSubmit={onSubmit}>
115+
<ComposerActionButton aria-label='Search' type={type} />
116+
</form>
117+
)
118+
act(() => button().click())
119+
expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1)
120+
})
121+
}
122+
})

0 commit comments

Comments
 (0)