refactor(dify-ui): rename toggle group to segmented control (#36605)

This commit is contained in:
yyh
2026-05-25 12:57:39 +08:00
committed by GitHub
parent adb14d23de
commit 8876efb419
11 changed files with 181 additions and 137 deletions
+18 -10
View File
@@ -33,6 +33,7 @@ import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@@ -40,22 +41,29 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./pagination`, `./tabs`, `./toggle-group` | Pagination for page navigation; Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./pagination`, `./tabs` | Pagination for page navigation; Tabs for panels. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:
- `./cn``clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root.
## Segmented control contract
`SegmentedControl` is Dify's design-system primitive for mode, filter, and view selection. It is built on Base UI `ToggleGroup` + `Toggle`, so use `Tabs` instead when the UI needs `tablist` / `tabpanel` semantics.
Its value contract follows Base UI: `value`, `defaultValue`, and `onValueChange` use arrays, and single-selection mode may report an empty array when the active item is toggled off.
## Form contract
Dify UI's form primitives are a Base UI composition layer for native form semantics, field accessibility, and design-system styling. They are intentionally not a form state-management framework. See the upstream [Base UI forms handbook], [Base UI Form], [Base UI Field], and [Base UI Fieldset] docs for the underlying component contracts.
+4 -4
View File
@@ -113,14 +113,14 @@
"types": "./src/switch/index.tsx",
"import": "./src/switch/index.tsx"
},
"./segmented-control": {
"types": "./src/segmented-control/index.tsx",
"import": "./src/segmented-control/index.tsx"
},
"./tabs": {
"types": "./src/tabs/index.tsx",
"import": "./src/tabs/index.tsx"
},
"./toggle-group": {
"types": "./src/toggle-group/index.tsx",
"import": "./src/toggle-group/index.tsx"
},
"./toast": {
"types": "./src/toast/index.tsx",
"import": "./src/toast/index.tsx"
@@ -180,7 +180,7 @@ describe('Pagination primitive', () => {
expect(onPageChange).not.toHaveBeenCalled()
})
it('uses Base UI ToggleGroup semantics for page size', async () => {
it('uses segmented control semantics for page size', async () => {
const { screen, onPageSizeChange } = await renderPagination()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toHaveClass('bg-components-segmented-control-bg-normal')
@@ -58,7 +58,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a ToggleGroup-backed page-size selector.',
component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a SegmentedControl-backed page-size selector.',
},
},
},
+7 -7
View File
@@ -13,9 +13,9 @@ import {
NumberFieldInput,
} from '../number-field'
import {
ToggleGroup,
ToggleGroupItem,
} from '../toggle-group'
SegmentedControl,
SegmentedControlItem,
} from '../segmented-control'
type PageItem = number | 'ellipsis-start' | 'ellipsis-end'
@@ -523,7 +523,7 @@ export function PaginationPageSize<Value extends number = number>({
<div className="w-13 shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary opacity-0 transition-opacity group-hover/page-size:opacity-100 group-focus-within/page-size:opacity-100 motion-reduce:transition-none">
{label}
</div>
<ToggleGroup
<SegmentedControl
value={[String(value)]}
aria-label={ariaLabel}
onValueChange={(nextValue) => {
@@ -539,15 +539,15 @@ export function PaginationPageSize<Value extends number = number>({
}}
>
{options.map(option => (
<ToggleGroupItem
<SegmentedControlItem
key={option}
value={String(option)}
className="min-w-9 data-pressed:text-text-primary"
>
{option}
</ToggleGroupItem>
</SegmentedControlItem>
))}
</ToggleGroup>
</SegmentedControl>
</div>
)
}
@@ -1,19 +1,19 @@
import { render } from 'vitest-browser-react'
import {
ToggleGroup,
ToggleGroupDivider,
ToggleGroupItem,
SegmentedControl,
SegmentedControlDivider,
SegmentedControlItem,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('ToggleGroup wrappers', () => {
describe('SegmentedControl wrappers', () => {
it('renders a segmented control with Base UI pressed state', async () => {
const screen = await render(
<ToggleGroup defaultValue={['one']} aria-label="View">
<ToggleGroupItem value="one">One</ToggleGroupItem>
<ToggleGroupItem value="two">Two</ToggleGroupItem>
</ToggleGroup>,
<SegmentedControl defaultValue={['one']} aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
await expect.element(screen.getByRole('group')).toHaveClass(
@@ -30,10 +30,10 @@ describe('ToggleGroup wrappers', () => {
it('uses single selection by default', async () => {
const screen = await render(
<ToggleGroup defaultValue={['one']} aria-label="View">
<ToggleGroupItem value="one">One</ToggleGroupItem>
<ToggleGroupItem value="two">Two</ToggleGroupItem>
</ToggleGroup>,
<SegmentedControl defaultValue={['one']} aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
@@ -45,10 +45,10 @@ describe('ToggleGroup wrappers', () => {
it('calls onValueChange while leaving controlled value to the caller', async () => {
const onValueChange = vi.fn()
const screen = await render(
<ToggleGroup value={['one']} onValueChange={onValueChange} aria-label="View">
<ToggleGroupItem value="one">One</ToggleGroupItem>
<ToggleGroupItem value="two">Two</ToggleGroupItem>
</ToggleGroup>,
<SegmentedControl value={['one']} onValueChange={onValueChange} aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
@@ -57,13 +57,28 @@ describe('ToggleGroup wrappers', () => {
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true')
})
it('preserves Base UI empty-array behavior when a single selected item is toggled off', async () => {
const onValueChange = vi.fn()
const screen = await render(
<SegmentedControl value={['one']} onValueChange={onValueChange} aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
asHTMLElement(screen.getByRole('button', { name: 'One' }).element()).click()
expect(onValueChange).toHaveBeenCalledWith([], expect.anything())
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true')
})
it('forwards disabled and className to composable parts', async () => {
const screen = await render(
<ToggleGroup defaultValue={['one']} aria-label="View" className="custom-group">
<ToggleGroupItem value="one" className="custom-item">One</ToggleGroupItem>
<ToggleGroupDivider className="custom-divider" data-testid="divider" />
<ToggleGroupItem value="two" disabled>Two</ToggleGroupItem>
</ToggleGroup>,
<SegmentedControl defaultValue={['one']} aria-label="View" className="custom-group">
<SegmentedControlItem value="one" className="custom-item">One</SegmentedControlItem>
<SegmentedControlDivider className="custom-divider" data-testid="divider" />
<SegmentedControlItem value="two" disabled>Two</SegmentedControlItem>
</SegmentedControl>,
)
await expect.element(screen.getByRole('group')).toHaveClass('custom-group')
@@ -1,24 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ReactNode } from 'react'
import {
ToggleGroup,
ToggleGroupDivider,
ToggleGroupItem,
SegmentedControl,
SegmentedControlDivider,
SegmentedControlItem,
} from '.'
const meta = {
title: 'Base/UI/ToggleGroup',
component: ToggleGroup,
title: 'Base/UI/SegmentedControl',
component: SegmentedControl,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use this for mode, filter, and view selection that does not need tabpanel semantics.',
component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use it for mode, filter, and view selection that does not need tabpanel semantics.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ToggleGroup>
} satisfies Meta<typeof SegmentedControl>
export default meta
type Story = StoryObj<typeof meta>
@@ -41,21 +41,21 @@ const Item = () => (
</>
)
function SegmentedControl({
function SegmentedControlExample({
defaultValue,
values,
iconOnly = false,
noPadding = false,
}: SegmentedControlProps) {
return (
<ToggleGroup
<SegmentedControl
defaultValue={[defaultValue]}
aria-label="Segmented control"
className={noPadding ? 'rounded-lg border-[0.5px] border-divider-subtle p-0' : undefined}
>
{values.map((itemValue, index) => (
<span key={itemValue} className="relative flex items-center">
<ToggleGroupItem
<SegmentedControlItem
value={itemValue}
aria-label={iconOnly ? `Item ${index + 1}` : undefined}
>
@@ -63,15 +63,15 @@ function SegmentedControl({
{!iconOnly && (
<span className="px-0.5">Item</span>
)}
</ToggleGroupItem>
</SegmentedControlItem>
{index === 1 && (
<span className="pointer-events-none absolute top-0 -right-px flex h-full items-center" aria-hidden="true">
<ToggleGroupDivider />
<SegmentedControlDivider />
</span>
)}
</span>
))}
</ToggleGroup>
</SegmentedControl>
)
}
@@ -80,10 +80,10 @@ function SpecColumn() {
return (
<div className="flex flex-col items-center gap-6">
<SegmentedControl defaultValue="one" values={values} />
<SegmentedControl defaultValue="one" values={values} iconOnly />
<SegmentedControl defaultValue="one" values={values} noPadding />
<SegmentedControl defaultValue="one" values={values} iconOnly noPadding />
<SegmentedControlExample defaultValue="one" values={values} />
<SegmentedControlExample defaultValue="one" values={values} iconOnly />
<SegmentedControlExample defaultValue="one" values={values} noPadding />
<SegmentedControlExample defaultValue="one" values={values} iconOnly noPadding />
</div>
)
}
@@ -124,53 +124,53 @@ export const DesignSpec: Story = {
export const DataAttributeStates: Story = {
render: () => (
<div className="flex flex-col gap-5">
<ToggleGroup defaultValue={['active']} aria-label="Basic states">
<ToggleGroupItem value="default">
<SegmentedControl defaultValue={['active']} aria-label="Basic states">
<SegmentedControlItem value="default">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="active">
</SegmentedControlItem>
<SegmentedControlItem value="active">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="disabled" disabled>
</SegmentedControlItem>
<SegmentedControlItem value="disabled" disabled>
<Item />
</ToggleGroupItem>
</ToggleGroup>
</SegmentedControlItem>
</SegmentedControl>
<ToggleGroup defaultValue={['accent-light']} aria-label="Active states">
<ToggleGroupItem value="accent-light">
<SegmentedControl defaultValue={['accent-light']} aria-label="Active states">
<SegmentedControlItem value="accent-light">
<Item />
</ToggleGroupItem>
<ToggleGroupItem
</SegmentedControlItem>
<SegmentedControlItem
value="neutral"
className="data-pressed:text-text-primary"
>
<Item />
</ToggleGroupItem>
<ToggleGroupItem
</SegmentedControlItem>
<SegmentedControlItem
value="accent"
className="data-pressed:border-components-segmented-control-item-active-accent-border data-pressed:bg-components-segmented-control-item-active-accent-bg data-pressed:text-text-accent"
>
<Item />
</ToggleGroupItem>
</ToggleGroup>
</SegmentedControlItem>
</SegmentedControl>
<ToggleGroup defaultValue={['one', 'three']} multiple aria-label="Multiple selection">
<ToggleGroupItem value="one">
<SegmentedControl defaultValue={['one', 'three']} multiple aria-label="Multiple selection">
<SegmentedControlItem value="one">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="two">
</SegmentedControlItem>
<SegmentedControlItem value="two">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="three">
</SegmentedControlItem>
<SegmentedControlItem value="three">
<Item />
</ToggleGroupItem>
</ToggleGroup>
</SegmentedControlItem>
</SegmentedControl>
</div>
),
parameters: {
docs: {
description: {
story: '`ToggleGroupItem` gets `data-pressed` and `data-disabled` from Base UI. Accent, neutral, and multiple-selection examples are composed through props and className.',
story: '`SegmentedControlItem` gets `data-pressed` and `data-disabled` from Base UI Toggle. Accent, neutral, and multiple-selection examples are composed through props and className.',
},
},
},
@@ -7,14 +7,14 @@ import { Toggle as BaseToggle } from '@base-ui/react/toggle'
import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group'
import { cn } from '../cn'
export type ToggleGroupProps<Value extends string = string> = Omit<BaseToggleGroupNS.Props<Value>, 'className'> & {
export type SegmentedControlProps<Value extends string = string> = Omit<BaseToggleGroupNS.Props<Value>, 'className'> & {
className?: string
}
export function ToggleGroup<Value extends string = string>({
export function SegmentedControl<Value extends string = string>({
className,
...props
}: ToggleGroupProps<Value>) {
}: SegmentedControlProps<Value>) {
return (
<BaseToggleGroup
className={cn('inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', className)}
@@ -23,14 +23,14 @@ export function ToggleGroup<Value extends string = string>({
)
}
export type ToggleGroupItemProps<Value extends string = string> = Omit<BaseToggleNS.Props<Value>, 'className'> & {
export type SegmentedControlItemProps<Value extends string = string> = Omit<BaseToggleNS.Props<Value>, 'className'> & {
className?: string
}
export function ToggleGroupItem<Value extends string = string>({
export function SegmentedControlItem<Value extends string = string>({
className,
...props
}: ToggleGroupItemProps<Value>) {
}: SegmentedControlItemProps<Value>) {
return (
<BaseToggle
className={cn('relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden whitespace-nowrap rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover data-pressed:border-components-segmented-control-item-active-border data-pressed:bg-components-segmented-control-item-active-bg data-pressed:text-text-accent-light-mode-only data-pressed:shadow-xs data-pressed:shadow-shadow-shadow-3 data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-text-disabled data-disabled:shadow-none data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled motion-reduce:transition-none', className)}
@@ -39,14 +39,14 @@ export function ToggleGroupItem<Value extends string = string>({
)
}
export type ToggleGroupDividerProps = Omit<HTMLAttributes<HTMLSpanElement>, 'className'> & {
export type SegmentedControlDividerProps = Omit<HTMLAttributes<HTMLSpanElement>, 'className'> & {
className?: string
}
export function ToggleGroupDivider({
export function SegmentedControlDivider({
className,
...props
}: ToggleGroupDividerProps) {
}: SegmentedControlDividerProps) {
return (
<span
role="presentation"
@@ -1,8 +1,8 @@
import type { SchemaRoot } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { toast } from '@langgenius/dify-ui/toast'
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
@@ -46,7 +46,7 @@ function BracesIcon({ className }: IconProps) {
return <span className={cn('i-ri-braces-line', className)} />
}
const VIEW_TABS = [
const SCHEMA_VIEW_OPTIONS = [
{ Icon: TimelineViewIcon, text: 'Visual Editor', value: SchemaView.VisualEditor },
{ Icon: BracesIcon, text: 'JSON Schema', value: SchemaView.JsonSchema },
]
@@ -64,7 +64,7 @@ function JsonSchemaConfigContent({
onClose,
}: JsonSchemaConfigProps) {
const { t } = useTranslation()
const [currentTab, setCurrentTab] = useState<readonly SchemaView[]>([SchemaView.VisualEditor])
const [selectedSchemaViews, setSelectedSchemaViews] = useState<readonly SchemaView[]>([SchemaView.VisualEditor])
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2))
const [btnWidth, setBtnWidth] = useState(0)
@@ -76,16 +76,16 @@ function JsonSchemaConfigContent({
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const { emit } = useMittContext()
const selectedTab = currentTab[0] ?? SchemaView.VisualEditor
const selectedSchemaView = selectedSchemaViews[0] ?? SchemaView.VisualEditor
function updateBtnWidth(width: number) {
setBtnWidth(width + 32)
}
function handleTabChange(value: SchemaView) {
if (selectedTab === value)
function handleSchemaViewChange(value: SchemaView) {
if (selectedSchemaView === value)
return
if (selectedTab === SchemaView.JsonSchema) {
if (selectedSchemaView === SchemaView.JsonSchema) {
try {
const schema = JSON.parse(json)
setParseError(null)
@@ -116,28 +116,28 @@ function JsonSchemaConfigContent({
return
}
}
else if (selectedTab === SchemaView.VisualEditor) {
else if (selectedSchemaView === SchemaView.VisualEditor) {
if (advancedEditing || isAddingNewField)
emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) })
else
setJson(JSON.stringify(jsonSchema, null, 2))
}
setCurrentTab([value])
setSelectedSchemaViews([value])
}
function handleApplySchema(schema: SchemaRoot) {
if (selectedTab === SchemaView.VisualEditor)
if (selectedSchemaView === SchemaView.VisualEditor)
setJsonSchema(schema)
else if (selectedTab === SchemaView.JsonSchema)
else if (selectedSchemaView === SchemaView.JsonSchema)
setJson(JSON.stringify(schema, null, 2))
}
function handleSubmit(schema: Record<string, unknown>) {
const jsonSchema = jsonToSchema(schema) as SchemaRoot
if (selectedTab === SchemaView.VisualEditor)
if (selectedSchemaView === SchemaView.VisualEditor)
setJsonSchema(jsonSchema)
else if (selectedTab === SchemaView.JsonSchema)
else if (selectedSchemaView === SchemaView.JsonSchema)
setJson(JSON.stringify(jsonSchema, null, 2))
}
@@ -150,7 +150,7 @@ function JsonSchemaConfigContent({
}
function handleResetDefaults() {
if (selectedTab === SchemaView.VisualEditor) {
if (selectedSchemaView === SchemaView.VisualEditor) {
setHoveringProperty(null)
if (advancedEditing)
setAdvancedEditing(false)
@@ -167,7 +167,7 @@ function JsonSchemaConfigContent({
function handleSave() {
let schema = jsonSchema
if (selectedTab === SchemaView.JsonSchema) {
if (selectedSchemaView === SchemaView.JsonSchema) {
try {
schema = JSON.parse(json)
setParseError(null)
@@ -198,7 +198,7 @@ function JsonSchemaConfigContent({
return
}
}
else if (selectedTab === SchemaView.VisualEditor) {
else if (selectedSchemaView === SchemaView.VisualEditor) {
if (advancedEditing || isAddingNewField) {
toast.warning(t('nodes.llm.jsonSchema.warningTips.saveSchema', { ns: 'workflow' }))
return
@@ -224,25 +224,23 @@ function JsonSchemaConfigContent({
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" />
</button>
</div>
{/* Content */}
<div className="flex items-center justify-between px-6 py-2">
{/* Tab */}
<ToggleGroup<SchemaView>
<SegmentedControl<SchemaView>
aria-label={t('nodes.llm.jsonSchema.title', { ns: 'workflow' })}
value={currentTab}
onValueChange={(nextTab) => {
const value = nextTab[0]
value={selectedSchemaViews}
onValueChange={(nextSchemaViews) => {
const value = nextSchemaViews[0]
if (value)
handleTabChange(value)
handleSchemaViewChange(value)
}}
>
{VIEW_TABS.map(({ Icon, text, value }) => (
<ToggleGroupItem key={value} value={value}>
{SCHEMA_VIEW_OPTIONS.map(({ Icon, text, value }) => (
<SegmentedControlItem key={value} value={value}>
<Icon className="size-4 shrink-0" />
<span className="p-0.5">{text}</span>
</ToggleGroupItem>
</SegmentedControlItem>
))}
</ToggleGroup>
</SegmentedControl>
<div className="flex items-center gap-x-0.5">
{/* JSON Schema Generator */}
<JsonSchemaGenerator
@@ -258,13 +256,13 @@ function JsonSchemaConfigContent({
</div>
</div>
<div className="flex grow flex-col gap-y-1 overflow-hidden px-6">
{selectedTab === SchemaView.VisualEditor && (
{selectedSchemaView === SchemaView.VisualEditor && (
<VisualEditor
schema={jsonSchema}
onChange={handleVisualEditorUpdate}
/>
)}
{selectedTab === SchemaView.JsonSchema && (
{selectedSchemaView === SchemaView.JsonSchema && (
<SchemaEditor
schema={json}
onUpdate={handleSchemaEditorUpdate}
@@ -3,15 +3,19 @@ import { DisplayContent } from '../display-content'
import { PreviewType } from '../types'
describe('variable inspect display content', () => {
const baseProps = {
previewType: PreviewType.Markdown,
varType: 'string' as never,
mdString: 'hello markdown',
readonly: false,
}
it('renders markdown code view and forwards text edits', () => {
const handleTextChange = vi.fn()
render(
<DisplayContent
previewType={PreviewType.Markdown}
varType={'string' as never}
mdString="hello markdown"
readonly={false}
{...baseProps}
handleTextChange={handleTextChange}
/>,
)
@@ -24,4 +28,16 @@ describe('variable inspect display content', () => {
expect(handleTextChange).toHaveBeenCalledWith('updated markdown')
})
it('keeps the active view selected when clicking the selected segmented control item', () => {
render(<DisplayContent {...baseProps} />)
const codeButton = screen.getByRole('button', { name: 'workflow.nodes.templateTransform.code' })
expect(codeButton).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(codeButton)
expect(codeButton).toHaveAttribute('aria-pressed', 'true')
})
})
@@ -2,7 +2,7 @@ import type { VarType } from '../types'
import type { ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list/types'
import type { ParentMode } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/app/components/base/markdown'
@@ -26,14 +26,21 @@ type DisplayContentProps = {
export function DisplayContent(props: DisplayContentProps) {
const { previewType, varType, schemaType, mdString, jsonString, readonly, handleTextChange, handleEditorChange, className } = props
const [viewMode, setViewMode] = useState<readonly ViewMode[]>([ViewMode.Code])
const [selectedViewModes, setSelectedViewModes] = useState<readonly ViewMode[]>([ViewMode.Code])
const [isFocused, setIsFocused] = useState(false)
const { t } = useTranslation()
const viewOptions = [
{ value: ViewMode.Code, label: t('nodes.templateTransform.code', { ns: 'workflow' }), iconClassName: 'i-ri-braces-line' },
{ value: ViewMode.Preview, label: t('common.preview', { ns: 'workflow' }), iconClassName: 'i-ri-eye-line' },
]
const selectedViewMode = viewMode[0] ?? ViewMode.Code
const selectedViewMode = selectedViewModes[0] ?? ViewMode.Code
function handleViewModeChange(nextViewModes: ViewMode[]) {
const nextViewMode = nextViewModes[0]
if (nextViewMode)
setSelectedViewModes([nextViewMode])
}
const chunkType = useMemo(() => {
if (previewType !== PreviewType.Chunks || !schemaType)
@@ -68,23 +75,23 @@ export function DisplayContent(props: DisplayContentProps) {
{schemaType ? `(${schemaType})` : ''}
</div>
)}
<ToggleGroup<ViewMode>
<SegmentedControl<ViewMode>
aria-label={t('common.preview', { ns: 'workflow' })}
value={viewMode}
onValueChange={setViewMode}
value={selectedViewModes}
onValueChange={handleViewModeChange}
className="shrink-0 rounded-md p-px"
>
{viewOptions.map(({ value, label, iconClassName }) => (
<ToggleGroupItem
<SegmentedControlItem
key={value}
value={value}
className="h-[22px] gap-[3px] rounded-md p-px pr-0.5 pl-1.5 text-text-tertiary data-pressed:text-text-accent-light-mode-only"
>
<i className={cn('size-4 shrink-0', iconClassName)} aria-hidden="true" />
<span className="p-0.5 pr-1">{label}</span>
</ToggleGroupItem>
</SegmentedControlItem>
))}
</ToggleGroup>
</SegmentedControl>
</div>
<div className="flex flex-1 overflow-auto rounded-b-[10px] pr-1 pl-3">
{selectedViewMode === ViewMode.Code && (