)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx
index e79ab62abcd..684602b559c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx
@@ -1,7 +1,7 @@
-import { Combobox, cn, Label, Tooltip } from '@sim/emcn'
-import { ArrowLeftRight } from '@sim/emcn/icons'
+import { Combobox, Label } from '@sim/emcn'
import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility'
import type { StoredTool } from '@/lib/workflows/tool-input/types'
+import { FieldModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-mode-toggle/field-mode-toggle'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input'
import type { SubBlockConfig } from '@/blocks/types'
@@ -63,29 +63,12 @@ export function ToolUsageControl({
{mode === 'advanced' ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
index 5ce53f38f50..84b832a7cbb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
@@ -2,18 +2,19 @@ import { useEffect, useRef, useState } from 'react'
import {
Badge,
Button,
+ CollapsibleCard,
Combobox,
type ComboboxOption,
cn,
- handleKeyboardActivation,
Input,
Label,
+ OverflowText,
Textarea,
- Tooltip,
} from '@sim/emcn'
-import { ArrowLeftRight, FileText, Plus, Trash } from '@sim/emcn/icons'
+import { FileText, Plus, Trash } from '@sim/emcn/icons'
import { generateId } from '@sim/utils/id'
import { useParams } from 'next/navigation'
+import { FieldModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-mode-toggle/field-mode-toggle'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
@@ -382,39 +383,32 @@ export function VariablesInput({
})
return (
-
-
toggleCollapse(assignment.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => toggleCollapse(assignment.id))
- }}
- >
-
-
+ title={
+
+
{assignment.variableName
? formatDisplayText(assignment.variableName, {
workflowSearchHighlight: variableLabelHighlight,
})
: `Variable ${index + 1}`}
-
+
{assignment.variableName && (
{assignment.type}
)}
-
-
+
+ }
+ 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
+ }
+ />
- {!collapsed && (
-
-
-
-
({ label: v.name, value: v.id }))}
- value={assignment.variableId || ''}
- onChange={(value) => handleVariableSelect(assignment.id, value)}
- placeholder='Select a variable...'
+
+
+
+ {assignment.type === 'boolean' && (
+
- {formatDisplayText(variableLabel, {
- workflowSearchHighlight: variableLabelHighlight,
- })}
-
- ) : undefined
+ onClick={() =>
+ setManualBooleanModes((prev) => ({
+ ...prev,
+ [assignment.id]: !isManualBoolean,
+ }))
}
/>
-
-
-
-
-
- {assignment.type === 'boolean' && (
-
-
-
-
-
-
- {isManualBoolean ? 'Switch to selector' : 'Switch to manual value'}
-
-
-
- )}
-
- {assignment.type === 'boolean' && !isManualBoolean ? (
-
- !isReadOnly && updateAssignment(assignment.id, { value: v })
+ )}
+
+ {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' ? (
+
+ )}
+
+ {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/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
index 4795928d1c6..7b40f64332f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
@@ -1,13 +1,6 @@
import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react'
import { Button, cn, Input, Label, Tooltip } from '@sim/emcn'
-import {
- ArrowLeftRight,
- ArrowUp,
- Check,
- Clipboard,
- SquareArrowUpRight,
- TriangleAlert,
-} from '@sim/emcn/icons'
+import { ArrowUp, Check, Clipboard, SquareArrowUpRight, TriangleAlert } from '@sim/emcn/icons'
import { isEqual } from 'es-toolkit'
import { useParams } from 'next/navigation'
import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants'
@@ -54,6 +47,7 @@ import {
WorkflowSelectorInput,
WorkspaceFolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
+import { FieldModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-mode-toggle/field-mode-toggle'
import { MODAL_REGISTRY } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/modal-registry'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import type { SubBlockConfig } from '@/blocks/types'
@@ -351,7 +345,8 @@ const renderLabel = (
e.stopPropagation()
wandState.onSearchSubmit()
}}
- className='size-[20px] shrink-0 p-0'
+ size='icon'
+ className='shrink-0'
>
@@ -377,37 +372,14 @@ const renderLabel = (
)}
{showCanonicalToggle && (
-
-
-
-
-
-
- {canonicalToggle?.mode === 'advanced'
- ? 'Switch to selector'
- : 'Switch to manual ID'}
-
-
-
+
)}
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..34175fed4b2 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..12da6d82427 100644
--- a/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx
+++ b/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx
@@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
import { act, type ReactNode, useState } from 'react'
-import { CollapsibleCard } from '@sim/emcn'
+import { CollapsibleCard, FieldCard } from '@sim/emcn'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -23,32 +23,75 @@ 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('prevents disabled header activation without disabling independent actions', () => {
+ const toggle = vi.fn()
+ const action = vi.fn()
+ mount(
+
+ Refresh
+
+ }
+ >
+ Content
+
+ )
+ const trigger = container!.querySelector('[role="button"]')!
+ expect(trigger.tabIndex).toBe(-1)
+ expect(trigger.getAttribute('aria-disabled')).toBe('true')
+ act(() => {
+ trigger.click()
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
+ container!.querySelector('button')!.click()
+ })
+ expect(toggle).not.toHaveBeenCalled()
+ expect(action).toHaveBeenCalledTimes(1)
+ expect(container!.textContent).not.toContain('Content')
})
it('keeps enabled and disabled actions outside the collapse target', () => {
@@ -92,3 +135,19 @@ describe('CollapsibleCard', () => {
expect(parentClick).not.toHaveBeenCalled()
})
})
+
+it('keeps static FieldCard content visible without a collapse target', () => {
+ mount(
+ string} data-field='query'>
+
+
+ )
+ const card = container!.querySelector('[data-field="query"]')!
+ const input = card.querySelector('input')!
+ expect(card.textContent).toContain('query')
+ expect(card.textContent).toContain('string')
+ expect(card.querySelector('[role="button"], button, [aria-expanded]')).toBeNull()
+ act(() => card.querySelector('[data-overflow-text]')!.click())
+ expect(card.querySelector('input')).toBe(input)
+ expect(input.value).toBe('Search terms')
+})
diff --git a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
index 7dbc6a76b8a..8bb9c460891 100644
--- a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
+++ b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
@@ -3,6 +3,8 @@
import type * as React from 'react'
import { cn } from '../../lib/cn'
import { handleKeyboardActivation } from '../../lib/keyboard'
+import { Expandable, ExpandableContent } from '../expandable/expandable'
+import { FieldCardContent, FieldCardFrame } from '../field-card/field-card'
import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-text'
export interface CollapsibleCardProps
@@ -13,8 +15,14 @@ export interface CollapsibleCardProps
badge?: React.ReactNode
/** Header actions, outside the collapse target and arranged with standard spacing. */
actions?: React.ReactNode
+ /** Prevent header activation and remove it from tab order; independent actions stay available. */
+ disabled?: boolean
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
}
@@ -23,6 +31,7 @@ export interface CollapsibleCardProps
* A collapsible field card: a `--surface-4` header (click / keyboard to toggle)
* with a fade-clipped title + optional badge, over a `--surface-2` body. Shared by
* the workflow input-mapping rows and the enrichment output-column config.
+ * Its frame and body are also used by the always-open FieldCard.
*
* @example
* {children}
return (
-
-
-
{
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, onToggleCollapse)
- }}
- >
- {typeof title === 'string' || typeof title === 'number' ? (
-
- ) : (
-
- {title}
-
- )}
- {badge}
-
- {actions && (
+ className={cn(collapsed ? 'overflow-hidden' : 'overflow-visible', className)}
+ header={
+ <>
event.stopPropagation()}
+ role='button'
+ tabIndex={disabled ? -1 : 0}
+ aria-expanded={!collapsed}
+ aria-disabled={disabled || undefined}
+ aria-controls={contentProps?.id}
+ className={cn(
+ 'flex min-w-0 flex-1 items-center gap-2 px-2.5 py-[5px]',
+ !disabled && 'cursor-pointer',
+ actions && 'pr-2'
+ )}
+ onClick={disabled ? undefined : onToggleCollapse}
+ onKeyDown={(event) => {
+ if (disabled || event.target !== event.currentTarget) return
+ handleKeyboardActivation(event, onToggleCollapse)
+ }}
>
- {actions}
+ {typeof title === 'string' || typeof title === 'number' ? (
+
+ ) : (
+
+ {title}
+
+ )}
+ {badge}
- )}
-
- {!collapsed && (
-
- {children}
-
+ {actions && (
+
event.stopPropagation()}
+ >
+ {actions}
+
+ )}
+ >
+ }
+ >
+ {animated ? (
+
+ {content}
+
+ ) : (
+ !collapsed && content
)}
-
+
)
}
diff --git a/packages/emcn/src/components/field-card/field-card.tsx b/packages/emcn/src/components/field-card/field-card.tsx
new file mode 100644
index 00000000000..c5f05745635
--- /dev/null
+++ b/packages/emcn/src/components/field-card/field-card.tsx
@@ -0,0 +1,74 @@
+import type * as React from 'react'
+import { cn } from '../../lib/cn'
+import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-text'
+
+export interface FieldCardProps
+ extends Omit, 'title' | 'children'> {
+ /** Field name; plain text uses the standard fade and full-value tooltip. */
+ title: React.ReactNode
+ /** Optional content adjacent to the field name, such as a type badge. */
+ badge?: React.ReactNode
+ /** Always-visible field controls or description. */
+ children: React.ReactNode
+}
+
+interface FieldCardFrameProps extends React.HTMLAttributes {
+ header: React.ReactNode
+}
+
+/** Internal frame shared by static and collapsible field cards. */
+export function FieldCardFrame({ header, children, className, ...props }: FieldCardFrameProps) {
+ return (
+
+
+ {header}
+
+ {children}
+
+ )
+}
+
+/** Internal body shared by static and collapsible field cards. */
+export function FieldCardContent({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+/**
+ * An always-open field card with the same frame and body as CollapsibleCard.
+ * The header is a label, not a button; controls retain their own behavior.
+ *
+ * @example
+ * string}>
+ * {descriptionField}
+ *
+ */
+export function FieldCard({ title, badge, children, className, ...props }: FieldCardProps) {
+ return (
+
+ {typeof title === 'string' || typeof title === 'number' ? (
+
+ ) : (
+
+ {title}
+
+ )}
+ {badge}
+
+ }
+ >
+
{children}
+
+ )
+}
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index b194122e642..b26ee162548 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -150,6 +150,7 @@ export {
dropdownMenuRowClass,
} from './dropdown-menu/dropdown-menu'
export { Expandable, ExpandableContent } from './expandable/expandable'
+export { FieldCard, type FieldCardProps } from './field-card/field-card'
export { DashedDividerLine, FieldDivider } from './field-divider/field-divider'
export { Info } from './info/info'
export {