toggleCollapse(assignment.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => toggleCollapse(assignment.id))
- }}
- >
-
+
+ }
+ collapsed={collapsed}
+ onToggleCollapse={() => toggleCollapse(assignment.id)}
+ actions={
+ <>
-
-
-
-
({ label: v.name, value: v.id }))}
- value={assignment.variableId || ''}
- onChange={(value) => handleVariableSelect(assignment.id, value)}
- placeholder='Select a variable...'
- disabled={isReadOnly}
- overlayContent={
- variableLabelHighlight ? (
-
- {formatDisplayText(variableLabel, {
- workflowSearchHighlight: variableLabelHighlight,
- })}
-
- ) : undefined
+
+
+
+ {assignment.type === 'boolean' && (
+
+
+
+
+
+ {isManualBoolean ? 'Switch to selector' : 'Switch to manual value'}
+
+
+ )}
+
+ {assignment.type === 'boolean' && !isManualBoolean ? (
+
!isReadOnly && updateAssignment(assignment.id, { value: v })}
+ placeholder='Select value'
+ disabled={isReadOnly}
+ overlayContent={
+ booleanLabelHighlight ? (
+
+ {formatDisplayText(assignment.value ?? '', {
+ workflowSearchHighlight: booleanLabelHighlight,
+ })}
+
+ ) : undefined
+ }
+ />
+ ) : assignment.type === 'object' || assignment.type === 'array' ? (
+
+
-
-
-
-
- {assignment.type === 'boolean' && (
-
-
-
-
-
-
- {isManualBoolean ? 'Switch to selector' : 'Switch to manual value'}
-
-
-
+
{
+ if (el) overlayRefs.current[assignment.id] = el
+ }}
+ className={cn(
+ 'absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm',
+ !isReadOnly && 'pointer-events-none'
)}
+ style={{ scrollbarWidth: 'none' }}
+ >
+
+ {formatDisplayText(assignment.value || '', {
+ accessiblePrefixes,
+ highlightAll: !accessiblePrefixes,
+ workflowSearchHighlight: valueSearchHighlight,
+ })}
+
- {assignment.type === 'boolean' && !isManualBoolean ? (
-
- !isReadOnly && updateAssignment(assignment.id, { value: v })
- }
- placeholder='Select value'
- disabled={isReadOnly}
- overlayContent={
- booleanLabelHighlight ? (
-
- {formatDisplayText(assignment.value ?? '', {
- workflowSearchHighlight: booleanLabelHighlight,
- })}
-
- ) : undefined
+
+ ) : (
+
+
{
+ if (el) valueInputRefs.current[assignment.id] = el
+ }}
+ name='value'
+ value={assignment.value || ''}
+ onChange={(e) =>
+ handleValueInputChange(
+ assignment.id,
+ e.target.value,
+ e.target.selectionStart ?? undefined
+ )
+ }
+ onKeyDown={handleKeyDown}
+ onFocus={() => {
+ if (!isReadOnly && !assignment.value?.trim()) {
+ setActiveFieldId(assignment.id)
+ setCursorPosition(0)
+ setShowTags(true)
}
- />
- ) : assignment.type === 'object' || assignment.type === 'array' ? (
-
- ) : (
-
-
{
- if (el) valueInputRefs.current[assignment.id] = el
- }}
- name='value'
- value={assignment.value || ''}
- onChange={(e) =>
- handleValueInputChange(
+ }}
+ onScroll={(e) =>
+ syncOverlayScroll(assignment.id, e.currentTarget.scrollLeft)
+ }
+ onPaste={() =>
+ setTimeout(() => {
+ const input = valueInputRefs.current[assignment.id]
+ if (input)
+ syncOverlayScroll(
assignment.id,
- e.target.value,
- e.target.selectionStart ?? undefined
+ (input as HTMLInputElement).scrollLeft
)
- }
- onKeyDown={handleKeyDown}
- onFocus={() => {
- if (!isReadOnly && !assignment.value?.trim()) {
- setActiveFieldId(assignment.id)
- setCursorPosition(0)
- setShowTags(true)
- }
- }}
- onScroll={(e) =>
- syncOverlayScroll(assignment.id, e.currentTarget.scrollLeft)
- }
- onPaste={() =>
- setTimeout(() => {
- const input = valueInputRefs.current[assignment.id]
- if (input)
- syncOverlayScroll(
- assignment.id,
- (input as HTMLInputElement).scrollLeft
- )
- }, 0)
- }
- placeholder={`${assignment.type} value`}
- disabled={isReadOnly}
- autoComplete='off'
- className={cn(
- 'allow-scroll w-full overflow-x-auto overflow-y-hidden text-transparent caret-foreground [letter-spacing:inherit]',
- dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
- )}
- onDrop={(e) => handleDrop(e, assignment.id)}
- onDragOver={(e) => handleDragOver(e, assignment.id)}
- onDragLeave={(e) => handleDragLeave(e, assignment.id)}
- />
-
{
- if (el) overlayRefs.current[assignment.id] = el
- }}
- className={cn(
- 'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-2 py-1.5 font-sans text-sm',
- !isReadOnly && 'pointer-events-none'
- )}
- style={{ scrollbarWidth: 'none' }}
- >
-
- {formatDisplayText(
- assignment.value || '',
- accessiblePrefixes
- ? {
- accessiblePrefixes,
- workflowSearchHighlight: valueSearchHighlight,
- }
- : {
- highlightAll: true,
- workflowSearchHighlight: valueSearchHighlight,
- }
- )}
-
-
+ }, 0)
+ }
+ placeholder={`${assignment.type} value`}
+ disabled={isReadOnly}
+ autoComplete='off'
+ className={cn(
+ 'allow-scroll w-full overflow-x-auto overflow-y-hidden text-transparent caret-foreground [letter-spacing:inherit]',
+ dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
+ )}
+ onDrop={(e) => handleDrop(e, assignment.id)}
+ onDragOver={(e) => handleDragOver(e, assignment.id)}
+ onDragLeave={(e) => handleDragLeave(e, assignment.id)}
+ />
+
{
+ if (el) overlayRefs.current[assignment.id] = el
+ }}
+ className={cn(
+ 'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-2 py-1.5 font-sans text-sm',
+ !isReadOnly && 'pointer-events-none'
+ )}
+ style={{ scrollbarWidth: 'none' }}
+ >
+
+ {formatDisplayText(
+ assignment.value || '',
+ accessiblePrefixes
+ ? {
+ accessiblePrefixes,
+ workflowSearchHighlight: valueSearchHighlight,
+ }
+ : {
+ highlightAll: true,
+ workflowSearchHighlight: valueSearchHighlight,
+ }
+ )}
- )}
-
- {showTags && activeFieldId === assignment.id && (
-
setShowTags(false)}
- inputRef={
- {
- current: valueInputRefs.current[assignment.id] || null,
- } as React.RefObject
- }
- />
- )}
+
-
- )}
-
+ )}
+
+ {showTags && activeFieldId === assignment.id && (
+ setShowTags(false)}
+ inputRef={
+ {
+ current: valueInputRefs.current[assignment.id] || null,
+ } as React.RefObject
+ }
+ />
+ )}
+
+
)
})}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
index 6b2ee09e620..61ca1967060 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
@@ -5,15 +5,16 @@ import {
Badge,
Button,
Code,
+ CollapsibleCard,
Combobox,
type ComboboxOption,
calculateGutterWidth,
- cn,
getCodeEditorProps,
highlight,
Input,
Label,
languages,
+ OverflowText,
} from '@sim/emcn'
import { Plus, Trash, X } from '@sim/emcn/icons'
import Editor from 'react-simple-code-editor'
@@ -83,67 +84,6 @@ const STRINGS = {
emptyState: 'No variables yet',
}
-interface VariableHeaderProps {
- variable: Variable
- index: number
- isCollapsed: boolean
- onToggleCollapse: () => void
- onRemove: () => void
- readOnly: boolean
-}
-
-function VariableHeader({
- variable,
- index,
- isCollapsed,
- onToggleCollapse,
- onRemove,
- readOnly,
-}: VariableHeaderProps) {
- function handleHeaderKeyDown(e: React.KeyboardEvent) {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- onToggleCollapse()
- }
- }
-
- return (
-
-
-
- {variable.name || `Variable ${index + 1}`}
-
- {variable.name && (
-
- {variable.type}
-
- )}
-
-
-
- )
-}
-
interface VariableValueInputProps {
variable: Variable
onUpdate: (variableId: string, field: 'name' | 'value' | 'type', value: any) => void
@@ -491,69 +431,81 @@ export function Variables({ readOnly = false }: VariablesProps) {
{workflowVariables.map((variable, index) => (
-
-
toggleCollapsed(variable.id)}
- onRemove={() => handleRemoveVariable(variable.id)}
- readOnly={readOnly}
- />
-
- {!(collapsedById[variable.id] ?? false) && (
-
+
+ {variable.name && (
+
+ {variable.type}
+
+ )}
+
+ }
+ collapsed={collapsedById[variable.id] ?? false}
+ onToggleCollapse={() => toggleCollapsed(variable.id)}
+ contentProps={{ id: `variable-content-${variable.id}`, className: 'gap-1.5' }}
+ actions={
+
+ }
+ >
+
+
+
handleVariableNameChange(variable.id, e.target.value)}
+ onBlur={() => handleVariableNameBlur(variable.id)}
+ onKeyDown={handleVariableNameKeyDown}
+ placeholder={STRINGS.placeholders.name}
+ disabled={readOnly}
+ />
+ {nameErrors[variable.id] && (
+
+ {nameErrors[variable.id]}
+
+ )}
+
+
+
+
+ handleUpdateVariable(variable.id, 'type', value)}
+ disabled={readOnly}
+ />
+
+
+
+
+
+
- )}
-
+
+
))}
diff --git a/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx b/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx
index 48c2002f869..2a46d78eaef 100644
--- a/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx
+++ b/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx
@@ -23,33 +23,44 @@ afterEach(() => {
})
describe('CollapsibleCard', () => {
- it('toggles controlled content by click, Enter and Space and forwards root attributes', () => {
- function Example() {
- const [collapsed, setCollapsed] = useState(true)
- return (
-
setCollapsed((value) => !value)}
- >
-
-
+ it.each([false, true])(
+ 'toggles controlled content and links its body (animated: %s)',
+ (animated) => {
+ function Example() {
+ const [collapsed, setCollapsed] = useState(true)
+ return (
+
setCollapsed((value) => !value)}
+ >
+
+
+ )
+ }
+ mount(
)
+ const card = container!.querySelector('[data-filter-id="condition-1"]')!
+ const trigger = card.querySelector
('[role="button"]')!
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
+ expect(trigger.getAttribute('aria-controls')).toBe('condition-fields')
+ expect(card.querySelector('input')).toBeNull()
+ act(() => trigger.click())
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
+ expect(card.querySelector('#condition-fields input')?.getAttribute('aria-label')).toBe(
+ 'Value'
)
+ expect(card.querySelector('input')?.value).toBe('Example')
+ act(() =>
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ )
+ expect(card.querySelector('input')).toBeNull()
+ act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })))
+ expect(card.querySelector('input')).not.toBeNull()
}
- mount()
- const card = container!.querySelector('[data-filter-id="condition-1"]')!
- const trigger = card.querySelector('[role="button"]')!
- expect(trigger.getAttribute('aria-expanded')).toBe('false')
- expect(card.querySelector('input')).toBeNull()
- act(() => trigger.click())
- expect(trigger.getAttribute('aria-expanded')).toBe('true')
- expect(card.querySelector('input')?.value).toBe('Example')
- act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
- expect(card.querySelector('input')).toBeNull()
- act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })))
- expect(card.querySelector('input')).not.toBeNull()
- })
+ )
it('keeps enabled and disabled actions outside the collapse target', () => {
const toggle = vi.fn()
diff --git a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
index 7dbc6a76b8a..a2e9a640c68 100644
--- a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
+++ b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
@@ -3,6 +3,7 @@
import type * as React from 'react'
import { cn } from '../../lib/cn'
import { handleKeyboardActivation } from '../../lib/keyboard'
+import { Expandable, ExpandableContent } from '../expandable/expandable'
import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-text'
export interface CollapsibleCardProps
@@ -15,6 +16,10 @@ export interface CollapsibleCardProps
actions?: React.ReactNode
collapsed: boolean
onToggleCollapse: () => void
+ /** Animate expansion using the shared Expandable height transition. */
+ animated?: boolean
+ /** Native body attributes and layout, including an ID linked from the trigger. */
+ contentProps?: React.HTMLAttributes
/** Body content, shown when expanded. */
children: React.ReactNode
}
@@ -36,10 +41,23 @@ export function CollapsibleCard({
actions,
collapsed,
onToggleCollapse,
+ animated = false,
+ contentProps,
children,
className,
...props
}: CollapsibleCardProps) {
+ const content = (
+
+ {children}
+
+ )
return (
)}
- {!collapsed && (
-
- {children}
-
+ {animated ? (
+
+ {content}
+
+ ) : (
+ !collapsed && content
)}
)