mirror of
https://github.com/langgenius/dify.git
synced 2026-06-06 08:00:00 +08:00
refactor(dify-ui): rename toggle group to segmented control (#36605)
This commit is contained in:
+18
-10
@@ -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.
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+36
-21
@@ -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')
|
||||
+42
-42
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
+9
-9
@@ -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"
|
||||
+27
-29
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user