Skip to content

Commit a483bfa

Browse files
committed
Merge visual switch prerequisite for canonical tool mode
2 parents 621bf55 + 80dccd1 commit a483bfa

6 files changed

Lines changed: 245 additions & 39 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { IconSwitch } from '@sim/emcn'
2+
import { List } from '@sim/emcn/icons'
3+
import { VariableIcon } from '@/components/icons'
4+
import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility'
5+
6+
interface CanonicalModeToggleProps {
7+
mode: CanonicalMode
8+
disabled?: boolean
9+
onToggle?: () => void
10+
}
11+
12+
const MODE_OPTIONS = [
13+
{ value: 'basic', label: 'Selector', icon: List },
14+
{ value: 'advanced', label: 'Variable', icon: VariableIcon },
15+
] as const
16+
17+
export function CanonicalModeToggle({ mode, disabled, onToggle }: CanonicalModeToggleProps) {
18+
return (
19+
<IconSwitch
20+
options={MODE_OPTIONS}
21+
value={mode}
22+
onValueChange={() => onToggle?.()}
23+
disabled={disabled}
24+
showTooltips
25+
aria-label='Input mode'
26+
/>
27+
)
28+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { CanonicalModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle'
12
export { CheckboxList } from './checkbox-list'
23
export { Code } from './code'
34
export { ComboBox } from './combobox'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx

Lines changed: 7 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react'
22
import { Button, cn, Input, Label, Tooltip } from '@sim/emcn'
3-
import {
4-
ArrowLeftRight,
5-
ArrowUp,
6-
Check,
7-
Clipboard,
8-
SquareArrowUpRight,
9-
TriangleAlert,
10-
} from '@sim/emcn/icons'
3+
import { ArrowUp, Check, Clipboard, SquareArrowUpRight, TriangleAlert } from '@sim/emcn/icons'
114
import { isEqual } from 'es-toolkit'
125
import { useParams } from 'next/navigation'
136
import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants'
147
import {
8+
CanonicalModeToggle,
159
CheckboxList,
1610
Code,
1711
ComboBox,
@@ -374,37 +368,11 @@ const renderLabel = (
374368
</Tooltip.Root>
375369
)}
376370
{showCanonicalToggle && (
377-
<Tooltip.Root>
378-
<Tooltip.Trigger asChild>
379-
<button
380-
type='button'
381-
className='flex size-[12px] shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
382-
onClick={canonicalToggle?.onToggle}
383-
disabled={canonicalToggleDisabledResolved}
384-
aria-label={
385-
canonicalToggle?.mode === 'advanced'
386-
? 'Switch to selector'
387-
: 'Switch to manual ID'
388-
}
389-
>
390-
<ArrowLeftRight
391-
className={cn(
392-
'h-[12px]! w-[12px]!',
393-
canonicalToggle?.mode === 'advanced'
394-
? 'text-[var(--text-primary)]'
395-
: 'text-[var(--text-secondary)]'
396-
)}
397-
/>
398-
</button>
399-
</Tooltip.Trigger>
400-
<Tooltip.Content side='top'>
401-
<p>
402-
{canonicalToggle?.mode === 'advanced'
403-
? 'Switch to selector'
404-
: 'Switch to manual ID'}
405-
</p>
406-
</Tooltip.Content>
407-
</Tooltip.Root>
371+
<CanonicalModeToggle
372+
mode={canonicalToggle?.mode ?? 'basic'}
373+
onToggle={canonicalToggle?.onToggle}
374+
disabled={canonicalToggleDisabledResolved}
375+
/>
408376
)}
409377
</div>
410378
</div>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/** @vitest-environment jsdom */
2+
import { act, useState } from 'react'
3+
import { IconSwitch } from '@sim/emcn'
4+
import { Code, List } from '@sim/emcn/icons'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const OPTIONS = [
9+
{ value: 'selector', label: 'Selector', icon: List },
10+
{ value: 'variable', label: 'Variable', icon: Code },
11+
] as const
12+
13+
interface HarnessProps {
14+
disabled?: boolean
15+
showTooltips?: boolean
16+
onValueChange: (value: string) => void
17+
}
18+
19+
function Harness({ disabled, showTooltips, onValueChange }: HarnessProps) {
20+
const [value, setValue] = useState('selector')
21+
return (
22+
<IconSwitch
23+
options={OPTIONS}
24+
value={value}
25+
onValueChange={(nextValue) => {
26+
setValue(nextValue)
27+
onValueChange(nextValue)
28+
}}
29+
disabled={disabled}
30+
showTooltips={showTooltips}
31+
aria-label='Input mode'
32+
/>
33+
)
34+
}
35+
36+
let root: Root | null = null
37+
let container: HTMLDivElement
38+
39+
beforeEach(() => {
40+
vi.useFakeTimers()
41+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
42+
container = document.createElement('div')
43+
document.body.appendChild(container)
44+
root = createRoot(container)
45+
})
46+
47+
afterEach(() => {
48+
act(() => root?.unmount())
49+
container.remove()
50+
root = null
51+
vi.useRealTimers()
52+
})
53+
54+
function mount(props: HarnessProps) {
55+
act(() => root?.render(<Harness {...props} />))
56+
return {
57+
inputs: [...container.querySelectorAll('input')],
58+
labels: [...container.querySelectorAll('label')],
59+
}
60+
}
61+
62+
describe('IconSwitch', () => {
63+
it('selects either mode without toggling an already selected choice', () => {
64+
const onValueChange = vi.fn()
65+
const { inputs, labels } = mount({ onValueChange })
66+
67+
act(() => labels[1].click())
68+
expect(inputs.map((input) => input.checked)).toEqual([false, true])
69+
expect(onValueChange).toHaveBeenLastCalledWith('variable')
70+
71+
act(() => labels[1].click())
72+
expect(onValueChange).toHaveBeenCalledTimes(1)
73+
74+
act(() => labels[0].click())
75+
expect(inputs.map((input) => input.checked)).toEqual([true, false])
76+
expect(onValueChange).toHaveBeenLastCalledWith('selector')
77+
})
78+
79+
it('prevents selection changes while disabled', () => {
80+
const onValueChange = vi.fn()
81+
const { inputs, labels } = mount({ disabled: true, onValueChange })
82+
83+
act(() => labels[1].click())
84+
expect(onValueChange).not.toHaveBeenCalled()
85+
expect(inputs.every((input) => input.disabled)).toBe(true)
86+
expect(inputs.map((input) => input.checked)).toEqual([true, false])
87+
})
88+
89+
it('shows each option label in its hover tooltip', () => {
90+
const { inputs } = mount({ showTooltips: true, onValueChange: vi.fn() })
91+
92+
for (const [index, option] of OPTIONS.entries()) {
93+
act(() => {
94+
inputs[index].dispatchEvent(
95+
new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
96+
)
97+
})
98+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(option.label)
99+
act(() => inputs[index].dispatchEvent(new MouseEvent('pointerout', { bubbles: true })))
100+
}
101+
})
102+
103+
it('shows a tooltip on keyboard focus without changing selection', () => {
104+
const onValueChange = vi.fn()
105+
const { inputs } = mount({ showTooltips: true, onValueChange })
106+
107+
act(() => inputs[1].focus())
108+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Variable')
109+
expect(onValueChange).not.toHaveBeenCalled()
110+
expect(inputs.map((input) => input.checked)).toEqual([true, false])
111+
})
112+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use client'
2+
3+
import { type ComponentType, useId } from 'react'
4+
import { cn, Tooltip } from '@sim/emcn'
5+
6+
export interface IconSwitchOption<T extends string = string> {
7+
value: T
8+
label: string
9+
icon: ComponentType<{ className?: string }>
10+
}
11+
12+
export interface IconSwitchProps<T extends string = string> {
13+
options: readonly [IconSwitchOption<T>, IconSwitchOption<T>]
14+
value: T
15+
onValueChange: (value: T) => void
16+
disabled?: boolean
17+
showTooltips?: boolean
18+
'aria-label': string
19+
className?: string
20+
}
21+
22+
/**
23+
* Two square icon choices inside a compact frame. Native radios provide mutually
24+
* exclusive selection and keyboard navigation; optional tooltips use each label.
25+
*
26+
* @example
27+
* <IconSwitch options={modes} value={mode} onValueChange={setMode} aria-label="Input mode" />
28+
*/
29+
export function IconSwitch<T extends string>({
30+
options,
31+
value,
32+
onValueChange,
33+
disabled = false,
34+
showTooltips = false,
35+
'aria-label': ariaLabel,
36+
className,
37+
}: IconSwitchProps<T>) {
38+
const groupName = useId()
39+
40+
return (
41+
<div
42+
role='radiogroup'
43+
aria-label={ariaLabel}
44+
className={cn(
45+
'inline-flex w-fit shrink-0 items-center gap-0.5 rounded-[5px] border border-[var(--border)] bg-[var(--surface-3)] p-0.5',
46+
disabled && 'opacity-50',
47+
className
48+
)}
49+
>
50+
{options.map((option) => {
51+
const Icon = option.icon
52+
const selected = option.value === value
53+
const optionId = `${groupName}-${option.value}`
54+
const input = (
55+
<input
56+
id={optionId}
57+
type='radio'
58+
name={groupName}
59+
value={option.value}
60+
checked={selected}
61+
onChange={() => onValueChange(option.value)}
62+
disabled={disabled}
63+
aria-label={option.label}
64+
className='peer m-0 size-5 cursor-pointer appearance-none rounded-[3px] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed'
65+
/>
66+
)
67+
68+
return (
69+
<label
70+
key={option.value}
71+
htmlFor={optionId}
72+
className={cn('relative flex', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}
73+
>
74+
{showTooltips ? (
75+
<Tooltip.Root>
76+
<Tooltip.Trigger asChild>{input}</Tooltip.Trigger>
77+
<Tooltip.Content side='top'>{option.label}</Tooltip.Content>
78+
</Tooltip.Root>
79+
) : (
80+
input
81+
)}
82+
<Icon
83+
aria-hidden='true'
84+
className={cn(
85+
'-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-1/2 size-[14px] transition-colors',
86+
selected
87+
? 'text-[var(--text-primary)]'
88+
: 'text-[var(--text-muted)] peer-hover:text-[var(--text-secondary)]'
89+
)}
90+
/>
91+
</label>
92+
)
93+
})}
94+
</div>
95+
)
96+
}

packages/emcn/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export {
132132
} from './dropdown-menu/dropdown-menu'
133133
export { Expandable, ExpandableContent } from './expandable/expandable'
134134
export { DashedDividerLine, FieldDivider } from './field-divider/field-divider'
135+
export { IconSwitch, type IconSwitchOption, type IconSwitchProps } from './icon-switch/icon-switch'
135136
export { Info } from './info/info'
136137
export {
137138
InfoCard,

0 commit comments

Comments
 (0)