diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index f3890dea07..dbf6dbac17 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -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. diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 75181b521e..d98543960c 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -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" diff --git a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx index 254b08c86e..b52c81727e 100644 --- a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/pagination/__tests__/index.spec.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') diff --git a/packages/dify-ui/src/pagination/index.stories.tsx b/packages/dify-ui/src/pagination/index.stories.tsx index 53b5023bf8..8b016a2ef2 100644 --- a/packages/dify-ui/src/pagination/index.stories.tsx +++ b/packages/dify-ui/src/pagination/index.stories.tsx @@ -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.', }, }, }, diff --git a/packages/dify-ui/src/pagination/index.tsx b/packages/dify-ui/src/pagination/index.tsx index 447f02fd9b..06086c66e1 100644 --- a/packages/dify-ui/src/pagination/index.tsx +++ b/packages/dify-ui/src/pagination/index.tsx @@ -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({
{label}
- { @@ -539,15 +539,15 @@ export function PaginationPageSize({ }} > {options.map(option => ( - {option} - + ))} - + ) } diff --git a/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx similarity index 52% rename from packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx rename to packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx index ec6e7351e2..401e28489b 100644 --- a/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx @@ -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( - - One - Two - , + + One + Two + , ) 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( - - One - Two - , + + One + Two + , ) 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( - - One - Two - , + + One + Two + , ) 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( + + One + Two + , + ) + + 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( - - One - - Two - , + + One + + Two + , ) await expect.element(screen.getByRole('group')).toHaveClass('custom-group') diff --git a/packages/dify-ui/src/toggle-group/index.stories.tsx b/packages/dify-ui/src/segmented-control/index.stories.tsx similarity index 60% rename from packages/dify-ui/src/toggle-group/index.stories.tsx rename to packages/dify-ui/src/segmented-control/index.stories.tsx index 960957b7ab..f03dacc2b9 100644 --- a/packages/dify-ui/src/toggle-group/index.stories.tsx +++ b/packages/dify-ui/src/segmented-control/index.stories.tsx @@ -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 +} satisfies Meta export default meta type Story = StoryObj @@ -41,21 +41,21 @@ const Item = () => ( ) -function SegmentedControl({ +function SegmentedControlExample({ defaultValue, values, iconOnly = false, noPadding = false, }: SegmentedControlProps) { return ( - {values.map((itemValue, index) => ( - @@ -63,15 +63,15 @@ function SegmentedControl({ {!iconOnly && ( Item )} - + {index === 1 && ( )} ))} - + ) } @@ -80,10 +80,10 @@ function SpecColumn() { return (
- - - - + + + +
) } @@ -124,53 +124,53 @@ export const DesignSpec: Story = { export const DataAttributeStates: Story = { render: () => (
- - + + - - + + - - + + - - + + - - + + - - + - - + - - + + - - + + - - + + - - + + - - + +
), 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.', }, }, }, diff --git a/packages/dify-ui/src/toggle-group/index.tsx b/packages/dify-ui/src/segmented-control/index.tsx similarity index 74% rename from packages/dify-ui/src/toggle-group/index.tsx rename to packages/dify-ui/src/segmented-control/index.tsx index 661385a132..87bfb07fa0 100644 --- a/packages/dify-ui/src/toggle-group/index.tsx +++ b/packages/dify-ui/src/segmented-control/index.tsx @@ -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 = Omit, 'className'> & { +export type SegmentedControlProps = Omit, 'className'> & { className?: string } -export function ToggleGroup({ +export function SegmentedControl({ className, ...props -}: ToggleGroupProps) { +}: SegmentedControlProps) { return ( ({ ) } -export type ToggleGroupItemProps = Omit, 'className'> & { +export type SegmentedControlItemProps = Omit, 'className'> & { className?: string } -export function ToggleGroupItem({ +export function SegmentedControlItem({ className, ...props -}: ToggleGroupItemProps) { +}: SegmentedControlItemProps) { return ( ({ ) } -export type ToggleGroupDividerProps = Omit, 'className'> & { +export type SegmentedControlDividerProps = Omit, 'className'> & { className?: string } -export function ToggleGroupDivider({ +export function SegmentedControlDivider({ className, ...props -}: ToggleGroupDividerProps) { +}: SegmentedControlDividerProps) { return ( } -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([SchemaView.VisualEditor]) + const [selectedSchemaViews, setSelectedSchemaViews] = useState([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) { 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({ - {/* Content */}
- {/* Tab */} - + 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 }) => ( - + {SCHEMA_VIEW_OPTIONS.map(({ Icon, text, value }) => ( + {text} - + ))} - +
{/* JSON Schema Generator */}
- {selectedTab === SchemaView.VisualEditor && ( + {selectedSchemaView === SchemaView.VisualEditor && ( )} - {selectedTab === SchemaView.JsonSchema && ( + {selectedSchemaView === SchemaView.JsonSchema && ( { + 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( , ) @@ -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() + + 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') + }) }) diff --git a/web/app/components/workflow/variable-inspect/display-content.tsx b/web/app/components/workflow/variable-inspect/display-content.tsx index 11ee2a7a42..5c893fc425 100644 --- a/web/app/components/workflow/variable-inspect/display-content.tsx +++ b/web/app/components/workflow/variable-inspect/display-content.tsx @@ -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([ViewMode.Code]) + const [selectedViewModes, setSelectedViewModes] = useState([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})` : ''}
)} - + 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 }) => ( -