diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx
index b5abcaf47a4..7023aa4f226 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx
@@ -4,12 +4,13 @@ import { useMemo, useRef } from 'react'
import {
Badge,
Button,
+ CollapsibleCard,
Combobox,
type ComboboxOption,
cn,
- handleKeyboardActivation,
Input,
Label,
+ OverflowText,
Trash,
} from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
@@ -235,55 +236,46 @@ export function DocumentTagEntry({
}
/**
- * Renders the tag header with name, badge, and action buttons
+ * Renders the tag summary with its type badge
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
- const renderTagHeader = (tag: DocumentTag, index: number) => (
-
toggleCollapse(tag.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => toggleCollapse(tag.id))
- }}
- >
-
-
- {tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
-
- {tag.collapsed && tag.tagName && (
-
- {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}
-
- )}
-
-
e.stopPropagation()}
+ const renderTitle = (tag: DocumentTag, index: number) => (
+
+
-
-
- Add Tag
-
- removeTag(tag.id)}
- disabled={isReadOnly}
- className='h-auto p-0'
- >
-
- Delete Tag
-
-
-
+ {tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
+
+ {tag.collapsed && tag.tagName && (
+
+ {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}
+
+ )}
+
+ )
+
+ const renderActions = (tag: DocumentTag) => (
+ <>
+
+
+ Add Tag
+
+ removeTag(tag.id)}
+ disabled={isReadOnly}
+ className='h-auto p-0'
+ >
+
+ Delete Tag
+
+ >
)
/**
@@ -385,7 +377,7 @@ export function DocumentTagEntry({
}))
return (
-
+ <>
Tag
Value
{renderValueInput(tag)}
-
+ >
)
}
return (
{tags.map((tag, index) => (
-
toggleCollapse(tag.id)}
>
- {renderTagHeader(tag, index)}
{!tag.collapsed && renderTagContent(tag)}
-
+
))}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx
index d41572a72d6..7e0e4375b3b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx
@@ -2,12 +2,13 @@ import { useRef } from 'react'
import {
Badge,
Button,
+ CollapsibleCard,
Combobox,
type ComboboxOption,
cn,
- handleKeyboardActivation,
Input,
Label,
+ OverflowText,
Trash,
} from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
@@ -102,56 +103,46 @@ export function FilterRuleRow({
label,
})
- const renderHeader = () => (
- onToggleCollapse(rule.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => onToggleCollapse(rule.id))
- }}
- >
-
-
- {rule.collapsed && rule.column
- ? formatDisplayText(getColumnLabel(rule.column), {
- workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)),
- })
- : `Condition ${index + 1}`}
-
- {rule.collapsed && rule.column && (
-
- {formatDisplayText(getOperatorLabel(rule.operator), {
- workflowSearchHighlight: getLabelHighlight(
- 'operator',
- getOperatorLabel(rule.operator)
- ),
- })}
-
- )}
-
-
e.stopPropagation()}
+ const renderTitle = () => (
+
+
-
-
- Add Condition
-
- onRemove(rule.id)}
- disabled={isReadOnly}
- className='h-auto p-0'
- >
-
- Delete Condition
-
-
-
+ {rule.collapsed && rule.column
+ ? formatDisplayText(getColumnLabel(rule.column), {
+ workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)),
+ })
+ : `Condition ${index + 1}`}
+
+ {rule.collapsed && rule.column && (
+
+ {formatDisplayText(getOperatorLabel(rule.operator), {
+ workflowSearchHighlight: getLabelHighlight('operator', getOperatorLabel(rule.operator)),
+ })}
+
+ )}
+
+ )
+
+ const renderActions = () => (
+ <>
+
+
+ Add Condition
+
+ onRemove(rule.id)}
+ disabled={isReadOnly}
+ className='h-auto p-0'
+ >
+
+ Delete Condition
+
+ >
)
const renderValueInput = () => (
@@ -209,7 +200,7 @@ export function FilterRuleRow({
)
const renderContent = () => (
-
+ <>
{index > 0 && (
Logic
@@ -281,19 +272,20 @@ export function FilterRuleRow({
Value
{renderValueInput()}
-
+ >
)
return (
- onToggleCollapse(rule.id)}
>
- {renderHeader()}
{!rule.collapsed && renderContent()}
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx
index b5ca52dd694..dc4e6b62c34 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx
@@ -4,12 +4,13 @@ import { useRef } from 'react'
import {
Badge,
Button,
+ CollapsibleCard,
Combobox,
type ComboboxOption,
cn,
- handleKeyboardActivation,
Input,
Label,
+ OverflowText,
Trash,
} from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
@@ -228,57 +229,52 @@ export function KnowledgeTagFilters({
}
/**
- * Renders the filter header with name, badge, and action buttons
+ * Renders the filter summary with its type badge
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
- const renderFilterHeader = (filter: TagFilter, index: number) => (
- toggleCollapse(filter.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => toggleCollapse(filter.id))
- }}
- >
-
-
- {filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
-
- {filter.collapsed && filter.tagName && (
-
- {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}
-
- )}
-
-
-
{
- e.stopPropagation()
- addFilter()
- }}
- disabled={isReadOnly}
- className='h-auto p-0'
- >
-
- Add Filter
-
-
{
- e.stopPropagation()
- removeFilter(filter.id)
- }}
- disabled={isReadOnly}
- className='h-auto p-0'
- >
-
- Delete Filter
-
-
-
+ const renderTitle = (filter: TagFilter, index: number) => (
+
+
+ {filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
+
+ {filter.collapsed && filter.tagName && (
+
+ {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}
+
+ )}
+
+ )
+
+ const renderActions = (filter: TagFilter) => (
+ <>
+ {
+ e.stopPropagation()
+ addFilter()
+ }}
+ disabled={isReadOnly}
+ className='h-auto p-0'
+ >
+
+ Add Filter
+
+ {
+ e.stopPropagation()
+ removeFilter(filter.id)
+ }}
+ disabled={isReadOnly}
+ className='h-auto p-0'
+ >
+
+ Delete Filter
+
+ >
)
/**
@@ -383,7 +379,7 @@ export function KnowledgeTagFilters({
const isBetween = filter.operator === 'between'
return (
-
+ <>
Tag
-
+ >
)
}
return (
{filters.map((filter, index) => (
-
toggleCollapse(filter.id)}
>
- {renderFilterHeader(filter, index)}
{!filter.collapsed && renderFilterContent(filter)}
-
+
))}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx
index 329adbef3c6..d831a90a785 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx
@@ -1,11 +1,11 @@
import {
Badge,
Button,
+ CollapsibleCard,
Combobox,
type ComboboxOption,
- cn,
- handleKeyboardActivation,
Label,
+ OverflowText,
Trash,
} from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
@@ -61,60 +61,51 @@ export function SortRuleRow({
label,
})
- const renderHeader = () => (
-
onToggleCollapse(rule.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => onToggleCollapse(rule.id))
- }}
- >
-
-
- {rule.collapsed && rule.column
- ? formatDisplayText(getColumnLabel(rule.column), {
- workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)),
- })
- : `Sort ${index + 1}`}
-
- {rule.collapsed && rule.column && (
-
- {formatDisplayText(getDirectionLabel(rule.direction), {
- workflowSearchHighlight: getLabelHighlight(
- 'direction',
- getDirectionLabel(rule.direction)
- ),
- })}
-
- )}
-
-
e.stopPropagation()}
+ const renderTitle = () => (
+
+
-
-
- Add Sort
-
- onRemove(rule.id)}
- disabled={isReadOnly}
- className='h-auto p-0'
- >
-
- Delete Sort
-
-
-
+ {rule.collapsed && rule.column
+ ? formatDisplayText(getColumnLabel(rule.column), {
+ workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)),
+ })
+ : `Sort ${index + 1}`}
+
+ {rule.collapsed && rule.column && (
+
+ {formatDisplayText(getDirectionLabel(rule.direction), {
+ workflowSearchHighlight: getLabelHighlight(
+ 'direction',
+ getDirectionLabel(rule.direction)
+ ),
+ })}
+
+ )}
+
+ )
+
+ const renderActions = () => (
+ <>
+
+
+ Add Sort
+
+
onRemove(rule.id)}
+ disabled={isReadOnly}
+ className='h-auto p-0'
+ >
+
+ Delete Sort
+
+ >
)
const renderContent = () => (
-
+ >
)
return (
-
onToggleCollapse(rule.id)}
>
- {renderHeader()}
{!rule.collapsed && renderContent()}
-
+
)
}
diff --git a/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx b/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx
new file mode 100644
index 00000000000..48c2002f869
--- /dev/null
+++ b/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx
@@ -0,0 +1,94 @@
+/** @vitest-environment jsdom */
+import { act, type ReactNode, useState } from 'react'
+import { CollapsibleCard } from '@sim/emcn'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+let container: HTMLDivElement | null = null
+let root: Root | 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))
+}
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+})
+
+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)}
+ >
+
+
+ )
+ }
+ 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()
+ const add = vi.fn()
+ const remove = vi.fn()
+ const parentClick = vi.fn()
+ mount(
+ Long condition name}
+ badge={Text }
+ collapsed
+ onToggleCollapse={toggle}
+ onClick={parentClick}
+ actions={
+ <>
+
+ Add
+
+
+ Delete
+
+ >
+ }
+ >
+ Content
+
+ )
+ const trigger = container!.querySelector('[role="button"]')!
+ const [addButton, deleteButton] = container!.querySelectorAll('button')
+ expect(trigger.contains(addButton)).toBe(false)
+ act(() => {
+ addButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ addButton.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
+ addButton.click()
+ deleteButton.click()
+ })
+ expect(add).toHaveBeenCalledTimes(1)
+ expect(remove).not.toHaveBeenCalled()
+ expect(toggle).not.toHaveBeenCalled()
+ expect(parentClick).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
index 661bab113c9..7dbc6a76b8a 100644
--- a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
+++ b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx
@@ -5,47 +5,65 @@ import { cn } from '../../lib/cn'
import { handleKeyboardActivation } from '../../lib/keyboard'
import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-text'
-export interface CollapsibleCardProps {
+export interface CollapsibleCardProps
+ extends Omit, 'title' | 'children'> {
/** Header label rendered with the standard fade-only overflow treatment. */
title: React.ReactNode
/** Optional trailing header content, e.g. a type `Badge`. */
badge?: React.ReactNode
+ /** Header actions, outside the collapse target and arranged with standard spacing. */
+ actions?: React.ReactNode
collapsed: boolean
onToggleCollapse: () => void
/** Body content, shown when expanded. */
children: React.ReactNode
- className?: string
}
/**
* 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.
+ *
+ * @example
+ * Add condition}>
+ * {fields}
+ *
*/
export function CollapsibleCard({
title,
badge,
+ actions,
collapsed,
onToggleCollapse,
children,
className,
+ ...props
}: CollapsibleCardProps) {
return (
-
handleKeyboardActivation(event, onToggleCollapse)}
- >
-
+
+
{
+ if (event.target !== event.currentTarget) return
+ handleKeyboardActivation(event, onToggleCollapse)
+ }}
+ >
{typeof title === 'string' || typeof title === 'number' ? (
+ {actions && (
+ event.stopPropagation()}
+ >
+ {actions}
+
+ )}
{!collapsed && (